# How to Add Custom Audit Checks to OpenSEO: A Complete Guide

> Learn to add custom audit checks to OpenSEO's site auditor. Implement pure functions and register them for page or site-wide analysis to enhance your SEO strategy.

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

---

**To add custom audit checks to OpenSEO, implement a pure function that returns `DetectedIssue` objects and register it in either `runPageReporters` (for page-level analysis) or `runMultipageChecks` (for site-wide validation).**

OpenSEO (every-app/open-seo) provides a flexible site auditing engine designed for extensibility. Whether you need to flag missing meta descriptions or detect structural patterns across your entire site, you can add custom audit checks to OpenSEO by hooking into its two-phase reporting architecture. The system automatically persists your custom issues to the database, making them immediately available in the UI, CSV exports, and API responses.

## Understanding the Two-Phase Audit Architecture

OpenSEO executes site audits in two distinct phases, each designed for specific types of analysis:

- **Per-page checks** – Located 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), the `runPageReporters` function executes pure functions against individual crawled pages (`CrawledPageResult`).
- **Multi-page (site-wide) checks** – Located in [`src/server/lib/audit/issues/multipage.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts), the `runMultipageChecks` function analyzes the complete set of pages using helpers from [`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).

Both phases feed an array of `DetectedIssue` objects into `AuditRepository.insertIssues`, which stores them in the `audit_issues` table.

## The DetectedIssue Contract

Every custom check must return objects conforming to the `DetectedIssue` interface defined 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):

```typescript
export interface DetectedIssue {
  /** Human-readable title for the issue (e.g. “Missing meta description”). */
  title: string;
  /** Detailed description shown in the UI. */
  description: string;
  /** Identifier used for filtering / CSV export. */
  type: string;
  /** Optional URL of the page the issue belongs to (for per-page checks). */
  url?: string;
}

```

The `type` field serves as the unique identifier for filtering and reporting, while `url` associates the issue with a specific page when applicable.

## Adding Per-Page Custom Checks

Per-page checks analyze individual `CrawledPageResult` objects immediately after crawling. Follow these three steps to add a custom audit check to the per-page pipeline:

1. **Create a reporter function** that receives a `CrawledPageResult` and returns `DetectedIssue[]`.
2. **Export the function** from a dedicated file (e.g., [`custom-page-reporters.ts`](https://github.com/every-app/open-seo/blob/main/custom-page-reporters.ts)).
3. **Register the reporter** by invoking it inside `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).

### Example: Detecting Missing Meta Descriptions

Create the reporter in [`src/server/lib/audit/issues/custom-page-reporters.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/custom-page-reporters.ts):

```typescript
import type { CrawledPageResult } from '@/server/lib/audit/types';
import type { DetectedIssue } from '@/server/lib/audit/issues/page-reporters';

export function missingMetaDescription(page: CrawledPageResult): DetectedIssue[] {
  const hasDescription = page.html?.match(/<meta\s+name=["']description["']\s+content=["'][^"']+["']\s*\/?>/i);
  if (!hasDescription) {
    return [
      {
        title: 'Missing meta description',
        description: 'The page does not contain a <meta name="description"> tag, which is important for click-through rate.',
        type: 'missing_meta_description',
        url: page.url,
      },
    ];
  }
  return [];
}

```

Then register it 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):

```typescript
import { missingMetaDescription } from './custom-page-reporters';

export function runPageReporters(page: CrawledPageResult): DetectedIssue[] {
  const issues: DetectedIssue[] = [];

  // Existing reporters …
  issues.push(...missingMetaDescription(page));

  return issues;
}

```

The `runPageReporters` implementation processes each crawled page independently at line 42 of the source file.

## Adding Site-Wide Custom Checks

