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
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);
}