March 29, 2026 · 14 min read

Automate SEO Audits in Next.js with the SEOPeek API

Next.js is the most popular React framework for production websites—but its mix of SSR, SSG, ISR, and client-side rendering creates unique SEO challenges. A page that renders perfectly in development can ship with missing OG tags, broken canonical URLs, or empty titles in production. This guide shows you how to wire the SEOPeek API into your Next.js project so that every route is audited automatically, every metadata regression is caught before deploy, and your entire sitemap is monitored continuously.

In this guide
  1. Why Next.js sites need automated SEO auditing
  2. Setting up the SEOPeek API client
  3. Route Handler: audit any page on demand
  4. Building a /dashboard/seo page
  5. Validating Next.js Metadata API output
  6. Build-time SEO validation
  7. CI/CD: SEO audit in GitHub Actions
  8. Cron-based sitemap monitoring
  9. Reusable SEO score component
  10. Pricing comparison
  11. FAQ

1. Why Next.js Sites Need Automated SEO Auditing

Next.js offers four rendering strategies—Static Site Generation (SSG), Server-Side Rendering (SSR), Incremental Static Regeneration (ISR), and client-side rendering—and many projects use all four across different routes. Each strategy has its own SEO failure modes:

The Next.js Metadata API (introduced in App Router) makes it easier to set titles, descriptions, and OG tags—but it does not validate the output. You can typo a property name, return an empty string from a database lookup, or accidentally override parent metadata without any warning.

Dynamic routes compound the problem. A /blog/[slug] route might generate correct metadata for your test post but produce broken OG tags for a post with special characters in the title. Manual spot-checking does not scale. You need automated auditing that covers every route, every render strategy, and every edge case.

The SEOPeek API audits the rendered HTML of any URL—exactly what Googlebot sees. One GET request returns a numeric score, letter grade, and pass/fail results for 20+ on-page SEO checks including title, description, OG tags, canonical URL, heading structure, and structured data.

2. Setting Up the SEOPeek API Client

The SEOPeek API is a single REST endpoint. No SDK required—just fetch. Create a shared utility that every part of your Next.js app can import:

// lib/seopeek.ts
const SEOPEEK_API =
  "https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit";

export interface SeoAuditResult {
  url: string;
  score: number;
  grade: string;
  checks: {
    name: string;
    passed: boolean;
    message: string;
    value?: string;
  }[];
  meta: {
    title: string | null;
    description: string | null;
    canonical: string | null;
    ogTitle: string | null;
    ogDescription: string | null;
    ogImage: string | null;
  };
  timestamp: string;
}

export async function auditUrl(url: string): Promise<SeoAuditResult> {
  const res = await fetch(
    `${SEOPEEK_API}?url=${encodeURIComponent(url)}`,
    { next: { revalidate: 0 } } // never cache audit results
  );

  if (!res.ok) {
    throw new Error(`SEOPeek API error: ${res.status} ${res.statusText}`);
  }

  return res.json();
}

export async function auditUrls(
  urls: string[],
  concurrency = 5
): Promise<SeoAuditResult[]> {
  const results: SeoAuditResult[] = [];

  for (let i = 0; i < urls.length; i += concurrency) {
    const batch = urls.slice(i, i + concurrency);
    const batchResults = await Promise.all(
      batch.map((url) =>
        auditUrl(url).catch((err) => ({
          url,
          score: 0,
          grade: "F",
          checks: [],
          meta: {
            title: null, description: null, canonical: null,
            ogTitle: null, ogDescription: null, ogImage: null,
          },
          timestamp: new Date().toISOString(),
          error: err.message,
        }))
      )
    );
    results.push(...(batchResults as SeoAuditResult[]));
  }

  return results;
}

This client runs server-side only—never import it in a client component. The API call should stay on your server to avoid exposing request patterns and to keep your API key secure if you upgrade to a paid plan later.

3. Route Handler: Audit Any Page on Demand

Create a Route Handler in the App Router that accepts a URL parameter and returns audit results as JSON. This gives you an internal API endpoint your dashboard, scripts, and CI/CD pipeline can call:

