HookStack
Back to catalogue
ValidationPostToolUse· Write|EditPostToolUseAfter tool execution · non-blocking· non-blocking

SEO page metadata guard

Every page ships indexable — title + description set

After editing a Next.js App Router page (src/app/**/page.tsx), verifies it exports `metadata` or `generateMetadata` with both a title and a description — the two tags Google shows in results and weighs when ranking. Non-blocking: names exactly what is missing so no page ever goes live invisible to search.

What does the SEO page metadata guard hook do?

SEO page metadata guard is a Claude Code PostToolUse hook matching Write|Edit. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Every page ships indexable — title + description set.

As a PostToolUse 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

  • Stop an agent from shipping a page Google cannot rank
  • Keep title + description coverage at 100% across every route

Tags

#seo#metadata#nextjs#indexing#google

settings.json fragment

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/seo-page-metadata-guard.mjs"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/seo-page-metadata-guard.mjs

#!/usr/bin/env node
// Vérifie qu'une page Next.js App Router expose ses métadonnées SEO après écriture
// (PostToolUse Write|Edit). Cible : src/app/**/page.tsx.
// Règle : la page doit exporter `metadata` OU `generateMetadata`, et l'objet doit
// porter un `title` ET une `description` (les deux leviers SEO de base).
// Non bloquant : signale précisément ce qui manque.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';

// Cible un fichier page.tsx sous un dossier app/ (App Router), racine incluse.
const PAGE_RE = /\/app\/(?:.*\/)?page\.tsx$/;

export function run(input, { readFile = readFileSync } = {}) {
  const filePath = input.tool_input?.file_path ?? '';
  if (!PAGE_RE.test(filePath)) return null;

  let content;
  try {
    content = readFile(filePath, 'utf8');
  } catch {
    return null; // fichier illisible/supprimé → rien à vérifier
  }

  const hasMetadata =
    /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);

  if (!hasMetadata) {
    return {
      message:
        `[seo-metadata] ${filePath} exports no metadata.\n` +
        `  → Add 'export const metadata = { title, description }' (or generateMetadata) ` +
        `so this page is indexable.\n`,
    };
  }

  const missing = [];
  if (!/\btitle\s*:/.test(content)) missing.push('title');
  if (!/\bdescription\s*:/.test(content)) missing.push('description');
  if (!missing.length) return null;

  return {
    message:
      `[seo-metadata] ${filePath} metadata is missing: ${missing.join(', ')}.\n` +
      `  → A page needs both a title and a description to rank.\n`,
  };
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'));
  const result = run(input);
  if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks