Playbook library
Next.js App Router conventions
The App Router's failure modes are boundary failures: a secret inlined into the client bundle, a server action treated as a private function, a page marked `"use client"` that opts the whole tree out of server rendering. Models trained largely on Pages Router code get this wrong in ways that build cleanly and ship.
- Written for
- Product teams on Next.js 14+ with the App Router.
- Not covered
- This is about the framework boundary, not your design system or your accessibility standard. Pair it with a component-level playbook if that is what your reviews actually argue about.
11 rules4 blocking6 warning1 info
01 Server-only modules stay server-only
errorA file carrying `"use client"` must not read a non-`NEXT_PUBLIC_` environment variable, import the database client, or import a server SDK — and neither may a module it pulls in. Mark server-only modules with `import "server-only"` so the mistake fails the build instead of shipping. The bundler inlines whatever the client graph can reach, without a warning.
Blocks: the failure mode is a published secret, and it is invisible in review without this check.
Scopeapp/**src/app/**components/**
Tagssecuritynextjsboundary
02 Server actions validate and authorize themselves
errorA function marked `"use server"` is a public HTTP endpoint. Parse its arguments with a schema and check the session and permissions inside the action — not in the component that renders the form. The action can be invoked directly with any payload, by anyone who has the action id, without your UI ever rendering.
Blocks: an unguarded server action is an open write endpoint on your own domain.
Scopeapp/**src/app/****/actions/**
Tagssecuritynextjsvalidation
03 Sanitize before `dangerouslySetInnerHTML`
errorEvery use passes through the shared sanitizer helper on the same expression. Interpolating user content, CMS content or model output raw is rejected. "The CMS is trusted" stops being true the first time an editor pastes an embed.
Blocks: stored XSS in a shared surface affects every viewer, not just the author.
Scopeapp/**src/**components/**
Tagssecurityreactxss
04 Auth gates run on the server
errorProtected routes are gated in middleware, a layout, or a server component. Redirecting from a `useEffect` after render is rejected: the protected payload has already been serialized and sent to the browser before the redirect runs, so the data is in the page source regardless of what the user sees.
Blocks: the data has already left the server — it is a disclosure, not a UX flaw.
Scopeapp/**src/app/**middleware.*
Tagssecuritynextjsauth
05 `"use client"` sits on the leaf
warningPut the directive on the smallest component that genuinely needs state, effects or event handlers. A `page.tsx` or `layout.tsx` carrying it is rejected. The directive is inherited by everything below it, so one high placement moves an entire subtree — and its dependencies — into the client bundle.
Warns: it is a performance and payload regression, correct but expensive.
Scopeapp/**src/app/**
Tagsnextjsperformance
06 Every server `fetch` declares its caching
warning`fetch` in a server component passes an explicit `cache` option or `next: { revalidate }`. Relying on the framework default is rejected. The default has changed between Next majors in both directions, so an implicit choice makes your correctness depend on your lockfile.
Warns: the consequence is stale or uncached data, painful but recoverable.
Scopeapp/**src/app/**
Tagsnextjscaching
07 Mutations revalidate what they changed
warningAny server action or route handler that writes must call `revalidatePath` or `revalidateTag` for the affected data, or the client must `router.refresh()`. A mutation that leaves a stale cache reads to the user as a lost write, and they will do it twice.
Warns: it produces user-visible wrong data, not a security failure.
Scopeapp/**src/app/****/actions/**
Tagsnextjscachingcorrectness
08 Don't fetch in an effect what the server can render
warningFetching data in `useEffect` when it is available at render time is rejected — fetch it in a server component and pass it down. The effect version costs a request waterfall, a loading state, a hydration flash and a second code path for errors, in exchange for nothing.
Warns: it is an architecture smell with a real but non-urgent cost.
Scopeapp/**src/**components/**
Tagsnextjsreactperformance
09 Effects clean up what they start
warningAny `useEffect` that adds an event listener, opens a socket, starts an interval or creates an `AbortController` returns the matching teardown. Strict Mode invokes effects twice in development and route changes unmount them constantly — without teardown, handlers accumulate for the life of the tab.
Warns: leaks degrade a long session rather than breaking the first one.
Scopeapp/**src/**components/**
Tagsreactmemory
10 List keys are stable identities
warning`key` comes from the item's own id. `key={index}` is rejected for any list that can be reordered, filtered, paginated or have items removed. React will reuse the wrong DOM node, so uncontrolled input state and focus move to a different row than the one the user was editing.
Warns: it produces a confusing, hard-to-reproduce bug rather than an outage.
Scopeapp/**src/**components/**
Tagsreactcorrectness
11 Images go through `next/image`
infoUse `next/image` with explicit `width`/`height` or `fill`. A bare `<img>` for an asset you control is rejected. Unsized images are the dominant source of layout shift, and the raw tag skips format negotiation and resizing entirely.
Informational: it is a quality nudge with legitimate exceptions (inline SVG, third-party embeds).
Scopeapp/**src/**components/**
Tagsnextjsperformanceux
Before you import all of it
Severity is the lever that decides whether people keep the gate on. Only error blocks a merge — bring these in as warnings first, look at what actually fires on a few weeks of real pull requests, and promote the rules that earned it. And rewrite the scope globs: the paths above are examples, and a rule only runs against files its globs match.
Rules go into Normik one at a time — through the playbook form, or by letting a coding agent propose them over MCP from an existing repository. There is no bulk markdown import: the copy button above is for your AGENTS.md, for a PR where the team argues about the list, and to keep beside you while you enter the rules you actually kept.
Every rule here is judged the way Normik judges it: on the lines the pull request adds. Nothing in this playbook depends on the PR description, on a file the diff does not touch, or on the history of a file — those are real review standards, but a gate that cannot see the evidence would either stay silent or guess.
The anatomy of a rule, how severity and globs behave, versioning and the AGENTS.md export are all in the playbook documentation.