// app/api/seo-audit/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auditUrl } from "@/lib/seopeek";

export async function GET(request: NextRequest) {
  const url = request.nextUrl.searchParams.get("url");

  if (!url) {
    return NextResponse.json(
      { error: "Missing required 'url' query parameter" },
      { status: 400 }
    );
  }

  try {
    const result = await auditUrl(url);
    return NextResponse.json(result);
  } catch (err) {
    return NextResponse.json(
      { error: err instanceof Error ? err.message : "Audit failed" },
      { status: 502 }
    );
  }
}

// POST handler for bulk audits
export async function POST(request: NextRequest) {
  const body = await request.json();
  const urls: string[] = body.urls;

  if (!Array.isArray(urls) || urls.length === 0) {
    return NextResponse.json(
      { error: "Body must include a 'urls' array" },
      { status: 400 }
    );
  }

  if (urls.length > 50) {
    return NextResponse.json(
      { error: "Maximum 50 URLs per request" },
      { status: 400 }
    );
  }

  const { auditUrls } = await import("@/lib/seopeek");
  const results = await auditUrls(urls);

  return NextResponse.json({ results });
}

Test it locally:

curl "http://localhost:3000/api/seo-audit?url=https://yoursite.com"

4. Building a /dashboard/seo Page

Create a server component that fetches audit results for all your routes and displays them in a table. This is your internal SEO health dashboard:

// app/dashboard/seo/page.tsx
import { auditUrls, SeoAuditResult } from "@/lib/seopeek";
import { SeoScoreBadge } from "@/components/seo-score-badge";

// Define the routes you want to monitor
const SITE_URL = "https://yoursite.com";
const ROUTES = [
  "/",
  "/about",
  "/pricing",
  "/blog",
  "/blog/getting-started",
  "/docs",
  "/contact",
];

export const dynamic = "force-dynamic"; // always fresh data

