# How to Add Custom Page Analysis Checks to the OpenSEO Site Audit System

> Enhance your OpenSEO site audit by adding custom page analysis checks. Implement per-page reporters or multipage checks and register them to gain deeper SEO insights.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: how-to-guide
- Published: 2026-07-27

---

**You can add custom page analysis checks to OpenSEO by implementing either per-page reporters in [`page-reporters.ts`](https://github.com/every-app/open-seo/blob/main/page-reporters.ts) for single-page inspections or multipage checks in [`multipage-checks.ts`](https://github.com/every-app/open-seo/blob/main/multipage-checks.ts) for cross-page analysis, then registering them in the respective orchestrators.**

The OpenSEO site audit system uses a modular, two-stage architecture that makes it straightforward to extend with custom validation logic. Whether you need to flag missing hreflang attributes on individual pages or detect duplicate titles across your entire site, the codebase provides clear extension points for both scenarios. This guide walks through the exact file locations and function signatures required to add custom page analysis checks to the site audit system.

## Understanding the OpenSEO Audit Architecture

The audit engine processes crawled data in two distinct phases:

- **Per-page reporters** – Pure functions that inspect a single `CrawledPageResult` object and emit `DetectedIssue` objects. These run immediately as each page is crawled via `runPageReporters()` in [[`src/server/lib/audit/issues/page-reporters.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/page-reporters.ts)](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/page-reporters.ts).

- **Cross-page (multipage) checks** – Functions that receive an array of `SlimPage` objects after the crawl finishes. These are implemented in [[`src/server/lib/audit/issues/multipage-checks.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage-checks.ts)](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage-checks.ts) and orchestrated from [[`src/server/lib/audit/issues/multipage.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts)](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts).

Both stages produce `DetectedIssue` objects that are persisted to the audit database by [[`src/server/workflows/siteAuditWorkflowCrawl.ts`](https://github.com/every-app/open-seo/blob/main/src/server/workflows/siteAuditWorkflowCrawl.ts)](https://github.com/every-app/open-seo/blob/main/src/server/workflows/siteAuditWorkflowCrawl.ts).

## Adding a Per-Page Analysis Check

Per-page checks are ideal for validating HTML content, metadata, or response headers on individual URLs.

### Register the Issue Type

First, extend the `AUDIT_ISSUE_TYPES` map in [[`src/shared/audit-issues.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/audit-issues.ts)](https://github.com/every-app/open-seo/blob/main/src/shared/audit-issues.ts) to define your check's metadata:

```typescript
// src/shared/audit-issues.ts
export const AUDIT_ISSUE_TYPES = {
  // ... existing types
  "missing-hreflang": {
    severity: "warning",
    title: "Missing Hreflang Attribute",
    explanation: "The page lacks hreflang annotations for international targeting.",
    howToFix: "Add <link rel=\"alternate\" hreflang=\"x\" href=\"...\" /> tags.",
  },
} as const;

```

### Implement the Reporter Function

Create a pure function that accepts a `CrawledPageResult` and returns a `DetectedIssue` or `null`:

```typescript
// src/server/lib/audit/issues/page-reporters.ts
function checkMissingHreflang(page: CrawledPageResult): DetectedIssue | null {
  if (page.isHtml && !page.hreflang?.length) {
    return {
      issueType: "missing-hreflang",
      pageId: page.id,
      pageUrl: page.url,
      details: { reason: "No <link rel=\"alternate\" hreflang> found" },
    };
  }
  return null;
}

```

### Integrate into the Pipeline

Hook your function into `runPageReporters()` (around line 42) to include it in the audit flow:

```typescript
// src/server/lib/audit/issues/page-reporters.ts
export function runPageReporters(page: CrawledPageResult): DetectedIssue[] {
  const issues: DetectedIssue[] = [];
  
  // ... existing checks
  
  const issue = checkMissingHreflang(page);
  if (issue) issues.push(issue);
  
  return issues;
}

```

### Write Tests

Add test coverage in [[`src/server/lib/audit/issues/page-reporters.test.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/page-reporters.test.ts)](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/page-reporters.test.ts) to verify your check detects the condition correctly:

```typescript
// src/server/lib/audit/issues/page-reporters.test.ts
it("should detect missing hreflang", () => {
  const page = createMockPage({ isHtml: true, hreflang: [] });
  const issues = runPageReporters(page);
  expect(issues).toContainEqual(
    expect.objectContaining({ issueType: "missing-hreflang" })
  );
});

```

## Adding a Cross-Page (Multipage) Check

Multipage checks are designed for site-wide analysis like finding duplicate titles or redirect chains.

### Define the Issue Type

As with per-page checks, add your issue type to `AUDIT_ISSUE_TYPES` in [[`src/shared/audit-issues.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/audit-issues.ts)](https://github.com/every-app/open-seo/blob/main/src/shared/audit-issues.ts):

```typescript
// src/shared/audit-issues.ts
"excessive-word-count": {
  severity: "info",
  title: "Excessive Word Count",
  explanation: "Page contains unusually high word count.",
  howToFix: "Consider splitting content into multiple pages.",
},

```

### Create the Check Function

Implement a function that analyzes the full array of `SlimPage` objects:

```typescript
// src/server/lib/audit/issues/multipage-checks.ts
export function findHugePages(pages: SlimPage[]): DetectedIssue[] {
  const issues: DetectedIssue[] = [];
  for (const p of pages) {
    if (p.wordCount > 10_000) {
      issues.push({
        issueType: "excessive-word-count",
        pageId: p.id,
        pageUrl: p.url,
        details: { wordCount: p.wordCount },
      });
    }
  }
  return issues;
}

```

### Wire into the Multipage Runner

Import and invoke your function in [[`src/server/lib/audit/issues/multipage.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts)](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts):

```typescript
// src/server/lib/audit/issues/multipage.ts
import { findHugePages } from "./multipage-checks";

export async function runMultipageChecks(pages: SlimPage[]): Promise<DetectedIssue[]> {
  const issues = [
    ...findDuplicates(pages),
    ...findRedirectChainsAndLoops(pages),
    ...findHugePages(pages), // ← new check added here
  ];
  return issues;
}

```

The `runMultipageChecks` function is called after the crawl completes. The returned issues are automatically persisted by `AuditRepository.insertCrawledBatch()` via the workflow in [[`src/server/workflows/siteAuditWorkflowCrawl.ts`](https://github.com/every-app/open-seo/blob/main/src/server/workflows/siteAuditWorkflowCrawl.ts)](https://github.com/every-app/open-seo/blob/main/src/server/workflows/siteAuditWorkflowCrawl.ts).

### Testing Cross-Page Logic

Add unit tests in [[`src/server/lib/audit/issues/multipage-checks.test.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage-checks.test.ts)](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage-checks.test.ts):

```typescript
// src/server/lib/audit/issues/multipage-checks.test.ts
it("should flag pages with excessive word count", () => {
  const pages = [
    { id: "1", url: "/long-post", wordCount: 15000 },
    { id: "2", url: "/short-post", wordCount: 500 },
  ];
  const issues = findHugePages(pages);
  expect(issues).toHaveLength(1);
  expect(issues[0].pageId).toBe("1");
});

```

## Summary

- **Per-page reporters** validate individual `CrawledPageResult` objects during the crawl phase via `runPageReporters()` in [`page-reporters.ts`](https://github.com/every-app/open-seo/blob/main/page-reporters.ts).
- **Multipage checks** analyze arrays of `SlimPage` objects after crawling completes via `runMultipageChecks()` in [`multipage.ts`](https://github.com/every-app/open-seo/blob/main/multipage.ts).
- **Issue registration** requires adding entries to `AUDIT_ISSUE_TYPES` in [`src/shared/audit-issues.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/audit-issues.ts) for proper metadata and severity levels.
- **Persistence** is handled automatically by the site audit workflow; you only need to return `DetectedIssue` objects from your functions.
- **Testing** should cover both positive detection cases and false-negative scenarios in the corresponding `*.test.ts` files.

## Frequently Asked Questions

### What is the difference between per-page and multipage checks?

**Per-page checks** inspect a single page's content in isolation, such as validating meta descriptions or checking for specific HTML tags. **Multipage checks** analyze relationships across the entire site, such as detecting duplicate titles or redirect chains that span multiple URLs. Per-page checks run during the crawl via `runPageReporters()`, while multipage checks execute after the crawl finishes via `runMultipageChecks()`.

### How do I register a new issue type in OpenSEO?

Register new issue types by extending the `AUDIT_ISSUE_TYPES` constant object in [[`src/shared/audit-issues.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/audit-issues.ts)](https://github.com/every-app/open-seo/blob/main/src/shared/audit-issues.ts). Each entry requires a unique key, severity level ("error", "warning", or "info"), title, explanation, and remediation instructions. This registry enables the dashboard to display human-readable descriptions of your custom checks.

### Where are audit issues stored after detection?

Audit issues are persisted to the database by [[`src/server/workflows/siteAuditWorkflowCrawl.ts`](https://github.com/every-app/open-seo/blob/main/src/server/workflows/siteAuditWorkflowCrawl.ts)](https://github.com/every-app/open-seo/blob/main/src/server/workflows/siteAuditWorkflowCrawl.ts), which calls `runPageReporters()` for each crawled page and later aggregates multipage check results. The issues are inserted via `AuditRepository.insertCrawledBatch()`; no manual database code is required when adding new checks.

### Can I access the full page HTML in multipage checks?

**No.** Multipage checks receive `SlimPage` objects, which contain lightweight metadata such as URL, title, word count, and status code, but not the full HTML content. If your check requires analyzing HTML structure, body content, or headers, implement it as a **per-page reporter** that receives the complete `CrawledPageResult` object containing the raw response body and parsed DOM.