HookStack
Back to catalogue
ValidationStopStopWhen the agent finishes its task· non-blocking

SEO structure gate

Your SEO can't regress — gated like your test suite

Session-end SEO gate (Stop) for Next.js projects — a test suite for search. Verifies every page.tsx carries a title + description, the structural files exist (robots, sitemap, OpenGraph image, manifest), and every literal internal <Link href="/..."> resolves to a real route, so no broken link bleeds crawl budget. Blocks the stop (exit 2) on any regression. Runtime JSON-LD and Core Web Vitals stay with a full audit.

What does the SEO structure gate hook do?

SEO structure gate is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Your SEO can't regress — gated like your test suite.

As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Gate SEO structure the way tests gate your code
  • Catch a broken internal link before it reaches production

Tags

#seo#gate#stop#links#nextjs#crawlability

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-seo-structure-check.mjs"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/stop-seo-structure-check.mjs

#!/usr/bin/env node
// Gate SEO structurel en fin de session (Stop) — l'équivalent « suite de tests » pour
// le référencement. Sur un projet Next.js App Router (src/app présent), vérifie en
// statique, sans navigateur ni réseau :
//   1. chaque page.tsx exporte ses métadonnées (title + description)
//   2. les fichiers structurels existent : robots, sitemap, image OpenGraph, manifest
//   3. chaque <Link href="/…"> interne littéral pointe vers une route connue (broken-link)
// Bloquant (exitCode 2) si une régression est trouvée ; silencieux sinon.
// La validité runtime des schémas JSON-LD et des Core Web Vitals relève du skill seo-geo-aeo.
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';

function defaultWalkTsx(dir, { exists = existsSync, readdir = readdirSync } = {}) {
  if (!exists(dir)) return [];
  const out = [];
  for (const entry of readdir(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) out.push(...defaultWalkTsx(full, { exists, readdir }));
    else if (entry.name.endsWith('.tsx')) out.push(full);
  }
  return out;
}

function defaultListDirs(dir, { exists = existsSync, readdir = readdirSync } = {}) {
  if (!exists(dir)) return [];
  return readdir(dir, { withFileTypes: true })
    .filter((e) => e.isDirectory())
    .map((e) => e.name);
}

function hasMetadata(content) {
  const present =
    /export\s+(?:const|let)\s+metadata\b/.test(content) ||
    /export\s+(?:async\s+)?function\s+generateMetadata\b/.test(content) ||
    /export\s*\{[^}]*\bmetadata\b[^}]*\}/.test(content);
  return present && /\btitle\s*:/.test(content) && /\bdescription\s*:/.test(content);
}

export function run(_input, deps = {}) {
  const {
    projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
    readFile = readFileSync,
    exists = existsSync,
    walkTsx = (dir) => defaultWalkTsx(dir),
    listDirs = (dir) => defaultListDirs(dir),
  } = deps;

  const appDir = join(projectDir, 'src/app');
  if (!exists(appDir)) return null; // pas un projet Next App Router → no-op

  const problems = [];
  const read = (p) => {
    try {
      return readFile(p, 'utf8');
    } catch {
      return '';
    }
  };

  // 1. Couverture métadonnées sur chaque page.tsx
  const pages = walkTsx(appDir).filter((p) => p.endsWith('page.tsx'));
  for (const page of pages) {
    if (!hasMetadata(read(page))) {
      problems.push(`metadata missing (title + description) → ${page.replace(projectDir + '/', '')}`);
    }
  }

  // 2. Fichiers structurels (au moins un candidat par catégorie)
  const need = [
    ['robots', ['src/app/robots.ts', 'src/app/robots.txt', 'public/robots.txt']],
    ['sitemap', ['src/app/sitemap.ts', 'src/app/sitemap.xml', 'public/sitemap.xml']],
    [
      'OpenGraph image',
      ['src/app/opengraph-image.tsx', 'src/app/opengraph-image.png', 'src/app/opengraph-image.jpg'],
    ],
    ['web manifest', ['src/app/manifest.ts', 'public/site.webmanifest', 'public/manifest.json']],
  ];
  for (const [label, candidates] of need) {
    if (!candidates.some((c) => exists(join(projectDir, c)))) {
      problems.push(`${label} missing → expected one of: ${candidates.join(', ')}`);
    }
  }

  // 3. Liens internes cassés (<Link href="/…"> littéral vers une route inconnue)
  const routes = new Set(listDirs(appDir)); // segments de 1er niveau (about, guides, hook, …)
  const LINK_RE = /<Link\b[^>]*\bhref=["'](\/[^"'#?]*)/g;
  for (const file of walkTsx(join(projectDir, 'src'))) {
    const content = read(file);
    for (const [, href] of content.matchAll(LINK_RE)) {
      if (href === '/') continue; // racine
      const seg = href.split('/')[1];
      if (!seg || routes.has(seg)) continue; // ancre ou route connue
      problems.push(`broken internal link "${href}" → unknown route in ${file.replace(projectDir + '/', '')}`);
    }
  }

  if (!problems.length) return null;

  return {
    exitCode: 2,
    message:
      `[seo-structure] SEO gate failed — fix before ending the session:\n` +
      problems.map((p) => `  - ${p}`).join('\n') +
      '\n',
  };
}

/* v8 ignore next 6 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const result = run();
  if (result?.message) process.stderr.write(result.message);
  if (result?.exitCode) process.exit(result.exitCode);
}

Learn more

Related hooks