export default async function SeoDashboard() {
  const urls = ROUTES.map((r) => `${SITE_URL}${r}`);
  const results = await auditUrls(urls, 3);

  const avgScore = Math.round(
    results.reduce((sum, r) => sum + r.score, 0) / results.length
  );

  return (
    <div style={{ padding: "2rem", maxWidth: "960px", margin: "0 auto" }}>
      <h1>SEO Health Dashboard</h1>
      <p>Average score: <SeoScoreBadge score={avgScore} /></p>

      <table style={{ width: "100%", borderCollapse: "collapse" }}>
        <thead>
          <tr>
            <th style={{ textAlign: "left", padding: "12px" }}>Route</th>
            <th>Score</th>
            <th>Grade</th>
            <th>Title</th>
            <th>OG Image</th>
          </tr>
        </thead>
        <tbody>
          {results.map((r) => (
            <tr key={r.url}>
              <td style={{ padding: "12px", fontFamily: "monospace" }}>
                {r.url.replace(SITE_URL, "")}
              </td>
              <td style={{ textAlign: "center" }}>
                <SeoScoreBadge score={r.score} />
              </td>
              <td style={{ textAlign: "center" }}>{r.grade}</td>
              <td>{r.meta.title || "MISSING"}</td>
              <td>{r.meta.ogImage ? "Yes" : "MISSING"}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

This page is server-rendered on every request (force-dynamic) so you always see the latest scores. For production, consider caching results for 5 minutes with revalidate: 300 to stay within your API quota.

5. Validating Next.js Metadata API Output

The Next.js Metadata API lets you export a generateMetadata function or a static metadata object from any page. The SEOPeek API validates the rendered output of these declarations—catching issues that TypeScript alone cannot:

Here is a utility that audits a specific page and checks its Metadata API output against SEOPeek results:

// lib/validate-metadata.ts
import { auditUrl, SeoAuditResult } from "@/lib/seopeek";

export interface MetadataIssue {
  field: string;
  severity: "error" | "warning";
  message: string;
  currentValue: string | null;
}

export async function validatePageMetadata(
  url: string
): Promise<{ audit: SeoAuditResult; issues: MetadataIssue[] }> {
  const audit = await auditUrl(url);
  const issues: MetadataIssue[] = [];

  if (!audit.meta.title) {
    issues.push({
      field: "title",
      severity: "error",
      message: "Page has no title tag. This is critical for SEO.",
      currentValue: null,
    });
  } else if (audit.meta.title.length > 60) {
    issues.push({
      field: "title",
      severity: "warning",
      message: `Title is ${audit.meta.title.length} chars. Google truncates at ~60.`,
      currentValue: audit.meta.title,
    });
  }

  if (!audit.meta.description) {
    issues.push({
      field: "description",
      severity: "error",
      message: "Page has no meta description.",
      currentValue: null,
    });
  } else if (audit.meta.description.length > 160) {
    issues.push({
      field: "description",
      severity: "warning",
      message: `Description is ${audit.meta.description.length} chars. Keep under 160.`,
      currentValue: audit.meta.description,
    });
  }

  if (!audit.meta.ogTitle) {
    issues.push({
      field: "og:title",
      severity: "warning",
      message: "Missing og:title. Social shares will use the page title.",
      currentValue: null,
    });
  }

  if (!audit.meta.ogImage) {
    issues.push({
      field: "og:image",
      severity: "error",
      message: "Missing og:image. Social shares will have no preview image.",
      currentValue: null,
    });
  }

  if (!audit.meta.canonical) {
    issues.push({
      field: "canonical",
      severity: "warning",
      message: "No canonical URL set. This can cause duplicate content issues.",
      currentValue: null,
    });
  }

  return { audit, issues };
}

6. Build-Time SEO Validation

For statically generated pages, you can run SEO audits as part of your build process. This catches metadata regressions before they reach production. Create a build script that audits your deployed staging site:

// scripts/audit-seo.ts
import { auditUrls } from "../lib/seopeek";

const STAGING_URL = process.env.STAGING_URL || "https://staging.yoursite.com";

const CRITICAL_ROUTES = [
  "/",
  "/pricing",
  "/blog",
  "/docs",
  "/about",
];

async function main() {
  console.log(`Auditing ${CRITICAL_ROUTES.length} routes on ${STAGING_URL}...`);

  const urls = CRITICAL_ROUTES.map((r) => `${STAGING_URL}${r}`);
  const results = await auditUrls(urls, 3);

  let hasFailures = false;

  for (const result of results) {
    const route = result.url.replace(STAGING_URL, "");
    const status = result.score >= 80 ? "PASS" : "FAIL";

    console.log(`  ${status}  ${route} — ${result.score}/100 (${result.grade})`);

    if (result.score < 70) {
      hasFailures = true;
      const failedChecks = result.checks.filter((c) => !c.passed);
      for (const check of failedChecks) {
        console.log(`        - ${check.name}: ${check.message}`);
      }
    }
  }

  const avg = Math.round(
    results.reduce((s, r) => s + r.score, 0) / results.length
  );
  console.log(`\nAverage score: ${avg}/100`);

  if (hasFailures) {
    console.error("\nSEO audit failed. Fix issues before deploying.");
    process.exit(1);
  }

  console.log("\nAll routes passed SEO audit.");
}

main();

Run it with npx tsx scripts/audit-seo.ts before deploying. If any route scores below 70, the script exits with code 1, which blocks the deploy.

7. CI/CD: SEO Audit in GitHub Actions

Add an SEO audit step to your GitHub Actions workflow. This runs after your staging deployment and blocks the production deploy if scores drop below your threshold:

# .github/workflows/seo-audit.yml
name: SEO Audit
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  seo-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Build and start server
        run: |
          npx next build
          npx next start &

      - name: Wait for server
        run: npx wait-on http://localhost:3000 --timeout 30000

      - name: Run SEO audit
        run: npx tsx scripts/audit-seo.ts
        env:
          STAGING_URL: http://localhost:3000

      - name: Upload audit results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: seo-audit-results
          path: seo-audit-results.json

This workflow builds and starts your Next.js app, waits for it to be ready, then runs the SEOPeek audit script against every critical route. Failed audits block the PR merge.

Tip: The free tier gives you 50 audits per day. For CI/CD pipelines that run frequently, the Starter plan at $9/month provides 1,000 audits—enough for 20 routes across 50 builds per month.

8. Cron-Based Sitemap Monitoring

Use a Next.js Route Handler with a cron trigger to monitor every URL in your sitemap on a schedule. This catches SEO regressions that happen after deploy—like CMS content changes that break metadata or third-party scripts that inject bad markup:

// app/api/cron/seo-monitor/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auditUrls } from "@/lib/seopeek";

// Protect with a secret so only your cron service can trigger it
const CRON_SECRET = process.env.CRON_SECRET;

async function fetchSitemapUrls(sitemapUrl: string): Promise<string[]> {
  const res = await fetch(sitemapUrl);
  const xml = await res.text();

  const urls: string[] = [];
  const locPattern = /<loc>(.+?)<\/loc>/g;
  let match: RegExpExecArray | null;
  while ((match = locPattern.exec(xml)) !== null) {
    urls.push(match[1]);
  }
  return urls;
}

export async function GET(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get("secret");
  if (secret !== CRON_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const sitemapUrl =
    process.env.SITE_URL + "/sitemap.xml" ||
    "https://yoursite.com/sitemap.xml";

  const urls = await fetchSitemapUrls(sitemapUrl);
  console.log(`Monitoring ${urls.length} URLs from sitemap`);

  const results = await auditUrls(urls, 3);

  const failures = results.filter((r) => r.score < 70);

  if (failures.length > 0) {
    console.error(`${failures.length} URLs below threshold:`);
    for (const f of failures) {
      console.error(`  ${f.url}: ${f.score}/100`);
    }

    // Optional: send alert to Slack
    if (process.env.SLACK_WEBHOOK_URL) {
      await fetch(process.env.SLACK_WEBHOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          text: `SEO Alert: ${failures.length} URLs scored below 70.\n` +
            failures.map((f) => `${f.url}: ${f.score}`).join("\n"),
        }),
      });
    }
  }

  return NextResponse.json({
    monitored: urls.length,
    passed: results.length - failures.length,
    failed: failures.length,
    results: results.map((r) => ({
      url: r.url,
      score: r.score,
      grade: r.grade,
    })),
  });
}

Trigger this with a cron service like CronPeek, GitHub Actions scheduled workflows, or any external scheduler. Running it daily keeps you informed of SEO drift without manual intervention.

9. Reusable SEO Score Component

Build a React component that displays the SEO score with a color-coded circular progress ring. Use this in your dashboard, admin panel, or anywhere you surface audit results:

// components/seo-score-badge.tsx
"use client";

interface SeoScoreBadgeProps {
  score: number;
  size?: "sm" | "md" | "lg";
  showLabel?: boolean;
}

function getScoreColor(score: number): string {
  if (score >= 90) return "#10B981"; // green
  if (score >= 70) return "#F59E0B"; // amber
  if (score >= 50) return "#F97316"; // orange
  return "#EF4444"; // red
}

function getScoreGrade(score: number): string {
  if (score >= 90) return "A";
  if (score >= 80) return "B";
  if (score >= 70) return "C";
  if (score >= 50) return "D";
  return "F";
}

export function SeoScoreBadge({
  score,
  size = "md",
  showLabel = true,
}: SeoScoreBadgeProps) {
  const color = getScoreColor(score);
  const grade = getScoreGrade(score);

  const sizes = {
    sm: { badge: 32, font: 12, ring: 2 },
    md: { badge: 48, font: 16, ring: 3 },
    lg: { badge: 72, font: 24, ring: 4 },
  };

  const s = sizes[size];
  const radius = (s.badge - s.ring * 2) / 2;
  const circumference = radius * 2 * Math.PI;
  const offset = circumference - (score / 100) * circumference;

  return (
    <div style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
      <div style={{ position: "relative", width: s.badge, height: s.badge }}>
        <svg
          width={s.badge}
          height={s.badge}
          viewBox={`0 0 ${s.badge} ${s.badge}`}
          style={{ transform: "rotate(-90deg)" }}
        >
          <circle
            cx={s.badge / 2}
            cy={s.badge / 2}
            r={radius}
            fill="none"
            stroke="rgba(255,255,255,0.1)"
            strokeWidth={s.ring}
          />
          <circle
            cx={s.badge / 2}
            cy={s.badge / 2}
            r={radius}
            fill="none"
            stroke={color}
            strokeWidth={s.ring}
            strokeDasharray={circumference}
            strokeDashoffset={offset}
            strokeLinecap="round"
          />
        </svg>
        <span
          style={{
            position: "absolute",
            inset: 0,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontSize: s.font,
            fontWeight: 700,
            color,
          }}
        >
          {score}
        </span>
      </div>
      {showLabel && (
        <span style={{ fontSize: s.font * 0.8, color, fontWeight: 600 }}>
          {grade}
        </span>
      )}
    </div>
  );
}

Import it anywhere in your app:

import { SeoScoreBadge } from "@/components/seo-score-badge";

// In your JSX:
<SeoScoreBadge score={87} size="lg" />
<SeoScoreBadge score={42} size="sm" showLabel={false} />

10. Pricing: SEOPeek vs the Competition

Most SEO audit tools are designed for marketers, not developers. They charge premium prices for GUI dashboards you do not need. SEOPeek is built API-first, so you only pay for what you use:

Feature SEOPeek SEOptimer Ahrefs
Starting price $9/mo $29/mo $99/mo
REST API Yes Yes (add-on) Yes (higher tier)
Free tier 50 audits/day No No
JSON response Yes Yes Yes
OG tag validation Yes Yes Limited
Structured data check Yes Yes Yes
CI/CD integration Native (single curl) Manual Manual
Response time <2 seconds 3–5 seconds 5–10 seconds
Next.js optimized Yes No No

For a Next.js project with 20 routes, the free tier covers a full audit twice daily. The $9/month Starter plan supports continuous monitoring across staging and production with room to spare.

Audit Your Next.js Site

50 free audits per day. No API key required. JSON response in under 2 seconds.

Audit your Next.js site — 50 free audits/day →

Frequently Asked Questions

Does the SEOPeek API work with both App Router and Pages Router?

Yes. The SEOPeek API is a standard REST endpoint that returns JSON, so it works identically with both routing systems. Use Route Handlers (app/api/*/route.ts) in the App Router or API Routes (pages/api/*.ts) in the Pages Router. The code examples in this guide focus on the App Router since it is the recommended approach for new Next.js projects, but the lib/seopeek.ts client works anywhere.

Can I run SEOPeek audits at build time in Next.js?

Yes. You can call the SEOPeek API in a build script (scripts/audit-seo.ts) that runs after next build. This requires your pages to be accessible at a URL—either a staging deployment or a local server started with next start. The CI/CD section of this guide shows exactly how to set this up with GitHub Actions.

How many free audits does the SEOPeek API provide?

The free tier includes 50 audits per day with no API key and no credit card required. That is enough to audit 25 routes twice daily or run a full audit of a smaller site multiple times during development. The Starter plan ($9/month) provides 1,000 audits per day, and the Pro plan ($29/month) provides 10,000 audits per day for larger sites or agencies.

Does the API validate Next.js Metadata API output?

The SEOPeek API fetches and audits the fully rendered HTML of any URL. This means it sees the final output of your generateMetadata functions, static metadata exports, and any <head> modifications from layouts or templates. It validates titles, descriptions, OG tags, Twitter cards, canonical URLs, structured data, and heading hierarchy—everything a search engine crawler would evaluate.

How does SEOPeek compare to running Lighthouse in CI/CD?

Lighthouse requires a headless Chrome instance, Puppeteer, and significant compute resources. A single Lighthouse audit takes 15–30 seconds and consumes around 500MB of RAM. SEOPeek is a single HTTP GET request that returns JSON in under 2 seconds with zero dependencies. For SEO-specific auditing in CI/CD, SEOPeek is faster, lighter, and returns more actionable on-page SEO data. Use Lighthouse for performance auditing and SEOPeek for SEO auditing.

More from the Peek Suite

SEOPeek is part of a family of developer tools. Each one solves a specific problem with a single API call: