
Nextjs
- 399 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
nextjs is a Claude Code skill from pproenca/dot-skills that guides Next.js development tasks for developers who want agent assistance aligned with dot-skills Next.js conventions.
About
nextjs is a Claude Code skill entry in pproenca/dot-skills scoped to Next.js application development. The catalog describes it for general development tasks within the dot-skills pack, though the readme excerpt is empty in the current snapshot. Developers install it when they want Claude agents to follow project-specific Next.js guidance bundled alongside other dot-skills. Reach for nextjs when working in repositories that already adopt pproenca/dot-skills and need consistent Next.js patterns during feature work, routing changes, or app-router tasks.
- nextjs
Nextjs by the numbers
- 399 all-time installs (skills.sh)
- +29 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,045 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill nextjsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 399 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you align agents with Next.js conventions?
Use nextjs for development tasks
Who is it for?
Developers using pproenca/dot-skills who want agent-assisted Next.js feature and routing work.
Skip if: Non-Next.js stacks or teams without the dot-skills pack installed in their agent environment.
When should I use this skill?
A developer works on Next.js files in a repo configured with pproenca/dot-skills.
What you get
Next.js code changes and patterns consistent with pproenca/dot-skills guidance for the active repository.
Files
Next.js 16 App Router Best Practices
Comprehensive Next.js 16 App Router guide for AI agents. Contains 45 rules across 9 categories, prioritized by impact from critical (build optimization, caching strategy) through to cross-cutting codebase hygiene (dedup, dead routes, boundary coherence). Reflects Next.js 16 changes: 'use cache' directive replacing implicit caching, revalidateTag(tag, cacheLife) requirement, proxy.ts replacing middleware.ts, Turbopack persistent caching, App Router conventions.
Rule files describe pattern shapes (not API names) and open with a "Shapes to recognize" section listing 2–4 syntactic disguises the same break can wear. Selected high-value rules (those whose disguises are most common in practice — 'use cache', parallel-fetching, dynamic-imports, server-action-forms, client-boundary, server-vs-client-fetching) include an extra concrete "In disguise" incorrect/correct example pair to teach pattern detection beyond the grep-friendly cases.
When to Apply
- Writing new Next.js 16 App Router code
- Auditing or modernizing a Next.js codebase — single file, PR, or whole repo (see `references/_review-algorithm.md`)
- Migrating from Next.js 15 to 16 (implicit caching →
'use cache',middleware.ts→proxy.ts,revalidateTagsingle-arg → withcacheLife) - Configuring caching strategies with
'use cache',unstable_cache,revalidateTag,revalidatePath - Implementing Server Components and parallel/colocated data fetching
- Setting up parallel routes, intercepting routes, prefetching,
proxy.ts - Creating Server Actions for form handling and mutations
- Tuning
'use client'boundaries to minimize client bundle - Finding codebase-level issues that single-file rules can't see: duplicated server fetchers, near-duplicate routes/layouts, dead routes,
'use client'propagation across the route tree, prop-shape drift (see Category 9)
How to Review or Refactor a Codebase
When the user asks to review, refactor, modernize, or audit Next.js code — single file or whole repo — follow [`references/_review-algorithm.md`](references/_review-algorithm.md). Do not improvise.
Four non-negotiables from that doc:
1. Two modes — never refuse a whole-repo audit. Pick Mode A (scoped, ≤~20 files) or Mode B (whole-tree: inventory pass + targeted sweeps + full Category 9). 2. Judgment over grep. Each rule names a pattern shape, not a syntactic marker. Read each rule's Shapes to recognize section before sweeping — grep finds the easy violations and misses the high-value ones (a layout marked 'use client' for one button; TanStack Query fetching initial page data; a route handler doing the work of a Server Action; a hand-rolled cache layer mimicking 'use cache'; sequential fetches hidden across parent/child Server Components). 3. Category-major, not file-major — with forcing functions. Sweep one category at a time across all in-scope files in priority order (CRITICAL → … → CROSS-CUTTING). The algorithm requires a scope declaration, per-category progress lines, and a final coverage table (category × file/bucket, cells ∈ {clean, N findings, n/a}). A missing category in the output is immediately visible. 4. Codebase-level findings come from Category 9. Single-file rules can't tell you "these two routes should be one" or "this server action is dead." Category 9 (Codebase Hygiene) sweeps the full inventory at the end and produces remove / dedup / reuse / consolidate findings.
Single-file ad-hoc questions ("is this caching strategy right?") can go straight to the relevant rule. The algorithm exists for the multi-file and whole-repo cases.
Rule Categories
| # | Category | Impact | Rules | Key Topics |
|---|---|---|---|---|
| 1 | Build & Bundle Optimization | CRITICAL | 5 | Turbopack, optimizePackageImports, dynamic imports, barrel files, serverExternalPackages |
| 2 | Caching Strategy | CRITICAL | 6 | 'use cache', revalidateTag+cacheLife, fetch options, segment config |
| 3 | Server Components & Data Fetching | HIGH | 6 | Parallel fetching, streaming, colocation, preload, no-client-fetch, error handling |
| 4 | Routing & Navigation | HIGH | 5 | Parallel routes, intercepting routes, prefetching, proxy.ts, notFound() |
| 5 | Server Actions & Mutations | MEDIUM-HIGH | 5 | Server actions, useFormStatus, action-result errors, useOptimistic, revalidation |
| 6 | Streaming & Loading States | MEDIUM | 5 | Suspense placement, loading.tsx, error.tsx, skeleton matching, nested Suspense |
| 7 | Metadata & SEO | MEDIUM | 4 | generateMetadata, sitemap.ts, robots.ts, opengraph-image.tsx |
| 8 | Client Components | LOW-MEDIUM | 4 | 'use client' boundary, children pattern, hydration mismatch, next/script |
| 9 | Codebase Hygiene | LOW-MEDIUM | 5 | Dedup server fetchers, route consolidation, dead routes/actions, `'use client'` propagation, prop drift |
Quick Reference
Critical patterns — get these right first:
- Add
'use cache'to Server Components/functions whose results should be cached (Next.js 16 dropped implicit fetch caching) - Call
revalidateTag(tag, cacheLife)with a profile — never the one-arg API - Configure
optimizePackageImportsfor icon/utility libraries with flat-export surfaces - Don't disable Turbopack persistent caching
- Wrap
<form action={serverAction}>instead of POST-to-/api/...
Next.js 16 idioms (do NOT generate Next.js 15 patterns):
proxy.ts(Node runtime) — notmiddleware.ts(Edge)- Explicit
'use cache'— not implicit fetch caching revalidateTag(tag, cacheLife)— not single-argrevalidateTag(tag)- Server Action +
useActionState— not clientfetch+useState app/sitemap.ts— not hand-maintainedpublic/sitemap.xml
Common single-file mistakes — avoid these anti-patterns:
- Sequential
awaitfor independent data (usePromise.allor preload) useEffect+fetchin a Client Component for initial page data'use client'at the layout level for one interactive button- Missing
revalidatePath/revalidateTagafter a mutating Server Action - Skeletons that don't match content dimensions (CLS hit)
Codebase-level patterns — surface these in Category 9 sweeps:
- 2+ Server Components hitting the same upstream with drifting cache policies — extract to a shared cached fetcher
- 2+ near-duplicate routes/layouts that should be one with a variant or dynamic segment — consolidate
- Routes / route handlers / Server Actions with no inbound traffic for 90+ days — delete (after analytics check)
'use client'propagating up the route tree because of one interactive leaf — demote layouts/parents to Server Components, leave a client island- Same concept under different route-param/search-param/prop names — converge on a canonical name (watch out for SEO redirects)
Table of Contents
1. Build & Bundle Optimization — CRITICAL
- 1.1 Import from the source module, not from a barrel `index.ts` — CRITICAL (2-10x faster dev startup)
- 1.2 Declare package-flat-export libraries in `optimizePackageImports` — CRITICAL (200-800ms faster imports, 50-80% smaller bundles)
- 1.3 Mark Node packages with native bindings as `serverExternalPackages` — HIGH
- 1.4 Don't disable Turbopack's persistent caching — CRITICAL (5-10x faster cold starts)
- 1.5 Split heavy components into separately loaded chunks — CRITICAL (30-70% smaller initial bundle)
2. Caching Strategy — CRITICAL
- 2.1 Make every server `fetch` declare its caching intent — HIGH
- 2.2 Declare route-level caching via segment-config exports — MEDIUM-HIGH
- 2.3 Mark cacheable Server Components/functions explicitly with `'use cache'` — CRITICAL
- 2.4 Call `revalidateTag(tag, cacheLife)` with a profile — CRITICAL
- 2.5 Every Server Action that mutates must invalidate the routes/tags that surface it — HIGH
- 2.6 Wrap per-request fetchers with React `cache()` for dedup — HIGH
3. Server Components & Data Fetching — HIGH
- 3.1 Independent server fetches run concurrently — sequential `await` is a waterfall — HIGH
- 3.2 Wrap each independently-paced async leaf in its own `<Suspense>` — HIGH
- 3.3 Each Server Component fetches the data it renders — HIGH
- 3.4 Trigger critical data fetches at the top via a `preload` call — MEDIUM-HIGH
- 3.5 Initial page data lands in HTML via a Server Component — never `useEffect`+`fetch` — MEDIUM-HIGH
- 3.6 Contain async failures via `error.tsx` or `ErrorBoundary` — MEDIUM
4. Routing & Navigation — HIGH
- 4.1 Multi-region layouts use parallel-route slots — HIGH
- 4.2 Modal/lightbox detail views use intercepting routes — HIGH
- 4.3 Tune `<Link prefetch>` to traffic likelihood — MEDIUM-HIGH
- 4.4 Network-boundary logic lives in `proxy.ts` — not `middleware.ts` — MEDIUM-HIGH
- 4.5 Missing dynamic resource calls `notFound()` for real HTTP 404 — MEDIUM
5. Server Actions & Mutations — MEDIUM-HIGH
- 5.1 Mutations from forms run through Server Actions — not API routes + client `fetch` — MEDIUM-HIGH
- 5.2 Submit buttons read parent-form pending state from `useFormStatus` — MEDIUM-HIGH
- 5.3 Server Actions return a typed error/state result — never throw silently — MEDIUM-HIGH
- 5.4 Mutations with predictable UI outcomes apply optimistically — MEDIUM
- 5.5 Every Server Action invalidates the routes/tags that surface its data — MEDIUM
6. Streaming & Loading States — MEDIUM
- 6.1 Place Suspense around independently-paced subtrees — MEDIUM
- 6.2 Every route has a `loading.tsx` adjacent to its `page.tsx` — MEDIUM
- 6.3 Every route has an `error.tsx` next to it — MEDIUM
- 6.4 Skeletons match the dimensions of the content they replace — MEDIUM
- 6.5 Nest Suspense when content has a natural reveal order — LOW-MEDIUM
7. Metadata & SEO — MEDIUM
- 7.1 Dynamic routes export `generateMetadata` for per-resource SEO — MEDIUM
- 7.2 Generate sitemaps from actual data — never hand-maintain XML — MEDIUM
- 7.3 Make crawl rules explicit via `app/robots.ts` and per-page metadata — MEDIUM
- 7.4 Generate per-page OG images via `opengraph-image.tsx` — LOW-MEDIUM
8. Client Components — LOW-MEDIUM
- 8.1 Push `'use client'` down to the interactive leaf — LOW-MEDIUM
- 8.2 Server content reaches inside a Client Component via `children` — LOW-MEDIUM
- 8.3 SSR and client initial render must produce identical HTML — LOW-MEDIUM
- 8.4 Wrap third-party scripts in `next/script` with the right `strategy` — LOW-MEDIUM
9. Codebase Hygiene — CROSS-CUTTING (multi-file findings; required for whole-repo audits)
- 9.1 Extract duplicated server-side fetchers/actions into a shared module — HIGH
- 9.2 Consolidate near-duplicate routes/layouts/components — HIGH
- 9.3 Delete unreachable routes, unused Server Actions, orphan utilities — MEDIUM-HIGH
- 9.4 Audit `'use client'` placement across the route tree — HIGH
- 9.5 Converge on canonical names for the same concept across routes/components — MEDIUM-HIGH
References
1. Next.js Documentation 2. Next.js 16 Release Notes 3. React Documentation 4. Vercel Engineering Blog
Related Skills
- For React 19 fundamentals (concurrent rendering, hooks, components), see
reactskill - For client-side form handling, see
react-hook-formskill - For client data caching with TanStack Query, see
tanstack-queryskill
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
Next.js 16 App Router
Version 1.0.3 Next.js Community January 2026
Note:
This Next.js 16 App Router guide is mainly for agents and LLMs to follow when
maintaining, generating, or refactoring Next.js codebases. Humans may also
find it useful, but guidance here is optimized for automation and consistency
by AI-assisted workflows.
---
Abstract
Comprehensive Next.js 16 App Router guide for AI agents and LLMs. Contains 45 rules across 9 categories, prioritized by impact from critical (build optimization, caching strategy) through to cross-cutting codebase hygiene (dedup, dead routes, boundary coherence). Reflects Next.js 16 changes: 'use cache' directive (no implicit caching), revalidateTag(tag, cacheLife) requirement, proxy.ts replacing middleware.ts, Turbopack persistent caching.
Rule files describe pattern shapes rather than API names and open with a "Shapes to recognize" section listing 2–4 syntactic disguises the same break can wear. Selected high-value rules (where pattern-disguise is most common in practice) include a concrete "In disguise" incorrect/correct example pair.
The skill ships a category-major review/refactor algorithm (`references/_review-algorithm.md`) with two modes (scoped vs whole-repo) and required forcing functions: scope declaration, per-category progress lines, and a coverage table that makes silent category skipping immediately visible.
---
Table of Contents
1. Build & Bundle Optimization — CRITICAL
- 1.1 Import from the source module, not from a barrel `index.ts` — CRITICAL
- 1.2 Declare package-flat-export libraries in `optimizePackageImports` — CRITICAL
- 1.3 Mark Node packages with native bindings as `serverExternalPackages` — HIGH
- 1.4 Don't disable Turbopack's persistent caching — CRITICAL
- 1.5 Split heavy components into separately loaded chunks — CRITICAL
2. Caching Strategy — CRITICAL
- 2.1 Make every server `fetch` declare its caching intent — HIGH
- 2.2 Declare route-level caching via segment-config exports — MEDIUM-HIGH
- 2.3 Mark cacheable Server Components/functions explicitly with `'use cache'` — CRITICAL
- 2.4 Call `revalidateTag(tag, cacheLife)` with a profile — CRITICAL
- 2.5 Every Server Action that mutates must invalidate the routes/tags that surface it — HIGH
- 2.6 Wrap per-request fetchers with React `cache()` for dedup — HIGH
3. Server Components & Data Fetching — HIGH
- 3.1 Independent server fetches run concurrently — sequential `await` is a waterfall — HIGH
- 3.2 Wrap each independently-paced async leaf in its own `<Suspense>` — HIGH
- 3.3 Each Server Component fetches the data it renders — HIGH
- 3.4 Trigger critical data fetches at the top via a `preload` call — MEDIUM-HIGH
- 3.5 Initial page data lands in HTML via a Server Component — MEDIUM-HIGH
- 3.6 Contain async failures via `error.tsx` or `ErrorBoundary` — MEDIUM
4. Routing & Navigation — HIGH
- 4.1 Multi-region layouts use parallel-route slots — HIGH
- 4.2 Modal/lightbox detail views use intercepting routes — HIGH
- 4.3 Tune `<Link prefetch>` to traffic likelihood — MEDIUM-HIGH
- 4.4 Network-boundary logic lives in `proxy.ts` — not `middleware.ts` — MEDIUM-HIGH
- 4.5 Missing dynamic resource calls `notFound()` for real HTTP 404 — MEDIUM
5. Server Actions & Mutations — MEDIUM-HIGH
- 5.1 Mutations from forms run through Server Actions — MEDIUM-HIGH
- 5.2 Submit buttons read parent-form pending state from `useFormStatus` — MEDIUM-HIGH
- 5.3 Server Actions return a typed error/state result — MEDIUM-HIGH
- 5.4 Mutations with predictable UI outcomes apply optimistically — MEDIUM
- 5.5 Every Server Action invalidates the routes/tags that surface its data — MEDIUM
6. Streaming & Loading States — MEDIUM
- 6.1 Place Suspense around independently-paced subtrees — MEDIUM
- 6.2 Every route has a `loading.tsx` adjacent to its `page.tsx` — MEDIUM
- 6.3 Every route has an `error.tsx` next to it — MEDIUM
- 6.4 Skeletons match the dimensions of the content they replace — MEDIUM
- 6.5 Nest Suspense when content has a natural reveal order — LOW-MEDIUM
7. Metadata & SEO — MEDIUM
- 7.1 Dynamic routes export `generateMetadata` for per-resource SEO — MEDIUM
- 7.2 Generate sitemaps from actual data — MEDIUM
- 7.3 Make crawl rules explicit via `app/robots.ts` and per-page metadata — MEDIUM
- 7.4 Generate per-page OG images via `opengraph-image.tsx` — LOW-MEDIUM
8. Client Components — LOW-MEDIUM
- 8.1 Push `'use client'` down to the interactive leaf — LOW-MEDIUM
- 8.2 Server content reaches inside a Client Component via `children` — LOW-MEDIUM
- 8.3 SSR and client initial render must produce identical HTML — LOW-MEDIUM
- 8.4 Wrap third-party scripts in `next/script` with the right `strategy` — LOW-MEDIUM
9. Codebase Hygiene — CROSS-CUTTING (multi-file findings; required for whole-repo audits)
- 9.1 Extract duplicated server-side fetchers/actions into a shared module — HIGH
- 9.2 Consolidate near-duplicate routes/layouts/components — HIGH
- 9.3 Delete unreachable routes, unused Server Actions, orphan utilities — MEDIUM-HIGH
- 9.4 Audit `'use client'` placement across the route tree — HIGH
- 9.5 Converge on canonical names for the same concept across routes/components — MEDIUM-HIGH
---
References
1. Next.js Documentation 2. Next.js 16 Release Notes 3. React Documentation 4. Vercel Engineering Blog
Rule Title Here
Brief explanation of the rule and why it matters (1-3 sentences). Focus on performance implications.
Incorrect (description of what's wrong):
// Bad code example here
const bad = example()Correct (description of what's right):
// Good code example here
const good = example()Reference: Link to documentation or resource
{
"version": "1.0.6",
"organization": "Next.js Community",
"technology": "Next.js 16 App Router",
"date": "January 2026",
"discipline": "distillation",
"abstract": "Comprehensive Next.js 16 App Router guide for AI agents and LLMs. Contains 45 rules across 9 categories, prioritized by impact from critical (build optimization, caching strategy) through to cross-cutting codebase hygiene (dedup, dead routes, boundary coherence). Covers Next.js 16 changes (`'use cache'` directive replacing implicit caching, `revalidateTag(tag, cacheLife)` requirement, `proxy.ts` replacing `middleware.ts`, Turbopack persistent caching) and App Router conventions (parallel routes, intercepting routes, server actions, server components). Rule files describe pattern shapes (not API names) and open with a 'Shapes to recognize' section listing 2-4 syntactic disguises the same break can wear. Selected high-value rules — those whose disguises are most common in practice — include a concrete 'In disguise' incorrect/correct example pair to teach pattern detection beyond grep-friendly cases. Ships a category-major review/refactor algorithm with two modes (scoped vs whole-repo) and forcing functions (scope declaration, per-category progress lines, coverage table) that make silent category skipping immediately visible.",
"references": [
"https://nextjs.org/docs",
"https://nextjs.org/blog/next-16",
"https://react.dev",
"https://vercel.com/blog"
],
"category": "Frontend"
}
Next.js 16 App Router Best Practices
Comprehensive performance optimization guide for Next.js 16 App Router applications.
Overview
This skill contains 40+ rules across 8 categories for optimizing Next.js 16 App Router applications. Rules are prioritized by impact from CRITICAL to LOW-MEDIUM.
Structure
nextjs-16-app-router/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide
├── metadata.json # Version and references
├── README.md # This file
└── rules/
├── _sections.md # Category definitions
└── {prefix}-{slug}.md # Individual rulesGetting Started
# Install dependencies (if using build scripts)
pnpm install
# Build the compiled AGENTS.md
pnpm build
# Validate the skill
pnpm validateCreating a New Rule
1. Choose the appropriate category prefix from _sections.md 2. Create a new file: rules/{prefix}-{descriptive-name}.md 3. Follow the template structure below 4. Run validation to ensure compliance
Prefix Reference
| Category | Prefix | Impact |
|---|---|---|
| Build & Bundle Optimization | build- | CRITICAL |
| Caching Strategy | cache- | CRITICAL |
| Server Components & Data Fetching | server- | HIGH |
| Routing & Navigation | route- | HIGH |
| Server Actions & Mutations | action- | MEDIUM-HIGH |
| Streaming & Loading States | stream- | MEDIUM |
| Metadata & SEO | meta- | MEDIUM |
| Client Components | client- | LOW-MEDIUM |
Rule File Structure
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: prefix, technique, tool
---
## Rule Title Here
Brief explanation of WHY this matters (1-3 sentences).
**Incorrect (description of problem):**
\`\`\`typescript
// Bad code example
\`\`\`
**Correct (description of solution):**
\`\`\`typescript
// Good code example
\`\`\`
Reference: [Link](https://example.com)File Naming Convention
Rule files follow the pattern: {prefix}-{descriptive-slug}.md
Examples:
build-dynamic-imports.mdcache-use-cache-directive.mdserver-parallel-fetching.md
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Fundamental issues that cause major performance problems |
| HIGH | Significant optimizations with measurable impact |
| MEDIUM-HIGH | Important patterns for common scenarios |
| MEDIUM | Useful optimizations for specific cases |
| LOW-MEDIUM | Minor improvements and best practices |
| LOW | Edge cases and advanced patterns |
Scripts
# Validate skill structure and content
node scripts/validate-skill.js ./skills/nextjs-16-app-router
# Build AGENTS.md from rules
node scripts/build-agents-md.js ./skills/nextjs-16-app-routerContributing
1. Follow the rule template exactly 2. Include both incorrect and correct examples 3. Quantify impact where possible 4. Reference authoritative sources 5. Run validation before submitting
Acknowledgments
Based on official Next.js documentation and Vercel engineering best practices.
Review & Refactor Algorithm
Use this when the user asks to review, refactor, modernize, or audit Next.js 16 App Router code — whether one file, a directory, or a whole repository.
Do not skim this. The default review reflex — file-by-file, grep-first — produces shallow, inconsistent results on this rule set. The procedure below is engineered to surface the refactors that grep cannot, and to make category skipping observable.
---
Principle 1 — Judgment over grep
Every single-file rule in this skill names a pattern shape, not a syntactic marker. The rule file titles describe the shape; the rule bodies open with Shapes to recognize — a list of 2–4 syntactic disguises the same break can wear.
The decision rule for every rule is: "Does this code break the pattern, in spirit?" — not "Does this string appear?"
| What grep finds | What grep misses (the high-value refactors) |
|---|---|
'use client' directive | A layout.tsx that's marked 'use client' for one button — the whole layout subtree is now client-rendered |
import { Icon } from 'lucide-react' | A barrel re-export through components/ui/index.ts doing the same expensive resolution |
cache: 'no-store' | Manual Date.now() in a query string to bust cache implicitly, doing what revalidateTag should do declaratively |
useEffect(() => fetch(...)) in a Client Component | Initial data fetched on the client via TanStack Query / SWR when the page is server-renderable |
Missing <Suspense> | A single page-level loading.tsx that gates an entire dashboard while one slow tile resolves |
Use grep/AST only to:
- Take inventory at the start (count routes, list Server Components, list Client Components, list Server Actions)
- As a post-hoc completeness check after judgment-based review (e.g. confirm zero remaining barrel-file imports after you finished refactoring)
Never use grep as the primary detector for a rule. If a violation is visible only via grep, it's the easy case — and you'll miss the harder ones. Read each rule's Shapes to recognize section before sweeping for that rule.
---
Principle 2 — Category-major sweep, not file-major
When reviewing N files against the 8 single-file categories + Category 9 (cross-cutting), the natural reflex is file-major:
for each file:
for each of 9 categories:
for each rule in category:
check fileThis fails in practice because:
- Late files and low-priority categories silently get skipped due to context fatigue.
- The reviewer never sees the cross-file patterns in a category (e.g. "3 of these 5 pages have the same client-side data-fetching bug").
- Cross-cutting findings (dead routes, duplicated server actions, near-duplicate layouts) cannot surface from a file-local lens.
- Reports come out file-by-file, which the user has to mentally re-group by theme.
Do this instead — category-major:
1. Load all target files into context up front.
2. For each of Categories 1-8, in priority order (CRITICAL → HIGH → MEDIUM-HIGH → MEDIUM → LOW-MEDIUM):
a. State the category's pattern in one sentence (the underlying intent).
b. Sweep every file simultaneously, looking for breaks of that pattern.
c. Record findings grouped by category, with file:line references.
3. Run Category 9 (Codebase Hygiene) as a final cross-cutting sweep over the inventory.
4. Emit the required coverage table.---
Principle 3 — Two modes: scoped vs whole-repo
Whole-repo audits are a real workflow, not a workflow to refuse. The two modes below differ in inventory strategy, not in rule rigor.
| Mode A — Scoped audit | Mode B — Repository audit | |
|---|---|---|
| Scope | Explicit file set, ≤ ~20 files (a feature directory, a PR, a hand-picked set) | A whole tree (app/, the repo, a subsystem) — no pre-curated file list |
| Inventory | Read every file fully | Glob + classify without reading bodies; then read targeted files |
| Sweep | Full category-major sweep across every file for every category | Targeted sweeps: top-N files per category by heuristic (see below) + full Category 9 over the inventory |
| Output | Findings per category × file | Inventory table + targeted findings + Category 9 findings + explicit gaps |
Both modes emit the same coverage table (Step 4 below).
Heuristics for Mode B targeted sweeps:
- Build & Bundle → all
next.config.{js,ts,mjs}, allpackage.json, all files matchingimport * as(barrel candidates), top 10 components by line count. - Caching Strategy → all
app/**/page.tsx,app/**/layout.tsx,app/**/route.ts; all files containingfetch(,cache(, or'use cache'. - Server Components & Data Fetching → all
app/**/page.tsx,app/**/layout.tsx; all files containingawait fetchorawait db.. - Routing & Navigation → all
app/**/page.tsx, all parallel/intercepting route folders (@*/,(.)*/), allproxy.ts. - Server Actions & Mutations → all files containing
'use server'or<form action={; all route handlers (route.ts). - Streaming & Loading States → all
loading.tsx, allerror.tsx, all files containing<Suspense>. - Metadata & SEO → all
app/**/page.tsx,app/**/layout.tsx, allsitemap.{ts,xml}, allrobots.{ts,txt}, allopengraph-image.{ts,tsx,png}. - Client Components → all files with
'use client'. - Category 9 → full inventory.
If a heuristic returns < 3 files, sweep all of them. If it returns > 15, sweep the first 15 ranked and note the truncation in the coverage table.
---
Procedure
Step 0 — Pick the mode
| User said | Mode |
|---|---|
| "audit these N files", "review this PR", explicit list | Mode A |
| "audit my Next.js codebase", "review app/", "modernize this repo", or any whole-tree language | Mode B |
| Ambiguous | Ask. Show the file count both modes would produce. |
Never refuse a whole-repo audit — pick Mode B.
Step 1 — Scope declaration (REQUIRED OUTPUT)
Before any reading, emit this preamble verbatim with the placeholders filled. The user must be able to see what you're about to do.
## Audit scope
- **Mode:** A (scoped) | B (repo)
- **Files in scope:** <N total> — <brief breakdown e.g. "12 pages, 4 layouts, 6 route handlers, 8 Server Actions, 14 Client Components, 2 next.config files">
- **Categories to sweep, in order:**
1/9 Build & Bundle Optimization (CRITICAL) — <files to sweep>
2/9 Caching Strategy (CRITICAL) — <files to sweep>
3/9 Server Components & Data Fetching (HIGH) — <files to sweep>
4/9 Routing & Navigation (HIGH) — <files to sweep>
5/9 Server Actions & Mutations (MEDIUM-HIGH) — <files to sweep>
6/9 Streaming & Loading States (MEDIUM) — <files to sweep>
7/9 Metadata & SEO (MEDIUM) — <files to sweep>
8/9 Client Components (LOW-MEDIUM) — <files to sweep>
9/9 Codebase Hygiene (CROSS-CUTTING) — full inventoryA scope declaration that omits any category number is a malformed audit — you cannot proceed.
Step 2 — Inventory pass
Read every file once (Mode A) or glob + classify by filename and top-level imports without reading bodies (Mode B). For each, tag:
- Server Component (no
'use client', no client hooks, typically inapp/outside'use client'files) - Client Component (
'use client'directive) - Server Action file (
'use server'at top, or contains'use server'inside functions) - Route handler (
route.ts, exportsGET/POST/etc.) - Route entry (
page.tsx,layout.tsx,template.tsx) - Special route file (
loading.tsx,error.tsx,not-found.tsx,default.tsx) - Metadata file (
sitemap.ts,robots.ts,opengraph-image.tsx,icon.tsx) - Config (
next.config.*,proxy.ts) - Other (utility, types)
In Mode B, this tagging also feeds the Category 9 sweep (e.g. files tagged "Client Component" with hook usage that doesn't need the client become candidates for cross-boundary-coherence).
Step 3 — Category-major sweeps (REQUIRED PROGRESS LINES)
For each of the 9 categories in order, emit a per-category progress line before sweeping:
### Sweeping <N>/9 — <Category Name> (<Impact>) across <M> filesThen sweep that category across all in-scope files using its Shapes to recognize as the lens (not its API name).
After the sweep, emit one of:
**Findings: <K>**followed by the findings grouped under that heading, OR**Findings: 0** (no breaks of <pattern statement> detected across <M> files)
A category that gets no progress line is a category that got skipped. The structure makes skipping observable.
Step 4 — Coverage table (REQUIRED OUTPUT)
After all 9 sweeps, emit a coverage table. Rows = categories. Columns = either each file (Mode A) or each tag bucket (Mode B). Cells ∈ {clean, N findings, n/a}.
Mode A example:
| Category | app/page.tsx | app/dashboard/page.tsx | app/api/users/route.ts | … |
|---|---|---|---|---|
| 1 Build & Bundle | n/a | n/a | n/a | |
| 2 Caching | clean | 2 findings | 1 finding | |
| 3 Server Components | clean | 1 finding | n/a | |
| … | ||||
| 9 Codebase Hygiene | applied across all files: 3 findings |
Mode B example:
| Category | Pages (12) | Layouts (4) | Route handlers (6) | Server Actions (8) | Client Comps (14) | Config (3) |
|---|---|---|---|---|---|---|
| 1 Build & Bundle | 10 swept, 0 findings | n/a | n/a | n/a | 10 swept, 4 findings | 3 swept, 2 findings |
| 2 Caching | 12 swept, 6 findings | 4 swept, 1 finding | 6 swept, 3 findings | 8 swept, 2 findings | n/a | n/a |
| 3 Server Components | 12 swept, 5 findings | 4 swept, 0 findings | n/a | n/a | n/a | n/a |
| … | ||||||
| 9 Codebase Hygiene | full inventory: 11 findings (4 dedup, 3 dead routes, 2 boundary, 2 prop drift) |
The coverage table is non-negotiable. It is the artifact that makes silent skipping impossible — a missing row or a missing column is immediately visible.
Step 5 — Report findings
Group output by category, then by file. For each finding:
- File:line — exact location
- Pattern break — one sentence, in the spirit of the rule (not "missing
'use cache'" but "this server-side fetcher is invoked from three routes and re-runs on every request — should be cached") - Suggested refactor — concrete shape, not just a rule name
- Rule reference — link to the rule file
Add a Cross-file observations subsection per category when 2+ files share the same break — surface the cluster, don't repeat the explanation.
Category 9 findings have a different shape because they are inherently cross-file:
- Affected files — full list, with the canonical version named first if applicable
- Proposed action — extract / consolidate / delete / rename / move to server
- Estimated impact — bundle bytes saved, files deleted, routes consolidated, props normalized
- Risk — anything blocking (e.g. dynamic import, external consumer, public API surface, parallel route)
Step 6 — Apply (optional)
When the user approves refactors:
- Apply by category, not by file — finish all of category 1 across all files before starting category 2. This makes the diff coherent per concern and easier to review.
- Apply Category 9 refactors last — they often touch files modified in earlier categories, and doing them last lets you fold the previous fixes into the consolidated shared module / route.
- After applying each category, take an inventory pass: re-read the touched files to confirm no regressions introduced (especially: did a fix to one rule break a parallel-route or intercepting-route convention?).
---
What this algorithm refuses to do
- File-major reports — never emit findings as "## app/page.tsx — issues: …" headings. Always group by category.
- Grep-only findings — if the only evidence is a string match, re-check by reading the surrounding 30 lines and judging the pattern. Grep is the trigger, never the verdict.
- Skipping the scope declaration or coverage table — these are required artifacts. An audit without them is not an audit.
- Trivial syntactic rewrites masquerading as refactors — replacing
cache: 'force-cache'with'use cache'is a codemod, not a refactor. The skill-worthy refactors are the ones that change the shape of data flow, caching boundaries, and server/client split. - Mass cache-tag additions without a revalidation story — adding
cacheLifeprofiles without thinking about what invalidates them is worse than no cache.
What this algorithm does not refuse:
- Whole-repo
find/ glob scans — use Mode B with an inventory pass instead. - Audits without a hand-curated file list — Mode B exists precisely for this.
---
Quick sanity check before reporting
Before delivering findings, confirm in your head:
- Did I emit the scope declaration with all 9 categories listed?
- Did I emit a progress line for each of the 9 categories?
- For each finding, is the evidence holistic (I read the surrounding code) or just a keyword match?
- Did I surface cross-file clusters where they exist?
- For categories with zero findings, did I emit the explicit "Findings: 0" line?
- Did I run Category 9 as a final cross-cutting sweep, not skip it because it's last?
- Did I emit the coverage table?
If any of these is "no", the audit is incomplete. Go back.
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Build & Bundle Optimization (build)
Impact: CRITICAL Description: Turbopack configuration, optimizePackageImports, and dynamic imports reduce cold start times and bundle size by up to 70%.
2. Caching Strategy (cache)
Impact: CRITICAL Description: The 'use cache' directive, revalidateTag, and cacheLife profiles control data freshness and reduce server load by eliminating redundant fetches.
3. Server Components & Data Fetching (server)
Impact: HIGH Description: Parallel fetching, React cache(), and streaming patterns eliminate server-side waterfalls and reduce Time to First Byte.
4. Routing & Navigation (route)
Impact: HIGH Description: Parallel routes, intercepting routes, prefetching, and proxy.ts optimize navigation performance and user experience.
5. Server Actions & Mutations (action)
Impact: MEDIUM-HIGH Description: Form handling, revalidatePath, and redirect patterns enable secure, performant data mutations with proper cache invalidation.
6. Streaming & Loading States (stream)
Impact: MEDIUM Description: Strategic Suspense boundaries, loading.tsx, and error.tsx enable progressive rendering and faster perceived performance.
7. Metadata & SEO (meta)
Impact: MEDIUM Description: generateMetadata, sitemap generation, and OpenGraph optimization improve search visibility and social sharing.
8. Client Components (client)
Impact: LOW-MEDIUM Description: Proper 'use client' boundaries and hydration optimization minimize client-side JavaScript and improve interactivity.
9. Codebase Hygiene (cross)
Impact: LOW-MEDIUM Description: Cross-cutting findings that only surface across files: duplicated server-side fetchers/actions that should be a shared module, near-duplicate routes/layouts that should be one, unused route files/server actions/components, 'use client' files (or parent layouts) that don't need client execution, and same-concept-different-name prop drift across server boundaries. The category sits at LOW-MEDIUM as a baseline urgency because most well-maintained codebases are clean here; the individual rule impacts within are calibrated separately (extract-shared-logic and route consolidation are HIGH when they fire, dead-code and boundary-coherence are MEDIUM-HIGH, etc.). These rules use a multi-file format alongside the standard single-file Incorrect/Correct shape, and run as a final sweep after Categories 1–8 in the review algorithm. Required for any whole-repo audit — single-file rule sweeps cannot, by construction, produce these findings.
Server Actions return a typed error/state result — never throw silently or rely on the client to know what failed
Pattern intent: validation and business-rule failures inside a Server Action should return a typed state object ({ error: string } or { errors: { field: string[] } }), not throw. The client form pairs the action with useActionState, which surfaces the returned state directly.
Shapes to recognize
- A
'use server'action that throws on validation failure — the error bubbles toerror.tsxinstead of being displayed inline at the form. - An action that calls
console.error('Invalid input')andreturn undefined— silent failure; user has no idea why nothing happened. - An action with a
try/catchthat swallows errors and returnsnull— silently drops user input. - An action that returns
{ error: e.message }but the caller doesn't renderstate.erroranywhere — the typed result exists but isn't surfaced. - An action that uses
redirectas the error path (if (!ok) redirect('/error')) — loses field-level error context; user can't fix the form.
The canonical resolution: define a typed state ({ error?, success?, errors? }); validate first, return { error } on failure; on success do the mutation, revalidatePath/revalidateTag, and either redirect or return { success: true }. The form uses useActionState(action, {}).
Incorrect (unhandled errors):
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title') as string
await db.posts.create({ data: { title } })
// If validation fails or DB errors, user sees nothing
}Correct (returning error state):
// actions.ts
'use server'
type ActionState = {
error?: string
success?: boolean
}
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const title = formData.get('title') as string
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' }
}
try {
await db.posts.create({ data: { title } })
revalidatePath('/posts')
return { success: true }
} catch (e) {
return { error: 'Failed to create post. Please try again.' }
}
}
// page.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
export default function NewPostForm() {
const [state, formAction, isPending] = useActionState(createPost, {})
return (
<form action={formAction}>
<input name="title" />
{state.error && <p className="error">{state.error}</p>}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
</form>
)
}Reference: useActionState
Mutations whose UI outcome is predictable apply optimistically with useOptimistic — automatic rollback on server failure
Pattern intent: "click → wait 200-500ms → see result" feels sluggish. For high-frequency UI actions (likes, toggles, follow, add-to-cart) where the new state is deterministic, apply the change immediately and let useOptimistic revert if the server rejects.
Shapes to recognize
- A
'Like'button that calls a Server Action andawaits before updating UI — the heart icon flashes ~300ms after the click. - A toggle that shows a spinner during submission — the spinner is the only feedback for an instant operation.
- A "favorite" button storing state in
useState, manually mutating on click, manually rolling back incatch— handles the case but inconsistent across the app. - A shopping-cart "add" button that disables itself for ~500ms post-click — uses
useTransitionfor pending state but doesn't update the cart count optimistically. - A workaround using SWR's
mutate(...)with optimistic data — works in client-data-fetching contexts;useOptimisticis the React-native equivalent that pairs cleanly with Server Actions.
The canonical resolution: const [optimistic, addOptimistic] = useOptimistic(real, reducer). Call addOptimistic(value) inside the form action before await-ing the server call. React reverts automatically when the action settles, regardless of outcome.
Incorrect (waiting for server response):
'use client'
import { useState } from 'react'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
const [isLiking, setIsLiking] = useState(false)
async function handleLike() {
setIsLiking(true)
const newLikes = await likePost(postId) // Wait for server
setLikes(newLikes)
setIsLiking(false)
}
return (
<button onClick={handleLike} disabled={isLiking}>
{likes} {isLiking ? '...' : '❤️'}
</button>
)
}
// 200-500ms delay before UI updatesCorrect (optimistic update):
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, _) => state + 1
)
async function handleLike() {
addOptimisticLike(null) // Instant UI update
await likePost(postId) // Server update in background
// If fails, React reverts automatically
}
return (
<form action={handleLike}>
<button type="submit">
{optimisticLikes} ❤️
</button>
</form>
)
}
// Instant feedback, reverts on failureWhen to use:
- Like/vote buttons
- Adding items to cart
- Toggling favorites
- Any action where instant feedback improves UX
Submit buttons read parent-form pending state from useFormStatus — not from a prop drilled in
Pattern intent: the submit button knows whether its containing form is submitting via useFormStatus from react-dom. That information comes from the form context, not from a useState cell lifted to the parent.
Shapes to recognize
- A submit button with
disabled={isPending}whereisPendingis auseStatelifted from the page-level component and threaded through<Form><Button isPending={isPending}/></Form>. - A form with no pending feedback at all — user clicks "Create" three times because nothing happens visibly.
useFormStatus()called in the same component as the<form>— returnspending: falsealways, because the hook reads the parent form's status. The fix is to extract the button to a child component.- A "form context" hand-rolled by the team to share submit state — reinvented
useFormStatus. - A workaround calling
useTransitionin the consumer to track submission — works for non-form mutations, but for form actionsuseFormStatusis the right primitive.
The canonical resolution: extract submit button into a separate Client Component; that component calls useFormStatus() and reads { pending, data, method } from the surrounding form context.
Incorrect (no feedback during submission):
// app/posts/new/page.tsx
export default function NewPostPage() {
async function createPost(formData: FormData) {
'use server'
await db.posts.create({ data: { title: formData.get('title') } })
}
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create Post</button>
{/* User clicks multiple times, no feedback */}
</form>
)
}Correct (pending state with useFormStatus):
// app/posts/new/page.tsx
import { SubmitButton } from './submit-button'
export default function NewPostPage() {
async function createPost(formData: FormData) {
'use server'
await db.posts.create({ data: { title: formData.get('title') } })
}
return (
<form action={createPost}>
<input name="title" />
<SubmitButton />
</form>
)
}
// submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}Note: useFormStatus must be used in a child component of the form, not in the same component as the form element.
Every Server Action that writes data must invalidate the routes/tags that surface that data
Pattern intent: mutation + invalidation form a transaction in the user's mental model. An action without a paired invalidation leaves the user looking at stale data with no error to debug.
Shapes to recognize
- A
'use server'action withawait db.x.create(...)and norevalidatePath/revalidateTagcall — user sees no change until cache expires. - A bug report of the form "Created the thing — refresh, still not there" — almost always a missing invalidation in the action.
- An action that calls
revalidatePath('/foo')but the data also surfaces on/barand/baz— under-invalidation. - An action that calls
revalidatePath('/', 'layout')for a small mutation — nukes everyone's cache; should be more targeted. - An action that returns
{ success: true }and the client triggersrouter.refresh()afterward — works but loses the server-driven invalidation guarantee. - A
redirect()called beforerevalidatePath—redirectthrows internally; the invalidation never runs.
The canonical resolution: after the write succeeds, call revalidateTag(tag, cacheLife) (preferred for granular control) or revalidatePath(path) (coarser), then redirect(...). Pair every action with one or more invalidation calls.
Incorrect (stale cache after mutation):
'use server'
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } })
redirect('/posts')
// Posts list still shows deleted post from cache!
}Correct (invalidating cache):
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } })
// Option 1: Revalidate specific path
revalidatePath('/posts')
// Option 2: Revalidate by tag (more granular)
revalidateTag('posts')
redirect('/posts')
}
export async function updatePost(postId: string, formData: FormData) {
await db.posts.update({
where: { id: postId },
data: { title: formData.get('title') }
})
// Revalidate both the list and detail pages
revalidatePath('/posts')
revalidatePath(`/posts/${postId}`)
}Revalidation strategies:
// Specific route
revalidatePath('/posts')
// Dynamic route with specific ID
revalidatePath(`/posts/${postId}`)
// All routes using a layout
revalidatePath('/dashboard', 'layout')
// By cache tag
revalidateTag('posts')
// Multiple tags
revalidateTag('posts')
revalidateTag(`post-${postId}`)Mutations from forms run through Server Actions — not custom API routes + client fetch
Pattern intent: in App Router, form-driven mutations belong in a Server Action bound to <form action={...}>. The old "POST to /api/x, parse JSON, manually invalidate cache" pattern is now boilerplate that delivers a worse UX (requires JS to submit).
Shapes to recognize
- A
'use client'page withonSubmit={async (e) => { e.preventDefault(); fetch('/api/...', {...}) }}— the classic anti-pattern. - A
route.tsPOST handler that exists only to receive form submissions from one specific page — should be a Server Action. - A custom hook (
useCreatePost) that wrapsfetchanduseStateto track submission — Server Action +useActionStatedoes this declaratively. - A page that mutates state via fetch, then manually calls
router.refresh()to reload data — the action should callrevalidatePathserver-side instead. - A workaround using TanStack Query / SWR mutations against a route handler — fine for some cases, but for form-shaped mutations the Server Action path is simpler and progressively enhanced.
The canonical resolution: declare async function createX(formData: FormData) { 'use server'; ... }. Bind via <form action={createX}>. Call revalidatePath/revalidateTag then redirect server-side.
Incorrect (API route for form handling):
// app/api/posts/route.ts
export async function POST(request: Request) {
const data = await request.json()
const post = await db.posts.create({ data })
return Response.json(post)
}
// app/posts/new/page.tsx
'use client'
export default function NewPostPage() {
const handleSubmit = async (e) => {
e.preventDefault()
const formData = new FormData(e.target)
await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData))
})
}
// Requires client component, manual fetch, no type safety
}Correct (Server Action):
// app/posts/new/page.tsx
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export default function NewPostPage() {
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title') as string
const content = formData.get('content') as string
const post = await db.posts.create({
data: { title, content }
})
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" />
<button type="submit">Create Post</button>
</form>
)
}
// Works without JS, type-safe, integrated cachingBenefits:
- Progressive enhancement (works without JavaScript)
- Type-safe with TypeScript
- Direct cache invalidation
- No API route boilerplate
---
In disguise — route handler POST + client fetch doing the work of a Server Action
The grep-friendly anti-pattern is onSubmit={(e) => { e.preventDefault(); fetch('/api/...') }}. The disguise is more sophisticated: a route.ts POST handler that exists only to receive form submissions, paired with a Client Component that POSTs to it. This is "the Pages Router pattern, ported into App Router" and looks reasonable until you compare it to the Server Action equivalent.
Incorrect — in disguise (route handler + client POST + manual revalidation):
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const data = await request.json()
const post = await db.posts.create({ data })
// No cache invalidation here — the client has to trigger router.refresh() afterward
return NextResponse.json(post)
}
// app/posts/new/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function NewPostPage() {
const [submitting, setSubmitting] = useState(false)
const router = useRouter()
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSubmitting(true)
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({
title: formData.get('title'),
content: formData.get('content'),
}),
headers: { 'Content-Type': 'application/json' },
})
const post = await res.json()
router.refresh() // hope this picks up the new post somehow
router.push(`/posts/${post.id}`)
}
return (
<form onSubmit={onSubmit}>
<input name="title" required />
<textarea name="content" />
<button disabled={submitting}>{submitting ? 'Creating...' : 'Create'}</button>
</form>
)
}What's wrong: not progressively enhanced (form fails without JS); manual JSON.stringify instead of FormData; manual submission state; client-driven router.refresh() instead of server-driven revalidateTag; doubled type definitions (request body type + DB schema type) that drift.
Correct — Server Action handles everything in one path:
// app/posts/new/page.tsx (Server Component shell)
import { redirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title') as string
const content = formData.get('content') as string
const post = await db.posts.create({ data: { title, content } })
revalidateTag('posts', 'max')
redirect(`/posts/${post.id}`)
}
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" />
<SubmitButton />
</form>
)
}
// SubmitButton.tsx — small client island
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Creating...' : 'Create'}</button>
}Half the code, type-safe end-to-end, progressively enhanced, cache invalidation happens server-side. The route handler can be deleted (or kept only if external consumers need it).
Import from the source module, not from a barrel index.ts — barrel re-exports pessimize tree-shaking
Pattern intent: every import from a barrel file loads every module the barrel touches. In dev mode (where the bundler can't always prove unused exports are dead), one import becomes dozens of module loads. The fix is to import from the source file directly.
Shapes to recognize
import { formatDate } from '@/lib/utils'where@/lib/utils/index.tsisexport * from './formatDate'; export * from './formatCurrency'; ...— every consumer pulls in everything.- A
components/ui/index.tsre-exporting 30+ components, consumed from every page — every page touches the full re-export graph. - An internal package in a monorepo (
@org/ui) whose root entry is a barrel — same problem at workspace scope. - A barrel that does
export * from './x'(worst — loads everything in./x) vsexport { a } from './x'(still loads./xonce but compilers can sometimes optimize). - A barrel with side-effectful module imports — even setting
"sideEffects": falsedoesn't always rescue you. - Workaround: route everything through
optimizePackageImports— works for some packages but not your own; better to fix the barrel.
The canonical resolution: import the file directly (@/lib/utils/formatDate) or set up TS path aliases that point at sources. For shared component libraries, prefer explicit per-component imports over a barrel.
Incorrect (imports through barrel file):
// lib/utils/index.ts (barrel file)
export * from './formatDate'
export * from './formatCurrency'
export * from './validateEmail'
// ... 50 more exports
// app/dashboard/page.tsx
import { formatDate } from '@/lib/utils'
// Loads all 50+ modules even though only formatDate is usedCorrect (direct import):
// app/dashboard/page.tsx
import { formatDate } from '@/lib/utils/formatDate'
// Loads only the formatDate moduleAlternative (path aliases):
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/utils/*": ["./lib/utils/*"]
}
}
}
// app/dashboard/page.tsx
import { formatDate } from '@/utils/formatDate'Note: If you must use barrel files, configure optimizePackageImports or use explicit named exports instead of export *.
Split heavy components that aren't visible at first paint into separately loaded chunks
Pattern intent: every component imported at the top of a route ships in the initial bundle, even if it's only rendered conditionally (modals, charts, editors, maps). Splitting them into a dynamically-loaded chunk means the browser fetches them only when they're actually rendered.
Shapes to recognize
- A top-level
import HeavyChart from '...'followed by{open && <HeavyChart/>}— the component ships even whenopenis always false. - A modal/drawer/dialog component imported eagerly and rendered conditionally —
{isOpen && <SettingsModal/>}ships ~100KB the user never sees. - A code editor, video player, chart library, or map component imported in a layout — even pages that don't use it pay the cost.
- A "feature flag protected" component imported normally and gated by
if (flag)— the gated path still ships. - Workaround: a
React.lazy(() => import(...))instead ofnext/dynamic— works for client components but loses Next.js's SSR/loading-state integration; prefernext/dynamicin App Router code. - An
<iframe src="..." />hack to defer heavy components — works but loses SSR and styling integration.
The canonical resolution: const X = dynamic(() => import('./X'), { loading: () => <XSkeleton/> }). Add ssr: false only when the component genuinely cannot SSR (e.g., touches window at module scope).
Reference: Dynamic Imports
Incorrect (always included in main bundle):
import HeavyChart from '@/components/HeavyChart'
import CodeEditor from '@/components/CodeEditor'
export default function Dashboard() {
const [showChart, setShowChart] = useState(false)
return (
<div>
{showChart && <HeavyChart />}
<CodeEditor />
</div>
)
}
// Both components in initial bundle (~500KB added)Correct (loaded on demand):
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />
})
const CodeEditor = dynamic(() => import('@/components/CodeEditor'), {
ssr: false // Client-only component
})
export default function Dashboard() {
const [showChart, setShowChart] = useState(false)
return (
<div>
{showChart && <HeavyChart />}
<CodeEditor />
</div>
)
}
// Components loaded only when renderedWhen to use `ssr: false`: For components that access browser APIs (window, document) or libraries without SSR support.
---
In disguise — React.lazy + useEffect-triggered import instead of next/dynamic
The grep-friendly anti-pattern is a top-level import HeavyChart. The disguise is the developer realizing it's heavy and reaching for React.lazy plus a manual useEffect to "kick off the import." This produces a working result but loses Next.js's SSR/loading-state integration and is harder to type-check.
Incorrect — in disguise (React.lazy + useEffect kick-off):
'use client'
import { useState, useEffect, lazy, Suspense } from 'react'
const HeavyChart = lazy(() => import('@/components/HeavyChart'))
export function ChartSection({ data }: { data: ChartData }) {
const [shouldLoad, setShouldLoad] = useState(false)
useEffect(() => {
const id = setTimeout(() => setShouldLoad(true), 0)
return () => clearTimeout(id)
}, [])
if (!shouldLoad) return <ChartSkeleton />
return (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
)
}What's wrong: the manual useEffect + setTimeout reinvents the loading delay; React.lazy is client-only; the skeleton appears twice (once gated by shouldLoad, once by Suspense); the import path isn't SSR-aware.
Correct — `next/dynamic` with platform integration:
'use client'
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // only if the chart genuinely cannot SSR
})
export function ChartSection({ data }: { data: ChartData }) {
return <HeavyChart data={data} />
}The fallback, the SSR semantics, and the lazy-load triggering are all platform-managed.
Final reference: Dynamic Imports
Mark Node packages with native bindings or non-bundleable resolution as serverExternalPackages
Pattern intent: some Node packages ship native .node binaries, use require() at runtime to resolve drivers, or pull in heavy peer trees that the bundler can't trace. Trying to bundle them either fails the build or produces a broken artifact. serverExternalPackages tells Next.js "don't try; load this from node_modules at runtime."
Shapes to recognize
- A build error like "Module not found: Can't resolve './build/Release/...'" — almost always a native-binding package being bundled.
puppeteer,sharp,canvas,bcrypt,argon2,node-gyp-built packages used in Server Components or route handlers without being listed.- Database drivers (
pg,mysql2,better-sqlite3,@prisma/client) imported from server-only code without externalization — sometimes works, sometimes catastrophically large bundles. - A workaround
next.config.jswith custom webpackexternals: [...]config — pre-Turbopack era; should beserverExternalPackagesin App Router. - A
try { require(...) }wrapping an import to "be safe" — masks the real issue; configuringserverExternalPackagesremoves the need.
The canonical resolution: list the offenders in serverExternalPackages in next.config. Next.js loads them from node_modules at runtime rather than trying to bundle them.
Incorrect (bundling native modules):
// next.config.ts
const nextConfig = {
// No external packages configured
}
// lib/pdf.ts
import puppeteer from 'puppeteer'
// Build fails or produces oversized bundlesCorrect (excluding native modules):
// next.config.ts
const nextConfig = {
serverExternalPackages: [
'puppeteer',
'sharp',
'canvas',
'@prisma/client',
'bcrypt'
]
}
// lib/pdf.ts
import puppeteer from 'puppeteer'
// Loaded at runtime from node_modulesCommon packages to externalize:
- Database drivers:
@prisma/client,pg,mysql2 - Image processing:
sharp,canvas - Native bindings:
bcrypt,argon2 - Browser automation:
puppeteer,playwright
Declare package-flat-export libraries in optimizePackageImports so the compiler tree-shakes them
Pattern intent: libraries that ship a flat re-export surface (lucide-react, @heroicons/react, @mui/icons-material, date-fns, lodash) load every module when any named import is referenced, unless the bundler is told it's safe to pick out only what's used. The optimizePackageImports config does exactly that.
Shapes to recognize
import { Menu } from 'lucide-react'(or any other icon library) wherenext.config.{js,ts}does not listlucide-reactinexperimental.optimizePackageImports.- A barrel file in the repo (
@/components/uior similar) that re-exports 50+ items, used from many call sites — pays the same cost. - A
package.jsonlisting icon/utility libraries with no corresponding optimization config — every dev startup spends seconds resolving every export. - A custom-built utility library inside the monorepo whose top-level export re-exports dozens of submodules — same problem.
- Workaround: importing from a deep path like
lucide-react/icons/Menu— works for some libraries but not others, and the codebase splits between two conventions.
The canonical resolution: add the package(s) to experimental.optimizePackageImports. Next.js 16 ships defaults for the most common libraries; add custom ones.
Reference: How we optimized package imports in Next.js
Incorrect (loads entire library):
// next.config.ts
const nextConfig = {
// No optimization configured
}
// components/Header.tsx
import { Menu, X, Search } from 'lucide-react'
// Loads 1,583 modules, adds ~2.8s to dev startupCorrect (loads only used icons):
// next.config.ts
const nextConfig = {
experimental: {
optimizePackageImports: ['lucide-react', '@heroicons/react', '@mui/icons-material']
}
}
// components/Header.tsx
import { Menu, X, Search } from 'lucide-react'
// Loads only 3 modules (~2KB vs ~1MB)Note: Next.js 16 automatically optimizes common libraries. Add custom libraries that export many modules.
Reference: How we optimized package imports in Next.js
Don't disable Turbopack's persistent caching — the defaults are what give 5-10× faster restarts
Pattern intent: Next.js 16's Turbopack ships persistent file-system caching enabled by default. The fast restart story depends on it. Configurations that toggle the cache off (often copied from old guides or copy-pasted from another project) silently drop the win.
Shapes to recognize
experimental.turbo.persistentCaching: falseinnext.config.{js,ts,mjs}— kills the persistent cache.- A
.gitignorerule excluding.next/cache/turbopackplus CI clearing.nextbetween builds — guarantees a cold start every dev session locally, every build remotely. - A pre-
dev/pre-buildscript doingrm -rf .next"to be safe" — defeats the cache. - Custom
webpackconfiguration that conflicts with Turbopack (loaders pointing atwebpackrather thanturbo.rules) — falls back to webpack and loses Turbopack speed. - A
nextinvocation explicitly passing--no-turbopacksomewhere in package.json scripts — silently downgrades. - A Docker dev image that doesn't mount
.next/cacheas a volume — re-creates the cache every container start.
The canonical resolution: leave experimental.turbo defaults alone unless adding custom loaders/rules. Mount .next/cache if running in containers. Stop running pre-build clean steps in dev workflows.
Reference: Next.js 16 Release Notes
Incorrect (disabling Turbopack features):
// next.config.ts
const nextConfig = {
experimental: {
turbo: {
// Disabling caching slows down restarts
persistentCaching: false
}
}
}Correct (leveraging Turbopack defaults):
// next.config.ts
const nextConfig = {
// Turbopack is default in Next.js 16
// File system caching is enabled by default
experimental: {
turbo: {
// Add custom loaders if needed
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js'
}
}
}
}
}Development command:
# Turbopack is now the default bundler
next dev
# Explicitly enable for clarity
next dev --turbopackNote: Turbopack caches to .next/cache/turbopack. Don't add this to .gitignore locally for persistent caching across restarts.
Reference: Next.js 16 Release Notes
Make every server fetch declare its caching intent — never let the default behavior be the documentation
Pattern intent: every fetch in a Server Component or server function should declare what it wants — cache: 'no-store' for live data, cache: 'force-cache' for static, next: { revalidate: N } for time-based, or next: { tags: [...] } for tag-based. Leaving it implicit means the answer to "is this cached?" is "go read the framework changelog for the version we're on."
Shapes to recognize
- A
fetch(...)without any cache/revalidate/tags option in a Server Component — the intent is invisible to anyone reading it. - A user-specific fetch (
fetch(\/api/users/${userId}\)) withoutcache: 'no-store'— quietly returns stale data for the wrong user after the first request. - A "config" fetch (
fetch(\/api/config\)) withoutforce-cacheor arevalidate— hits upstream on every render. - Two fetches to the same upstream endpoint with different cache settings in different files — divergent freshness, hard-to-diagnose bugs.
- A
fetchwhose URL contains a?_=${Date.now()}cache-buster — manual cache invalidation, doing what Next.js's cache controls would do declaratively. - A workaround
headers: { 'Cache-Control': 'no-cache' }instead ofcache: 'no-store'— works for the upstream but doesn't tell Next.js the response is uncacheable.
The canonical resolution: pick the right mode for each fetch and make it explicit. Decision tree: per-user/real-time → no-store; semi-dynamic → next: { revalidate }; truly static → force-cache; on-demand invalidation → next: { tags }.
Incorrect (mixing cache strategies without intent):
export default async function Page() {
// Static data that rarely changes - correct
const config = await fetch('https://api.example.com/config')
// User-specific data that should be fresh - WRONG
const user = await fetch(`https://api.example.com/users/${userId}`)
// Using default caching for dynamic data!
}Correct (explicit cache strategies):
export default async function Page() {
// Static data - cache indefinitely
const config = await fetch('https://api.example.com/config', {
cache: 'force-cache'
})
// Dynamic data - never cache
const user = await fetch(`https://api.example.com/users/${userId}`, {
cache: 'no-store'
})
// Semi-dynamic - revalidate every 5 minutes
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 300 }
})
// Tagged for on-demand revalidation
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
}Cache strategy decision tree:
- User-specific or real-time →
no-store - Changes hourly/daily →
next: { revalidate: N } - Static/rarely changes →
force-cache - Needs on-demand invalidation →
next: { tags: [...] }
Wrap per-request fetchers with React cache() so calls from multiple Server Components in one render dedupe
Pattern intent: in a Server Component tree, multiple components (header, sidebar, page, footer) may each call getUser(userId). Without cache(), each call is an independent fetch. With cache(), they all return the same in-render-memoized result.
Shapes to recognize
- A
getX(id)function called from multiple Server Components, each making its own fetch (visible as duplicate log lines per request). - A
console.log('Fetching X')printing multiple times per page load — the fetcher is being called repeatedly with identical args. - Multiple components in one route tree calling
await db.user.findUnique({ where: { id } })for the same id. - A custom hook with module-level
const cache = new Map()doing per-request dedup by hand — reinventingreact.cache, plus you have to clear the map manually. - A "data context" pattern that pre-fetches in a top-level layout and passes data down through props to avoid the duplicate fetch — works but pollutes the prop tree.
The canonical resolution: export const getX = cache(async (id) => { ... }) at module scope. Callers don't need to coordinate. React dedupes by argument identity within the request boundary.
Note on layering: react.cache dedupes within a request. For across requests, layer it with unstable_cache or the 'use cache' directive.
Incorrect (duplicate fetches):
// lib/data.ts
export async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
// components/Header.tsx
export async function Header({ userId }: { userId: string }) {
const user = await getUser(userId) // Fetch #1
return <h1>Welcome, {user.name}</h1>
}
// components/Sidebar.tsx
export async function Sidebar({ userId }: { userId: string }) {
const user = await getUser(userId) // Fetch #2 - duplicate!
return <nav>{user.role === 'admin' && <AdminLinks />}</nav>
}Correct (deduplicated with cache):
// lib/data.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
const res = await fetch(`/api/users/${id}`)
return res.json()
})
// components/Header.tsx
export async function Header({ userId }: { userId: string }) {
const user = await getUser(userId) // Fetch
return <h1>Welcome, {user.name}</h1>
}
// components/Sidebar.tsx
export async function Sidebar({ userId }: { userId: string }) {
const user = await getUser(userId) // Cached result reused
return <nav>{user.role === 'admin' && <AdminLinks />}</nav>
}Note: React cache() deduplicates within a single request. For cross-request caching, use unstable_cache or the 'use cache' directive.
Every Server Action that mutates data must invalidate the routes/tags that surface it — the failure mode is silent staleness
Pattern intent: mutations and their cache invalidations form a transaction in the user's mental model. A Server Action that writes but doesn't invalidate the relevant cache leaves the user staring at their pre-write state with no error to debug. revalidatePath for whole routes; revalidateTag for granular slices.
Shapes to recognize
- A
'use server'action withawait db.x.create(...)and norevalidatePath/revalidateTagcall — the canonical anti-pattern. - A bug report "data doesn't appear until I refresh twice" — almost always a missing invalidation call after the action.
- An action that calls
revalidatePath('/x')but the data also appears on/y(e.g., a global sidebar count) — under-invalidation. - An action that calls
revalidatePath('/', 'layout')for any mutation — over-invalidation; nukes everyone's cache for a small change. - A
redirect(...)beforerevalidatePath(...)—redirectthrows internally, so the invalidation never runs. Order matters. - A custom "cache buster" approach (router.refresh() in the client after the action returns) — works for client-rendered subtrees but loses the server-driven invalidation guarantee.
The canonical resolution: call revalidatePath(specificRoute) or revalidateTag(...) after the mutation succeeds and before the redirect(). Prefer revalidateTag when multiple routes show the same data; revalidatePath is the coarser hammer.
Incorrect (forgetting to revalidate after mutation):
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.posts.create({ data: { title, content } })
// User doesn't see new post until cache expires!
}Correct (revalidating after mutation):
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const post = await db.posts.create({ data: { title, content } })
revalidatePath('/posts') // Invalidate posts list
redirect(`/posts/${post.id}`) // Navigate to new post
}Path patterns:
// Specific route
revalidatePath('/posts')
// Dynamic route
revalidatePath('/posts/[slug]', 'page')
// Layout and all child routes
revalidatePath('/dashboard', 'layout')
// Entire app (use sparingly)
revalidatePath('/', 'layout')Note: redirect must be called after revalidatePath as it throws internally.
Call revalidateTag(tag, cacheLife) with a profile — never invoke the old one-arg API
Pattern intent: Next.js 16's revalidateTag requires a cacheLife profile ('max' | 'hours' | 'days' | 'weeks') as its second argument. The profile controls how stale served content may be while the revalidation runs in background. Calls with one argument either throw at runtime or no-op silently depending on the codepath.
Shapes to recognize
revalidateTag('products')with no second arg — the Next.js 15 API still in the codebase post-upgrade.- A migrated codebase where some calls have profiles and others don't — inconsistent stale-while-revalidate behavior across the app.
- Workaround: a
revalidatePathcall where the author meant tag-based invalidation — coarser than needed, more cache miss. - A Server Action that mutates and calls
revalidateTagwith no'max'profile when the user must see fresh data immediately — leaks stale content into the post-mutation render. - A code review comment ("we should pick a cacheLife here") followed by the author hardcoding
revalidate: 0instead — sidesteps the API.
The canonical resolution: revalidateTag(tag, cacheLife) where cacheLife is 'max' (revalidate now), 'hours' (stale up to 1h while revalidating), 'days', or 'weeks'. Pick the profile based on how tolerable staleness is for the consumers of that tag.
Reference: Next.js 16 Caching
Incorrect (old revalidateTag API):
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, data: FormData) {
await db.products.update({ where: { id }, data })
// Old API - no longer works in Next.js 16
revalidateTag('products')
}Correct (revalidateTag with cacheLife):
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, data: FormData) {
await db.products.update({ where: { id }, data })
// New API with cacheLife profile
revalidateTag('products', 'hours')
}
// Cache profiles: 'max', 'hours', 'days', 'weeks'
// 'max' = immediate revalidation
// 'hours' = stale for up to 1 hour during revalidationTagging cached data:
// lib/data.ts
'use cache'
import { cacheTag } from 'next/cache'
export async function getProducts() {
cacheTag('products')
const res = await fetch('https://api.store.com/products')
return res.json()
}Reference: Next.js 16 Caching
Declare route-level caching intent via segment-config exports — dynamic, revalidate, generateStaticParams
Pattern intent: each route segment has a default rendering mode (dynamic if it reads cookies/headers/searchParams; static otherwise). For pages where the default is wrong — e.g., a marketing page that would be perfectly cacheable — declare intent explicitly via export const dynamic = 'force-static' / 'force-dynamic' and export const revalidate = N.
Shapes to recognize
- An "about us" / "pricing" / "marketing" page rendered dynamically because the author didn't realize it would be served per-request — should be
force-static. - A blog post route with
dynamic = 'force-dynamic'despite content being static for hours — should beforce-static+revalidate. - A route with
generateStaticParamsreturning a small list but nodynamicParams = false— dynamically renders unlisted slugs, which may be a feature or a perf bug. - A user dashboard with
force-staticexported — fails at runtime because reading cookies/auth requires dynamic rendering. - A page exporting both
dynamic = 'force-static'and usingawait cookies()/headers()— Next.js will warn or error; the two can't coexist. - Multiple sibling routes with inconsistent
revalidatevalues for the same data shape — pick a number and apply uniformly.
The canonical resolution: pick dynamic and revalidate based on what the route actually reads. Static content gets force-static. Auth-dependent gets force-dynamic (or just stays default-dynamic). Mostly-static content with a refresh budget gets revalidate.
Incorrect (dynamic when static would work):
// app/about/page.tsx
export default async function AboutPage() {
const team = await fetch('https://api.example.com/team')
return <TeamSection team={team} />
}
// Defaults to dynamic rendering on every requestCorrect (explicit static generation):
// app/about/page.tsx
export const dynamic = 'force-static'
export const revalidate = 86400 // Revalidate daily
export default async function AboutPage() {
const team = await fetch('https://api.example.com/team')
return <TeamSection team={team} />
}
// Generated at build time, revalidated dailySegment config options:
// Force dynamic rendering (never cache)
export const dynamic = 'force-dynamic'
// Force static generation (build-time only)
export const dynamic = 'force-static'
// Revalidate time in seconds
export const revalidate = 3600 // 1 hour
// Generate static params for dynamic routes
export async function generateStaticParams() {
const products = await getProducts()
return products.map((p) => ({ slug: p.slug }))
}Decision matrix:
- Static content →
force-static - User-specific/auth →
force-dynamic - Semi-static →
revalidate: N
Mark cacheable Server Components/functions explicitly with 'use cache' — never rely on implicit caching
Pattern intent: Next.js 16 removed default fetch caching. Data that should be cached must opt in, either via the 'use cache' directive on a Server Component / async function or via unstable_cache around a fetcher. Pages relying on Next.js 15's implicit caching silently re-fetch on every request and hammer upstream APIs.
Shapes to recognize
- A Server Component making
fetch(url)calls that worked fine in Next.js 15 — and silently became per-request in Next.js 16. - Migration from 15→16 where p95 latency suddenly doubled — implicit cache loss, not "the server got slow."
- A
fetch(...)without any cache option, where the data clearly doesn't need to be per-request —'use cache'(orunstable_cache) was forgotten. - A custom hand-rolled cache (module-level
Map, in-memory dictionary) used to "fix" the per-request fetching — reinvents the wheel, doesn't integrate withrevalidateTag. - A Server Component with manual
revalidate: 3600on every fetch but no top-level'use cache'— works but is finer-grained than needed;'use cache'on the whole component is often cleaner. - A whole route marked
force-staticto "cache it all" — works but loses granular invalidation;'use cache'+cacheTagis more flexible.
The canonical resolution: add 'use cache' to the top of the Server Component or async function whose results should be cached. Pair with cacheTag(...) for invalidation. Use unstable_cache(fn, key, options) for finer-grained control.
Reference: Next.js 16 Cache Components
Incorrect (relying on implicit caching):
// app/products/page.tsx
export default async function ProductsPage() {
// In Next.js 15, this was cached by default
// In Next.js 16, this fetches fresh data every request
const products = await fetch('https://api.store.com/products')
return <ProductList products={products} />
}Correct (explicit caching with 'use cache'):
// app/products/page.tsx
'use cache'
export default async function ProductsPage() {
const products = await fetch('https://api.store.com/products')
return <ProductList products={products} />
}
// Entire page is cached until manually invalidatedAlternative (cache specific functions):
// lib/data.ts
import { unstable_cache } from 'next/cache'
export const getProducts = unstable_cache(
async () => {
const res = await fetch('https://api.store.com/products')
return res.json()
},
['products'],
{ revalidate: 3600 } // Cache for 1 hour
)---
In disguise — a hand-rolled module-level cache mimicking 'use cache'
The grep-friendly anti-pattern is a fetch(...) with no cache annotation in a Server Component. The disguise is a custom caching layer (module-level Map, in-memory dictionary, ad-hoc TTL) introduced "to fix" the per-request fetching. It works for one request lifecycle but doesn't integrate with revalidateTag, can't survive a server restart cleanly, and competes with the platform's caching primitive.
Incorrect — in disguise (hand-rolled cache layer):
// lib/cache.ts — homemade caching
const productsCache = new Map<string, { data: Product[]; expires: number }>()
export async function getProducts(category: string): Promise<Product[]> {
const cached = productsCache.get(category)
if (cached && cached.expires > Date.now()) return cached.data
const res = await fetch(`https://api.store.com/products?category=${category}`)
const data = await res.json()
productsCache.set(category, { data, expires: Date.now() + 1000 * 60 * 5 })
return data
}Works locally, breaks in production: no shared state across server instances, no integration with revalidateTag('products'), no SWR semantics. On every server restart, the cache is cold.
Correct — `unstable_cache` with tagging:
// lib/products.ts
import { unstable_cache } from 'next/cache'
export const getProducts = unstable_cache(
async (category: string) => {
const res = await fetch(`https://api.store.com/products?category=${category}`)
return res.json() as Promise<Product[]>
},
['products-by-category'],
{ tags: ['products'], revalidate: 300 }
)Now revalidateTag('products', 'max') invalidates across all server instances. The audit can find this and the framework knows about it.
Final reference: Next.js 16 Cache Components
Server content reaches inside a Client Component via children or named slots — not by being imported
Pattern intent: a Client Component (modal, accordion, sidebar, tab strip) that wraps static content should accept that content as children/slot props rendered from a Server Component parent — not import the static content directly. Direct imports across the boundary force the imported tree onto the client.
Shapes to recognize
- A
'use client'modal imports<ProductDescription>directly —ProductDescriptionis now bundled to the client even though it was meant to stay on the server. - An accordion / tab strip / drawer that conditionally renders an imported Server-Component-shaped child — the child renders client-side instead.
- A layout shell that accepts no
childrenand instead imports every section by name — every section becomes client-rendered. - Workaround: the author duplicates the static content into two components (one for SSR, one for client) — maintenance burden, drift risk.
- Workaround: dynamic
import()inside the Client Component to "defer" the import — works for code-splitting, doesn't help with the SSR/RSC distinction.
The canonical resolution: the Client Component accepts children: ReactNode (or named slots like header/sidebar/main); a Server Component parent provides the slot content. Static content stays server-rendered; interactivity stays in the wrapper.
Incorrect (converting children to Client Components):
// components/Modal.tsx
'use client'
import { ProductDetails } from './ProductDetails' // Forces this to be client
export function Modal({ productId }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>View Details</button>
{isOpen && (
<div className="modal">
<ProductDetails productId={productId} /> {/* Now client-rendered */}
</div>
)}
</>
)
}Correct (children pattern keeps server content):
// components/Modal.tsx
'use client'
import { ReactNode, useState } from 'react'
export function Modal({ children, trigger }: { children: ReactNode; trigger: string }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>{trigger}</button>
{isOpen && (
<div className="modal">
<button onClick={() => setIsOpen(false)}>Close</button>
{children} {/* Server Component passed as children */}
</div>
)}
</>
)
}
// app/product/[id]/page.tsx (Server Component)
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
<Modal trigger="View Details">
<ProductDetails product={product} /> {/* Stays server-rendered */}
</Modal>
)
}Benefits:
ProductDetailsremains a Server Component- Data fetching happens on server
- Only Modal interactivity ships to client
SSR and client initial render must produce identical HTML — defer browser-only or time-varying values to a post-mount effect
Pattern intent: during hydration, React asserts that the client's first render matches the server's HTML. Time-of-day, Math.random, window.innerWidth, and navigator.userAgent produce different values per environment and trip the assertion.
Shapes to recognize
- A component rendering
new Date().toLocaleTimeString()directly in JSX — server renders one time, client hydrates with another a moment later. - A
Math.random()driving a JSX value (random tip, rotating banner) — different per render. - A
window.innerWidth-driven conditional in render — undefined on server, defined on client. - A
localStorage.getItem(...)read in render — undefined on server, populated on client. - A locale-dependent value (
new Date().toLocaleDateString(locale)) where server locale differs from client. - A workaround
suppressHydrationWarningslapped on every component — masks the symptom, hides real bugs. - A "loading" state initialized to
falseon server, immediately set totruein auseEffect— causes a flash; should render placeholder until effect runs.
The canonical resolution: render a placeholder (or nothing) on first render; populate the time/random/storage-dependent value in a useEffect after mount. Use suppressHydrationWarning only on the specific element (a <time> tag, not the whole subtree) when the mismatch is intentional.
Incorrect (hydration mismatch):
'use client'
export function Greeting() {
// Different on server vs client
const time = new Date().toLocaleTimeString()
return <p>Current time: {time}</p>
}
// Server renders "10:30:00", client hydrates with "10:30:01" → mismatch!Correct (defer client-only values):
'use client'
import { useState, useEffect } from 'react'
export function Greeting() {
const [time, setTime] = useState<string | null>(null)
useEffect(() => {
setTime(new Date().toLocaleTimeString())
const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString())
}, 1000)
return () => clearInterval(interval)
}, [])
// Render nothing or placeholder on server
if (!time) return <p>Loading time...</p>
return <p>Current time: {time}</p>
}Alternative (suppressHydrationWarning for known differences):
'use client'
export function Timestamp() {
return (
<time suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</time>
)
}
// Use sparingly - only when mismatch is intentionalCommon causes:
Date.now(),Math.random()window.innerWidth,navigator.userAgent- Browser extensions modifying HTML
- Different locales on server/client
Wrap third-party scripts in next/script with the right strategy — never <script src=...> in the layout <head>
Pattern intent: third-party scripts (analytics, A/B testing, chat widgets, social embeds) should run at a moment that matches their purpose — not block the critical render path. next/script exposes strategy so each script declares its loading priority.
Shapes to recognize
<script src="https://analytics.example.com/script.js" />rendered in<head>insidelayout.tsx— blocks render until script loads.- A
useEffect(() => { const s = document.createElement('script'); ... }, [])to load a script — works but loses Next.js's lifecycle integration and SSR-safe insertion. - Every third-party script using the same
strategy("afterInteractive" everywhere) — chat widget loads as eagerly as analytics, but doesn't need to. - A workaround using
dynamic(() => import(...))on a component that wraps a third-party script — overengineered;<Script>handles it. - A
<Script>with nostrategy(defaults toafterInteractive, fine) but missingidfor inline scripts — inline scripts needidto dedupe. - Tag Manager
(function(w,d,s,l,i)...)snippet pasted inline in<head>— should be<Script id="gtm" strategy="beforeInteractive">with the snippet asdangerouslySetInnerHTML.
The canonical resolution: <Script src=...> for external scripts, <Script id=...>{inline}</Script> for inline; pick strategy: beforeInteractive for critical (rare), afterInteractive for analytics/tracking (default), lazyOnload for chat/social/widgets, worker (experimental) to offload to a web worker.
Incorrect (blocking script in head):
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<head>
<script src="https://analytics.example.com/script.js" />
{/* Blocks rendering until script loads */}
</head>
<body>{children}</body>
</html>
)
}Correct (next/script with strategy):
// app/layout.tsx
import Script from 'next/script'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* Analytics - load after page is interactive */}
<Script
src="https://analytics.example.com/script.js"
strategy="afterInteractive"
/>
{/* Chat widget - load when idle */}
<Script
src="https://chat.example.com/widget.js"
strategy="lazyOnload"
/>
{/* Critical script - load before interactive */}
<Script
id="gtm"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXX');`
}}
/>
</body>
</html>
)
}Strategy guide:
beforeInteractive- Critical scripts (rare)afterInteractive- Analytics, tracking (default)lazyOnload- Chat widgets, social buttonsworker- Offload to web worker (experimental)
Push the 'use client' directive down to the interactive leaf — not up at the route/layout
Pattern intent: 'use client' marks the boundary below which everything is shipped to the client. Placing it at the route or layout level drags the entire subtree onto the client, even pieces that don't need any interactivity.
Shapes to recognize
'use client'at the top ofpage.tsx/layout.tsx, with most of the body being static markup and only one or two interactive leaves.- A
<ProductPage>Client Component receiving{ product, reviews, related, recommendations }— most of those exist only to render static children that don't need the client. - A wrapper component is
'use client'only because one descendant usesuseState— the wrapper itself never needs the client. - A
'use client'layout toggling a sidebar — could be a static layout with a small client island for the sidebar toggle button. - Heavy server-only data (large arrays, formatted HTML, image URLs) crossing the boundary because the boundary is too high — every byte gets serialized into the RSC payload.
The canonical resolution: keep page.tsx / layout.tsx as a Server Component; extract just the interactive part into a small Client Component; pass only the IDs/strings/handlers it needs. See also `cross-boundary-coherence.md` for cross-cutting analysis across the route tree.
---
In disguise — 'use client' on a layout.tsx because of one interactive element three levels deep
The grep-friendly anti-pattern is 'use client' at the top of page.tsx. The disguise is the directive on a layout — usually because the team added a theme toggle, a notification bell, or an auth-conditional element to the layout's <header> and didn't realize the whole subtree under the layout is now a Client Component.
Incorrect — in disguise (layout marked client for a single interactive header element):
// app/dashboard/layout.tsx
'use client' // ❌ entire dashboard route group is now client-rendered
import { useState } from 'react'
import { Sidebar } from '@/components/Sidebar' // static, doesn't need client
import { DashboardHeader } from '@/components/DashboardHeader' // mostly static
import { Footer } from '@/components/Footer' // static
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const [notifOpen, setNotifOpen] = useState(false) // the only reason 'use client' is here
return (
<div>
<DashboardHeader>
<button onClick={() => setNotifOpen(!notifOpen)}>🔔</button>
{notifOpen && <NotificationsDropdown />}
</DashboardHeader>
<Sidebar />
<main>{children}</main>
<Footer />
</div>
)
}Cost: every page rendered inside /dashboard/* ships under a client-rendered layout. The header, sidebar, and footer are all bundled to the client. Hydration cost compounds across the route group.
Correct — layout stays server, notification island is the only client part:
// app/dashboard/layout.tsx (Server Component — no directive)
import { Sidebar } from '@/components/Sidebar'
import { DashboardHeader } from '@/components/DashboardHeader'
import { Footer } from '@/components/Footer'
import { NotificationsButton } from './NotificationsButton'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<DashboardHeader>
<NotificationsButton />
</DashboardHeader>
<Sidebar />
<main>{children}</main>
<Footer />
</div>
)
}
// app/dashboard/NotificationsButton.tsx — only the leaf is client
'use client'
import { useState } from 'react'
export function NotificationsButton() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(!open)}>🔔</button>
{open && <NotificationsDropdown />}
</>
)
}The layout, sidebar, header shell, and footer stay on the server. Only the bell button hydrates. For complex apps this can drop dozens of KB from the route group's First Load JS.
This is also a common Category 9 finding — see `cross-boundary-coherence.md` for sweeping the whole route tree.
Incorrect (entire page as Client Component):
'use client'
export default function ProductPage({ product }) {
const [quantity, setQuantity] = useState(1)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p> {/* Static, doesn't need client */}
<img src={product.image} /> {/* Static, doesn't need client */}
<Reviews reviews={product.reviews} /> {/* Static, doesn't need client */}
{/* Only this needs interactivity */}
<input value={quantity} onChange={e => setQuantity(+e.target.value)} />
<button onClick={() => addToCart(product.id, quantity)}>Add to Cart</button>
</div>
)
}
// Entire page hydrates on clientCorrect (minimal Client Component):
// app/product/[id]/page.tsx (Server Component)
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<img src={product.image} />
<Reviews reviews={product.reviews} />
{/* Only interactive part is client */}
<AddToCartButton productId={product.id} />
</div>
)
}
// components/AddToCartButton.tsx
'use client'
import { useState } from 'react'
export function AddToCartButton({ productId }: { productId: string }) {
const [quantity, setQuantity] = useState(1)
return (
<div>
<input value={quantity} onChange={e => setQuantity(+e.target.value)} />
<button onClick={() => addToCart(productId, quantity)}>Add to Cart</button>
</div>
)
}
// Only button hydrates, rest is static HTMLAudit 'use client' placement across the route tree — demote files (or whole subtrees) that don't need the client
This is a cross-cutting rule. It surfaces when you map every 'use client' directive against the route tree and look for placements that drag in more JS than they need to.
Shapes to recognize
- A
layout.tsxmarked'use client'because one descendant button needs interactivity — the entire route group is now client-rendered. Lift the directive down to the interactive leaf. - A
'use client'file whose only "hook" isuseIdfor an ARIA attribute on otherwise-static markup —useIdis SSR-safe; the directive is unnecessary. - A
'use client'page that imports<Header>,<Footer>,<Sidebar>, all of which are static — those components are now bundled to the client; should be passed aschildrenfrom a Server Component parent. - A
'use client'file usinguseStateto hold a value initialized once and never updated by an event — you wanted a constant; the directive is vestigial. - Two sibling routes, one Server Component and one Client Component, sharing a heavy component — the heavy component is bundled into the client side anyway because of the Client sibling. Either split the shared component or refactor both routes.
- A custom hook (
use-*.ts) that's imported by both a Server Component (via re-export) and a Client Component — likely a server/client confusion; the hook should be split into a server function and a client hook.
Detection procedure
1. List every file with 'use client' in the inventory. 2. Map the route tree visually: app/ → which layout.tsxs have 'use client'? Which page.tsxs do? 3. For each 'use client' placement, classify by why it's there:
- Real interactivity: event handlers tied to state changes, refs to DOM measurement, browser-only APIs.
- Hooks that require client:
useEffect,useLayoutEffect,useReducerwith side-effects,useSyncExternalStore, anything fromreact-dom. - Nothing requires client — directive is vestigial.
- Propagation up: the directive is on a layout/page but the interactivity is in a leaf; lift the directive down.
4. For each non-real placement, propose: drop the directive, or split into a static parent + a small client island.
Multi-file example
Incorrect (a layout marked client because of one button, dragging four imported components onto the client):
// app/dashboard/layout.tsx
'use client'
import { Sidebar } from '@/components/Sidebar' // static, no interactivity
import { Header } from '@/components/Header' // static
import { Footer } from '@/components/Footer' // static
import { ThemeToggle } from '@/components/ThemeToggle' // the only interactive bit
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<Header />
<Sidebar />
<main>{children}</main>
<ThemeToggle /> // the reason 'use client' is here
<Footer />
</div>
)
}
// Result: Header, Sidebar, Footer, and every descendant of children are now in
// the client bundle and pay hydration cost — for nothing.Correct (Server Component layout, client island only where it's needed):
// app/dashboard/layout.tsx (Server Component — directive removed)
import { Sidebar } from '@/components/Sidebar'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { ThemeToggle } from '@/components/ThemeToggle' // imports a thin client island
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<Header />
<Sidebar />
<main>{children}</main>
<ThemeToggle />
<Footer />
</div>
)
}
// components/ThemeToggle.tsx (Client Component — the small interactive leaf)
'use client'
import { useState } from 'react'
export function ThemeToggle() {
const [dark, setDark] = useState(false)
return <button onClick={() => setDark(!dark)}>{dark ? '☀️' : '🌙'}</button>
}Cross-file observation (what the audit reports):
6 of the 14'use client'files have no client-only requirement at their current placement. 3 are layouts that should be Server Components with client islands inside; 2 use onlyuseId; 1 holds auseStatethat's never updated.
Demoting drops ~62 KB from the dashboard route's First Load JS and removes hydration cost for ~22% of the route tree.
When NOT to demote
- The file uses
useEffect,useLayoutEffect,useSyncExternalStore, or a DOM ref — those are real client requirements. - The file uses
useStateand does update it from an event handler or callback — even if the JSX looks static at a glance. - The file is imported by a Client Component context provider that needs to wrap the children — splitting will break the context.
- Removing
'use client'from a layout removes a context provider that descendants depend on.
Risk before demoting
- Demotion changes import semantics: a Server Component cannot import a Client Component freely (and vice-versa). Verify the import graph still types after demoting.
- The split case (Server parent + Client island) is the safer refactor when in doubt — it preserves interactivity exactly while moving the static mass off the client.
- Always re-test the affected route(s) with Network → "Disable JS" to confirm the server-rendered version still works.
Reference: Server and Client Components, Use Client directive
Consolidate near-duplicate routes/layouts/components into one with variants or composition
This is a cross-cutting rule. It surfaces only when 2+ routes/components are read side-by-side and recognized as the same shape with different labels.
Shapes to recognize
- Two
app/<entity-a>/page.tsxandapp/<entity-b>/page.tsxwith the same JSX skeleton, same data-fetcher shape, differing only by entity name and a couple of strings. - Two layouts with the same
<header><main><footer>shell and identical children handling, differing only by a className or a static title. - Two parallel routes (
@modalA,@modalB) whose content components are 90% the same — should be one slot with a discriminated prop. - Two route handlers (
/api/v1/usersand/api/v1/members) doing the same CRUD against different tables — usually a sign that the data model should be one with akindcolumn. - Two
(group-a)/page.tsxand(group-b)/page.tsxfiles that exist only to apply different route groups to functionally identical content.
Detection procedure
1. After Categories 1–8, list every page.tsx, layout.tsx, and major Client Component by JSX shape signature (top-level element + child element types, ignoring children content). 2. For each signature with 2+ members, ask: would a single component with a discriminated prop, a dynamic route segment ([kind]), or a children-based composition cover both? 3. Two criteria to consolidate (both must hold): structural similarity ≥ 90%, AND a clean variant axis (one slug, one prop, one branch).
Multi-file example
Incorrect (two routes, two files, parallel edits required):
// app/users/[id]/page.tsx
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await getUser(params.id)
return (
<article className="profile">
<Avatar src={user.avatarUrl} />
<h1>{user.name}</h1>
<p>{user.bio}</p>
<Link href={`/users/${user.id}/edit`}>Edit user</Link>
</article>
)
}
// app/members/[id]/page.tsx — identical shape, different entity
export default async function MemberPage({ params }: { params: { id: string } }) {
const member = await getMember(params.id)
return (
<article className="profile">
<Avatar src={member.avatarUrl} />
<h1>{member.name}</h1>
<p>{member.bio}</p> // drift: was "tagline" in UserPage at some point
<Link href={`/members/${member.id}/edit`}>Edit member</Link>
</article>
)
}Two routes that diverge every time anyone touches profile UI.
Correct (Option 1: one route with a dynamic segment):
// app/[kind]/[id]/page.tsx (where kind is 'users' | 'members')
type Kind = 'users' | 'members'
export default async function ProfilePage({
params,
}: {
params: { kind: Kind; id: string }
}) {
const profile = await getProfile(params.kind, params.id)
return (
<article className="profile">
<Avatar src={profile.avatarUrl} />
<h1>{profile.name}</h1>
<p>{profile.tagline}</p>
<Link href={`/${params.kind}/${profile.id}/edit`}>Edit {profile.singular}</Link>
</article>
)
}Correct (Option 2: shared component, two thin routes if route groups matter):
// components/profile/ProfilePage.tsx
export async function ProfilePage({ profile, basePath }: { profile: Profile; basePath: string }) {
return (
<article className="profile">
<Avatar src={profile.avatarUrl} />
<h1>{profile.name}</h1>
<p>{profile.tagline}</p>
<Link href={`${basePath}/${profile.id}/edit`}>Edit</Link>
</article>
)
}
// app/users/[id]/page.tsx — thin
export default async function UserRoute({ params }) {
return <ProfilePage profile={await getUser(params.id)} basePath="/users" />
}
// app/members/[id]/page.tsx — thin
export default async function MemberRoute({ params }) {
return <ProfilePage profile={await getMember(params.id)} basePath="/members" />
}Option 1 collapses to one route segment. Option 2 keeps two routes (for routing/metadata divergence) but shares the body.
When NOT to consolidate
- The two routes are in different access tiers (admin vs public) and the divergence is intentional (different layouts, different middleware checks).
- One route is server-rendered statically and the other is dynamic — Next.js may force them apart even if they look alike.
- The data shapes are nominally the same but semantically different (e.g., users have permissions; members don't).
Risk before consolidating
- If the two routes have different metadata (
generateMetadata), the merge needs to handle both. - Parallel/intercepting route conventions can break silently — verify after merging.
- Sitemaps and
robots.tsmay reference both paths separately.
Reference: Dynamic Routes, Route Groups
Dynamic routes export generateMetadata so each variant gets per-resource title/description/OG image
Pattern intent: a /product/[id] page must surface unique title, description, and OG image per product. The static export const metadata cannot read route params; only generateMetadata({ params }) can.
Shapes to recognize
export const metadata = { title: 'Product' }in a dynamic route — every product gets the same<title>in the HTML head.- A
generateMetadatathat ignoresparamsand returns a static value — same problem with extra ceremony. - A
generateMetadatathat calls a different fetcher than the page itself — duplicate fetches; should share acache()-wrapped fetcher. - A
<title>{post.title}</title>rendered inline in the page body — works for React 19 head-hoisting, but Next.js framework convention usesgenerateMetadatafor crawler-safe metadata. - A workaround setting
document.titlein auseEffect— client-side; SEO crawlers never see it.
The canonical resolution: export async function generateMetadata({ params }): Promise<Metadata> that fetches the same data the page does (via a cache()-wrapped getter — Next.js dedupes) and returns the per-resource fields.
Incorrect (static metadata for dynamic pages):
// app/product/[id]/page.tsx
export const metadata = {
title: 'Product', // Same for all products!
description: 'View product details'
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return <ProductDetails product={product} />
}Correct (dynamic metadata per product):
// app/product/[id]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({
params
}: {
params: { id: string }
}): Promise<Metadata> {
const product = await getProduct(params.id)
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [
{
url: product.image,
width: 1200,
height: 630,
alt: product.name
}
]
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.image]
}
}
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.id) // Deduplicated with cache()
return <ProductDetails product={product} />
}Note: Next.js automatically deduplicates fetch calls, so generateMetadata and the page can call getProduct without duplicate requests.
Related skills
FAQ
Where is the nextjs skill published?
The nextjs skill lives in the pproenca/dot-skills repository as a Claude Code skill entry scoped to Next.js development tasks within that skill pack.
What stack does nextjs target?
The nextjs skill name and repository context indicate Next.js and React frontend work, though the catalog readme excerpt is empty and live SKILL.md should be checked for exact rules.