HookStack
Back to catalogue
ValidationStop

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": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-seo-structure-check.mjs",
            "type": "command"
          }
        ]
      }
    ]
  }
}

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

#!/usr/bin/env node
// @hookstack stop-seo-structure-check
// 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 { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node: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