Back to catalogue
ValidationPostToolUse· Write|EditPostToolUseAfter tool execution · non-blocking· non-blocking
Next.js App Router quality checker
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.
Use cases
- Catch missing 'use client' in interactive components
- Detect Pages Router patterns during App Router migrations
- Enforce next/image and next/link usage
Providers & tags
Claude Code
#nextjs#react#app-router#quality#typescript
settings.json fragment
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/nextjs-quality-checker.mjs"
}
]
}
]
}
}Script · .claude/hooks/nextjs-quality-checker.mjs
#!/usr/bin/env node
// Vérifie les patterns Next.js App Router après écriture (PostToolUse Write|Edit)
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from '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);
}