Site-wide checks analyze patterns across the entire crawl, such as duplicate content or redirect chains. These checks operate on `SlimPage` objects in [`src/server/lib/audit/issues/multipage.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts):

1. **Create a helper function** 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) that receives `SlimPage[]` and returns `DetectedIssue[]`.
2. **Export the helper** for use in the main runner.
3. **Invoke the helper** inside `runMultipageChecks` in [`multipage.ts`](https://github.com/every-app/open-seo/blob/main/multipage.ts).

### Example: Detecting Overly Long H1 Headings

Add the check logic 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):

```typescript
import type { SlimPage } from '@/server/lib/audit/issues/multipage-checks';
import type { DetectedIssue } from '@/server/lib/audit/issues/page-reporters';

export function longH1Headings(pages: SlimPage[]): DetectedIssue[] {
  const issues: DetectedIssue[] = [];

  pages.forEach(p => {
    const h1Match = p.html?.match(/<h1[^>]*>([^<]+)<\/h1>/i);
    if (h1Match && h1Match[1].trim().length > 70) {
      issues.push({
        title: 'Very long H1 heading',
        description: `The H1 on ${p.url} is ${h1Match[1].trim().length} characters long. Google recommends keeping headings concise.`,
        type: 'long_h1_heading',
        url: p.url,
      });
    }
  });

  return issues;
}

```

Then integrate it into [`src/server/lib/audit/issues/multipage.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts):

```typescript
import { longH1Headings } from './multipage-checks';

export async function runMultipageChecks(input: { auditId: string }) {
  // …pages are fetched and transformed into `SlimPage[]` called `pages`…

  const issues: DetectedIssue[] = [];

  // Existing site-wide checks
  issues.push(...longH1Headings(pages));

  return issues;
}

```

`runMultipageChecks` is defined at line 20 of [`multipage.ts`](https://github.com/every-app/open-seo/blob/main/multipage.ts) and executes after all per-page checks complete.

## How Issues Are Persisted

Both per-page and multi-page reporters return `DetectedIssue` objects that flow into `AuditRepository.insertIssues`. This repository method stores issues in the `audit_issues` table without requiring additional database configuration.

In [`src/server/workflows/siteAuditWorkflowPhases.ts`](https://github.com/every-app/open-seo/blob/main/src/server/workflows/siteAuditWorkflowPhases.ts), the workflow orchestrates persistence:

```typescript
await AuditRepository.insertIssues(auditId, issues);

```

This call occurs at line 356 after running both phases of checks. Because the database layer maps the `type` field directly to the `audit_issues.type` column, your custom issues automatically appear in the UI at `/features/site-audit`, CSV exports, and API responses.

## Summary

- **OpenSEO uses two-phase auditing**: Per-page checks inspect individual `CrawledPageResult` objects via `runPageReporters`, while site-wide checks analyze the full page list via `runMultipageChecks`.
- **Implement `DetectedIssue` objects**: Return objects with `title`, `description`, `type`, and optional `url` properties from your custom functions.
- **Register in the appropriate runner**: Add per-page reporters to [`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) and multi-page helpers to [`src/server/lib/audit/issues/multipage.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/audit/issues/multipage.ts).
- **Automatic persistence**: Issues automatically save to the `audit_issues` table via `AuditRepository.insertIssues` and appear in all export formats.

## Frequently Asked Questions

### Can I add multiple custom checks to a single audit?

Yes. You can register multiple reporter functions in `runPageReporters` or multiple helpers in `runMultipageChecks`. Simply push the results of each function into the `issues` array. The system aggregates all `DetectedIssue` objects before persisting them to the database.

### What data is available in the `CrawledPageResult` object?

According to the OpenSEO source code, `CrawledPageResult` contains the full HTML content of the crawled page, the URL, HTTP status code, headers, and load time metrics. For site-wide checks, `SlimPage` provides a lightweight representation containing the URL and HTML content to minimize memory usage when processing large sites.

### How do I ensure my custom issues appear in the CSV export?

The CSV export functionality filters and displays issues based on the `type` field in the `audit_issues` table. As long as your custom check returns a `DetectedIssue` with a unique `type` identifier (e.g., `missing_meta_description`), it will automatically appear in exports without additional configuration.

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

**Per-page checks** in [`page-reporters.ts`](https://github.com/every-app/open-seo/blob/main/page-reporters.ts) analyze individual pages in isolation using `CrawledPageResult`, making them ideal for HTML validation issues like missing tags or improper heading structures. **Multi-page checks** in [`multipage.ts`](https://github.com/every-app/open-seo/blob/main/multipage.ts) receive the complete array of `SlimPage` objects, enabling analysis of cross-page patterns like duplicate content, redirect chains, or orphaned pages that require comparing multiple URLs simultaneously.