HookStack
Back to catalogue
ValidationPostToolUse· Write|Edit

Next.js App Router quality checker

Catches missing 'use client' and legacy Pages Router patterns before they silently break

After each file write or edit, scans JS/TS files for common Next.js App Router mistakes: Server Components using React hooks without 'use client', Pages Router data-fetching methods (getServerSideProps/getStaticProps) inside app/, and plain <img>/<a> tags where next/image and next/link should be used. Blocks on critical errors, warns on best-practice issues.

What does the Next.js App Router quality checker hook do?

Next.js App Router quality checker 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. Catches missing 'use client' and legacy Pages Router patterns before they silently break.

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

  • Catch missing 'use client' in interactive components
  • Detect Pages Router patterns during App Router migrations
  • Enforce next/image and next/link usage

Tags

#nextjs#react#app-router#quality#typescript

settings.json fragment

{
  "hooks": {
    "PostToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/nextjs-quality-checker.mjs",
            "type": "command"
          }
        ],
        "matcher": "Write|Edit"
      }
    ]
  }
}

Script · .claude/hooks/nextjs-quality-checker.mjs

#!/usr/bin/env node
// @hookstack post-write-nextjs-quality
// Vérifie les patterns Next.js App Router après écriture (PostToolUse Write|Edit)
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

const INTERACTIVE_PATTERNS = [
	/\buseState\b/,
	/\buseEffect\b/,
	/\buseReducer\b/,
	/\buseRef\b/,
	/\bonClick\b/,
	/\bonChange\b/,
	/\bonSubmit\b/,
	/\bonKeyDown\b/,
];

const PAGES_ROUTER_PATTERNS = [
	/\bgetServerSideProps\b/,
	/\bgetStaticProps\b/,
	/\bgetStaticPaths\b/,
];

export function run(
	input,
	{ readFile = readFileSync, fileExists = existsSync } = {},
) {
	const filePath = input.tool_input?.file_path ?? "";
	if (!filePath || !/\.[jt]sx?$/.test(filePath)) return null;
	if (!fileExists(filePath)) return null;

	const content = readFile(filePath, "utf8");
	const isInAppDir = /[/\\]app[/\\]/.test(filePath);
	const hasUseClient = /['"]use client['"]/.test(content);
	const hasInteractive = INTERACTIVE_PATTERNS.some((p) => p.test(content));
	const hasPagesRouter = PAGES_ROUTER_PATTERNS.some((p) => p.test(content));

	const warnings = [];
	const errors = [];

	// Server Component with interactive hooks → must add 'use client'
	if (isInAppDir && !hasUseClient && hasInteractive) {
		errors.push(
			`'use client' directive missing — file uses React hooks or event handlers (useState/useEffect/onClick…).`,
		);
	}

	// Pages Router data fetching in app/ → migration signal
	if (isInAppDir && hasPagesRouter) {
		warnings.push(
			`Pages Router API (getServerSideProps/getStaticProps) detected in app/ — use Server Components or Route Handlers instead.`,
		);
	}

	// <img> tag when next/image is imported or could be used
	if (/<img\s/i.test(content) && !content.includes("next/image")) {
		warnings.push(
			`<img> tag detected — consider next/image for automatic optimization and lazy loading.`,
		);
	}

	// <a href> for internal paths (not http/mailto/tel)
	const internalAnchor =
		/<a\s[^>]*href=["'][^"'#]/.test(content) &&
		!/href=["']https?:/.test(content);
	if (internalAnchor && !content.includes("next/link")) {
		warnings.push(
			`Internal <a href> detected — use next/link for prefetching and client-side navigation.`,
		);
	}

	if (errors.length === 0 && warnings.length === 0) return null;

	const lines = [
		...errors.map((e) => `❌ ${e}`),
		...warnings.map((w) => `⚠️  ${w}`),
	].join("\n");
	const message = `Next.js quality check — ${filePath}\n${lines}\n`;

	if (errors.length > 0) {
		return { message };
	}

	// Warnings only: write to stderr (non-blocking)
	process.stderr.write(message);
	return null;
}

/* 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