nextjs-ops
Next.js App Router operations - the server/client boundary, the two caching models, Server Actions security, streaming, proxy.ts and deployment. Use for: next.js, nextjs, app router, use cache, cacheComponents, Cache Components, cacheLife, cacheTag, revalidateTag, updateTag, reva
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/nextjs-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Next.js Operations
The App Router as an operational surface: what crosses the server/client
boundary, which caching model the app is actually on, why a response is stale,
and what a Server Action really is on the wire. React itself belongs to
react-ops; styling to tailwind-ops; the CMS above to payloadcms-ops; the
Workers runtime below to cloudflare-ops / hono-ops. This skill is the
framework.
Verified against Next.js 16.x (2026-08-30) —
next@16.3.3, docs snapshot 2026-08-25, React 19. Semantics changed materially at 15.0 (fetchuncached by default, request APIs async) and again at 16.0 (Cache Components,proxy.ts,revalidateTagsignature). Establish the app's version before answering any caching question — the right answer for 14 is the wrong answer for 16.
Staleness check: python scripts/check-nextjs-facts.py --offline asserts
the version-bearing facts are still named in the prose and that the currency
note above matches the catalog; --live confirms each package's npm major.
Catalog: assets/nextjs-facts.json.
Orient first: which model is this app on?
Three greps, before any advice. Getting this wrong is the single largest source of confidently-wrong Next.js answers.
node -p "require('next/package.json').version" # the major decides everything
grep -rn "cacheComponents" next.config.* # Cache Components on/off
ls proxy.* middleware.* src/proxy.* src/middleware.* 2>/dev/null
| Signal | Model in force | Read |
|---|---|---|
cacheComponents: true |
Cache Components — nothing cached unless 'use cache' says so; PPR is the default rendering |
references/cache-components.md |
No cacheComponents (16.x default) |
Previous model — fetch uncached by default, route-segment config, unstable_cache |
references/caching-model.md |
| Next.js ≤ 14 | Legacy — fetch cached by default; most "why is this stale" bugs live here |
references/caching-model.md |
Decision Tree
What are you doing with Next.js?
│
├─ "It's serving stale data" / "my change doesn't appear"
│ └─ Orient (above) → references/caching-model.md (debug ladder)
│
├─ Deciding what to cache, for how long, and how to bust it
│ └─ references/cache-components.md (use cache, cacheLife, cacheTag)
│ or references/caching-model.md if cacheComponents is off
│
├─ "use client" errors, serialization failures, context, env leaks
│ └─ Below + references/server-client-boundary.md
│
├─ Mutations: Server Action vs Route Handler, auth, validation
│ └─ Below + references/server-actions.md
│
├─ Data fetching, waterfalls, Suspense/loading.tsx, what streams
│ └─ references/data-fetching-streaming.md
│
├─ Routes, async params, layouts, parallel/intercepting routes, metadata
│ └─ references/routing-and-rendering.md
│
├─ proxy.ts (formerly middleware.ts), matchers, edge vs Node runtime
│ └─ references/proxy-and-runtimes.md
│
├─ Shipping it: self-host, Docker, multi-instance, CDN, Cloudflare
│ └─ references/deployment.md
│
├─ Upgrading 14/15 -> 16, or Pages Router -> App Router
│ └─ references/upgrading.md
│
├─ Fonts, scripts, images, bundle size, build speed
│ └─ references/optimization.md
│
├─ Testing it (and what simply cannot be unit-tested)
│ └─ references/testing.md
│
└─ Auditing an existing app for the known footguns
└─ python scripts/audit-app-router.py <project-root>
The boundary is a module graph, not a folder
'use client' marks an entry point into the client module graph. Everything
that file imports — and everything those files import — is bundled for the
browser, whether or not it carries the directive. Components passed through as
children or props are not imported by it, so they stay on the server and
arrive as already-rendered output.
That single asymmetry explains most boundary design:
// ❌ marking the layout client pulls the whole tree into the bundle
'use client'
export default function Layout({ children }) { /* ... */ }
// ✅ keep the layout on the server; make only the interactive leaf a client entry
export default function Layout({ children }) {
return <nav><Logo /><Search /></nav> // Search is the 'use client' file
}
A serialization error at the boundary is the real error. Props crossing
server → client are serialized into the RSC payload; a class instance, a
function, a URL, a Symbol or a Date-bearing ORM row cannot make the trip.
The fix is almost never "wrap it in a Client Component" — it is to stop sending
the un-serializable thing and send the shape the UI renders. That is also the
security fix: returning a raw database row to the client publishes every column
on it.
Two more rules that fall out of the same graph:
- Providers wrap
{children}, not the tree. A'use client'provider that accepts children keeps the subtree on the server. Render it as deep as it can go so the static parts stay static. - Environment poisoning is silent. Only
NEXT_PUBLIC_*variables reach the browser; anything else becomes an empty string in a client module — no error, just a request that fails at runtime with an emptyAuthorizationheader.import 'server-only'turns that into a build error instead. Theclient-secret-envrule inaudit-app-router.pycatches it statically.
Depth, interleaving patterns, and the third-party-component wrapper:
references/server-client-boundary.md.
Caching: the reason this skill exists
Caching is where Next.js costs teams the most hours, because the defaults inverted between majors and the failure mode is a correct-looking stale page.
The mental model that survives version changes: work is either (a) known at build time, (b) cached with a stated lifetime, or (c) request-time. Every caching bug is one of those three misclassified.
Under Cache Components (cacheComponents: true)
import { cacheLife, cacheTag } from 'next/cache'
async function BlogPosts() {
'use cache' // opt IN — this is the only thing that caches
cacheLife('hours') // ALWAYS state it; omitting it means the implicit 'default'
cacheTag('posts') // the handle you invalidate by
return <List posts={await getPosts()} />
}
Non-negotiables:
- Arguments and captured closure variables form the cache key. Different inputs, different entries. That is also why a per-user value in scope silently multiplies your entries.
- Request APIs cannot be read inside a cached scope —
cookies(),headers(),searchParams, and dynamicparamsthrownext-request-in-use-cache, and the restriction follows the call stack into helpers. Read them outside and pass the value in as an argument. - Set
cacheLifeexplicitly in every scope. Without it, an inner short-lived cache can silently shorten the outer one (and, if the outer has no explicit profile, that combination is a prerender-time build error). - The default store is per-instance and in-memory — on serverless it often
does not survive between requests.
'use cache: remote'is the durable, shared variant, and it costs a network round trip.
| Profile | stale (client) |
revalidate (server) |
expire |
|---|---|---|---|
default |
5 min | 15 min | never |
seconds |
30 s | 1 s | 1 min |
minutes |
5 min | 1 min | 1 hr |
hours |
5 min | 1 hr | 1 day |
days |
5 min | 1 day | 1 week |
weeks |
5 min | 1 week | 30 days |
max |
5 min | 30 days | 1 year |
revalidate: 0 or expire under 5 minutes drops the content out of the
prerender entirely; stale under 30 seconds drops it out of prefetches. Of the
presets only seconds trips either. Full semantics, nesting rules,
use cache: private / remote: references/cache-components.md.
Under the previous model (the 16.x default)
fetch is not cached unless you ask (cache: 'force-cache' or
next: { revalidate: n }); non-fetch work caches via unstable_cache; route
segments are steered by dynamic, revalidate and fetchCache exports. The
layers — request memoization, data cache, full route cache, client router cache
— and the ladder for finding which one is holding the stale value are in
references/caching-model.md.
Invalidating after a mutation — pick by what must change
| API | Semantics | Use when |
|---|---|---|
updateTag(tag) |
expires and re-reads in the same response | read-your-own-writes; the user must see their change now. Actions only |
revalidateTag(tag, profile) |
stale-while-revalidate; no immediate re-render | shared content that tolerates eventual consistency |
revalidatePath(path) |
invalidate one URL | a single route is affected and tagging is overkill |
refresh() |
refetch uncached data only, cache untouched | the view depends on state outside the cache. Actions only |
The single-argument revalidateTag('x') form is deprecated in 16 — that is the
revalidate-tag-single-arg finding.
Server Actions are public endpoints
An action is not a function call. 'use server' compiles the implementation
away from the client bundle and leaves an action ID that POSTs back to the
route. Anyone who can send that POST can invoke it, with no form, no page
render, and no UI-level gate in the way.
'use server'
export async function completeItem(itemId: string) {
const session = await auth() // 1. authenticate
if (!session?.user) throw new Error('Unauthorized')
const item = await db.item.findFirst({ // 2. authorize by ownership,
where: { id: itemId, ownerId: session.user.id }, // re-read from a trusted source
})
if (!item) return
await db.item.update({ where: { id: item.id }, data: { completed: true } })
}
- Take a reference, not the record. A client legitimately says which item; it does not get to supply the row's contents or its ownership. Schema validation checks shape, never entitlement.
- Rendering is not a gate. "The form only renders for admins" is not
authorization, and neither is a
proxy.tsmatcher — actions are POSTs to the route they live on, so moving one to another route can silently drop it out of matcher coverage. - Framework protections you get for free:
Origin-vs-HostCSRF check, a 1MB body cap, encrypted action IDs, dead-code elimination of unused actions, and closure-variable encryption. They are a floor, not a substitute. - Prefer a Route Handler for GETs, webhooks, third-party callers, file
streaming, anything needing custom status/headers, and anything that must run
in parallel — the client dispatches actions one at a time, so
Promise.allover actions is sequential.
Deployment, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, and the "Failed to find Server
Action" skew failure: references/server-actions.md.
Landmines
The non-obvious ones, in rough order of hours lost.
next devnever caches pages. Development renders on demand, so every caching bug is invisible untilnext build && next start. Verify caching against a production build, or you are testing a different program.- A cached scope reading request data can pass
next buildand fail undernext start. On a dynamically-rendered route thenext-request-in-use-cacheerror only surfaces when the route actually runs. - The build hangs for 50 seconds, then dies. A Promise created outside a
'use cache'boundary (acookies()store passed as a prop, a sharedMapof in-flight fetches) is being awaited inside one. It cannot resolve during prerender. The error names the timeout, not the prop that caused it. await paramsat the top of a layout de-opts the whole subtree. Awaiting request data high in the tree shrinks the static shell to nothing. Pass the promise down and await it inside a<Suspense>boundary instead — the same move applies tocookies(),headers()andsearchParams.proxy.tswithout amatcherruns on every request — including_next/static,_next/imageandpublic/. Auth logic there blocks your own CSS. Conversely,_next/datastill runs proxy even when excluded, on purpose.- Parallel route slots now require
default.js. Since 16, a@slotwithout one fails the build outright. - Bots get a different render. Crawlers are detected by user agent and served a full dynamic render instead of the static shell — so a shell built from build-time-only data can 500 for Googlebot while working for every human.
- Streaming dies quietly behind a buffering proxy. nginx, and some cloud load balancers, buffer by default: PPR still "works", but the shell and the dynamic content land together and the entire TTFB benefit disappears.
- New deploy, "Failed to find Server Action". Action IDs rotate per build
(at most every 14 days even when source is unchanged). Multi-instance
deployments need a shared
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY; clients mid-mutation need a retry path, not a stack trace. revalidateTag()only invalidates the instance it ran on. Across pods, tag state must be synced by a cache handler implementingrefreshTags().unstable_cacheand'use cache'are not the same store.use cacheentries are keyed by build id and never survive a deploy — evenremoteones. If something must persist across deploys, it is not ause cachejob.Math.random()/Date.now()inside a cached scope freeze one value for everyone. Callconnection()first and wrap in<Suspense>for a per-request value; cache it deliberately if one shared value is what you want.
Bundled resources
| Resource | Use it when |
|---|---|
scripts/audit-app-router.py |
Auditing or inheriting an app — 16 static rules for the landmines above, gated on the project's detected Next.js major |
scripts/check-nextjs-facts.py |
CI / freshness: are this skill's version facts still true? |
assets/next.config.template.ts |
Starting a 16.x config, or auditing an inherited one |
assets/nextjs-facts.json |
The dated fact catalog the verifier reads |
The audit script takes this skill's own advice: it reads the project's Next.js
major from node_modules/next (or package.json) and suppresses the rules
that postdate it — eight of the sixteen describe breakages introduced in 15 or
16, so running them against a 14-era app would flag correct code. The verdict
line always states the major it gated on. Use --assume-major N when scanning a
bare subdirectory where the version cannot be read.
# Inherit an unfamiliar app: what will bite, worst first
python scripts/audit-app-router.py /path/to/project
# CI gate: block only on the errors
python scripts/audit-app-router.py --min-severity error .
# Machine-readable, for triage or a report
python scripts/audit-app-router.py --json . | jq '.data[] | select(.severity=="error")'
# Scanning a subtree with no package.json in reach
python scripts/audit-app-router.py --assume-major 15 ./packages/web/app
# Is this skill still describing reality?
python scripts/check-nextjs-facts.py --offline
References
| File | Covers |
|---|---|
| references/caching-model.md | The previous model: four cache layers, defaults per major, the stale-response debug ladder |
| references/cache-components.md | use cache, cache keys, cacheLife/cacheTag, private vs remote, PPR and prefetch |
| references/server-client-boundary.md | use client graph, serialization, interleaving, context, environment poisoning |
| references/server-actions.md | Endpoint model, auth/validation, action vs route handler, config, skew |
| references/data-fetching-streaming.md | Fetch patterns, waterfalls, Suspense/loading.tsx, what a loading state costs |
| references/routing-and-rendering.md | File conventions, async params, dynamic/parallel/intercepting routes, metadata |
| references/proxy-and-runtimes.md | proxy.ts, matchers, execution order, Node vs Edge runtime API gaps |
| references/deployment.md | Self-hosting, Docker, multi-instance, CDN behaviour, the Cloudflare path |
| references/upgrading.md | 14 -> 15 -> 16 deltas (which are silent), Cache Components adoption, Pages -> App |
| references/optimization.md | next/font, next/script strategies, image props, bundle and build speed |
| references/testing.md | The shifted pyramid: async Server Components are E2E-only; action security tests; instant() |
Cross-references
react-ops— React itself: hooks, component architecture, state, the React-level Server Components model.typescript-ops— the type system behindPageProps/LayoutPropsand strict-mode discipline.tailwind-ops— styling;payloadcms-ops— Payload 3, which is Next.js-native and inherits every boundary rule here.cloudflare-ops/hono-ops— the Workers runtime, bindings and Hono-based APIs. Next.js on Cloudflare goes through@opennextjs/cloudflare; seereferences/deployment.mdfor the seam, and those skills for the platform.auth-ops— session and token design that Server Actions depend on;testing-ops— the test strategy this framework's boundaries need.
Files (claude-mods)
-
assets
-
next.config.template.ts 4.5 KB
/** * next.config.ts starter — Next.js 16.x, App Router, Cache Components. * * Every option below is either a deliberate default this skill recommends or an * ADAPT point marked as such. Delete what you do not need; an unexplained flag * in a config file is how a team inherits behaviour nobody chose. * * Verified against Next.js 16.3.3 (2026-08-30). The version-gated names here * (cacheComponents, partialPrefetching, cacheLife, cacheHandlers) are asserted * by scripts/check-nextjs-facts.py. */ import type { NextConfig } from 'next' const nextConfig: NextConfig = { // --- Rendering + caching model ------------------------------------------- // Cache Components is the explicit model: nothing is cached unless a // `'use cache'` scope says so, and Partial Prerendering becomes the default // rendering strategy. Turning this ON is a semantic change to every route — // read references/cache-components.md before flipping it on an existing app, // and migrate with the guide rather than route-by-route guesswork. // Leave it OFF and you are on the previous model (references/caching-model.md). cacheComponents: true, // Prefetches each route's App Shell so client navigations render instantly. // Pairs with cacheComponents; this is opt-in in 16.x and slated to become the // default in a later major. partialPrefetching: true, // ADAPT: name the cache lifetimes your domain actually has, rather than // scattering inline `cacheLife({ revalidate: 900 })` objects across the tree. // Built-in profiles (default/seconds/minutes/hours/days/weeks/max) still work; // redefining one changes it everywhere, so prefer a new name over overloading // `hours` — a reader expects `hours` to mean hours. cacheLife: { editorial: { stale: 600, // 10 min — how long the CLIENT reuses it without asking revalidate: 3600, // 1 hr — how often the SERVER refreshes in background expire: 86400, // 1 day — after this with no traffic, next read blocks }, }, // ADAPT (multi-instance only): the default `use cache` store is per-instance // and in-memory, so on serverless it rarely survives between requests and on // Kubernetes every pod holds its own copy. Point `'use cache: remote'` at a // shared handler (Redis/KV) when a high hit rate justifies the round trip. // cacheHandlers: { // remote: require.resolve('./cache-handler.mjs'), // }, // --- Images --------------------------------------------------------------- images: { // remotePatterns, never the deprecated `domains` array: patterns constrain // protocol, port and pathname, so a compromised host can't serve arbitrary // paths through your optimizer. remotePatterns: [ { protocol: 'https', hostname: 'images.example.com', pathname: '/media/**' }, ], // 16.x defaults, restated so they are a choice and not an accident: // qualities defaults to [75] and the `quality` prop is coerced to the // nearest listed value; minimumCacheTTL defaults to 14400 (4 hours). qualities: [75], minimumCacheTTL: 14400, }, // --- Deployment identity -------------------------------------------------- // ADAPT (multi-instance / rolling deploys): a stable deployment id is what // lets Next.js detect version skew and fall back to a hard navigation instead // of serving a client assets from a build that no longer exists. When set, it // also replaces the build id in `use cache` keys, so generateBuildId is inert. deploymentId: process.env.DEPLOYMENT_VERSION, // --- Self-hosting behind a buffering proxy -------------------------------- // Streaming only works end to end if nothing buffers the response. nginx // buffers by default; this header switches that off. Without it PPR still // "works" but the shell and the dynamic content arrive together, which // silently removes the entire TTFB benefit you enabled PPR for. async headers() { return [ { source: '/:path*{/}?', headers: [{ key: 'X-Accel-Buffering', value: 'no' }], }, ] }, // --- Server Actions ------------------------------------------------------- // ADAPT: only when you actually terminate TLS on another domain (proxy/CDN) // or accept payloads over the 1MB default. Widening allowedOrigins weakens // the Origin-vs-Host CSRF check, so list exact hosts, never a bare wildcard. // experimental: { // serverActions: { // allowedOrigins: ['my-proxy.example.com'], // bodySizeLimit: '2mb', // }, // }, } export default nextConfig -
nextjs-facts.json 1.3 KB
{ "schema": "claude-mods.nextjs-ops.facts/v1", "as_of": "2026-08-30", "comment": "Version-bearing external facts nextjs-ops states as current. Next.js caching and rendering semantics moved substantially at 15.0 (fetch uncached by default, async request APIs) and again at 16.0 (Cache Components / 'use cache', proxy.ts, Turbopack default), so a stale major here silently invalidates the highest-value section of the skill. check-nextjs-facts.py --offline asserts each prose_token is still named in the skill prose and that SKILL.md carries a dated currency note matching documented_major; --live asserts the npm package still resolves and its latest major still matches.", "next": { "prose_token": "Next.js 16", "package": "next", "documented_major": "16" }, "react": { "prose_token": "React 19", "package": "react", "documented_major": "19" }, "codemod": { "prose_token": "@next/codemod", "package": "@next/codemod", "documented_major": "16" }, "playwright_helper": { "prose_token": "@next/playwright", "package": "@next/playwright", "documented_major": "16" }, "opennext_cloudflare": { "prose_token": "@opennextjs/cloudflare", "package": "@opennextjs/cloudflare", "documented_major": "1" }, "server_only": { "prose_token": "server-only", "package": "server-only" } }
-
-
references
-
cache-components.md 12 KB
# Cache Components — `use cache`, lifetimes, and Partial Prerendering The explicit caching model, enabled with `cacheComponents: true` in `next.config.ts`. Introduced in Next.js 16.0 (`'use cache'` shipped experimentally in 15.0 under `experimental.dynamicIO`, since renamed). Verified against Next.js 16.3.3 docs, 2026-08-25. > This is a whole-app semantic change, not a per-route flag: every dynamic read > executes at request time unless a cache directive says otherwise, and Partial > Prerendering becomes the default rendering strategy. The old > `experimental.ppr` flag and the `experimental_ppr` route export were removed > in favour of it. ## The directive `'use cache'` caches the return value of an **async** function or component. It sits at the top of a function body, or at the top of a file (in which case every export is cached and every one of them must be async — including framework exports like `generateMetadata` and `generateStaticParams`). ```tsx import { cacheLife, cacheTag } from 'next/cache' export async function getProducts(categoryId: string) { 'use cache' cacheLife('hours') cacheTag(`category-${categoryId}`) return db.products.findMany({ where: { categoryId } }) } ``` Two levels, and the choice matters: - **Data-level** — cache the function. Reuse the same data across components, independent of the UI that renders it. - **UI-level** — cache the component/page/layout. The cached output is an RSC payload, so the *rendered markup* is what gets reused. ## Cache keys — what actually varies an entry The key is built from: 1. **Build ID** (or `deploymentId` when configured) — so no entry survives a deploy, `remote` ones included. 2. **Function ID** — a hash of the function's location and signature. 3. **Serializable arguments** — props for components, arguments for functions. 4. **HMR refresh hash**, in development only. **Closure captures become arguments.** A variable referenced from an enclosing scope is bound in automatically and joins the key: ```tsx async function Component({ userId }: { userId: string }) { const getData = async (filter: string) => { 'use cache' // key includes BOTH userId (captured) and filter (passed) return fetch(`/api/users/${userId}/data?filter=${filter}`).then((r) => r.json()) } return getData('active') } ``` That is the mechanism behind the most common capacity surprise: a per-user value in scope turns one logical entry into one entry per user. ## Serialization — the constraint that shapes the API Arguments use **Server Component** serialization; return values use **Client Component** serialization. The former is stricter, which is why you can *return* JSX but not *accept* it as an inspected argument. | | Supported | |---|---| | **Arguments** | primitives, plain objects, arrays, `Date`, `Map`, `Set`, TypedArrays, `ArrayBuffer`, React elements as pass-through only | | **Return values** | all of the above, plus JSX elements | | **Neither** | class instances, functions (except pass-through), Symbols, `WeakMap`/`WeakSet`, `URL` instances | ### Pass-through: composition without polluting the key A non-serializable value is fine **as long as the cached body never introspects it**. This is what keeps `children` composition alive: ```tsx async function CachedWrapper({ header, children }: { header: ReactNode; children: ReactNode }) { 'use cache' const data = await getCachedData() return <div>{header}<Rendered data={data} />{children}</div> // placed, never read } ``` Server Actions pass through the same way — hand one to a Client Component through a cached component, just never *call* it inside the cached body. A cached `layout` therefore does not cache its `children`; slots pass through. ## Constraints inside a cached scope | Constraint | Behaviour | |---|---| | **Request APIs** | `cookies()`, `headers()`, `searchParams`, dynamic `params` throw [`next-request-in-use-cache`](https://nextjs.org/docs/messages/next-request-in-use-cache). **The restriction follows the call stack** — a helper that reads them fails identically. On a dynamically-rendered route this can pass `next build` and only fail under `next start` | | **Draft Mode** | `draftMode()`'s `isEnabled` *is* readable inside a cached scope; while draft mode is on, cached functions re-execute every request and results are not stored. `enable()`/`disable()` throw | | **`React.cache`** | Runs in an isolated scope inside the boundary. Values stored outside are invisible inside — you cannot smuggle data in that way. Use arguments | | **Non-determinism** | `Math.random()`, `Date.now()`, `crypto.randomUUID()` are guarded. Call `connection()` and wrap in `<Suspense>` for a per-request value, or cache deliberately so everyone shares one. `performance.now()` is exempt — it is telemetry | The sanctioned pattern for request-dependent data is to read it **outside** and pass the value in: ```tsx async function ProfileContent() { // not cached: reads the cookie const session = (await cookies()).get('session')?.value return <CachedContent sessionId={session} /> } async function CachedContent({ sessionId }: { sessionId: string }) { 'use cache' // sessionId joins the key return <div>{await fetchUserData(sessionId)}</div> } ``` ## Lifetimes: `cacheLife` Three properties, three different audiences: - **`stale`** — how long the *client* reuses it without asking the server. - **`revalidate`** — how often the *server* regenerates in the background. - **`expire`** — after this long with no traffic, the next read blocks on a fresh render. Must be greater than `revalidate`; Next.js validates this. | Profile | `stale` | `revalidate` | `expire` | |---|---|---|---| | `default` | 5 min | 15 min | never | | `seconds` | 30 s | 1 s | 1 min | | `minutes` | 5 min | 1 min | 1 hr | | `hours` | 5 min | 1 hr | 1 day | | `days` | 5 min | 1 day | 1 week | | `weeks` | 5 min | 1 week | 30 days | | `max` | 5 min | 30 days | 1 year | Custom and overridden profiles live in `next.config.ts` under `cacheLife`; omitted properties inherit from `default`. Redefining `default` also changes what every un-annotated scope does. Inline objects (`cacheLife({ revalidate: 900 })`) are for one-offs, including lifetimes computed from fetched data. `cacheLife` cannot be called at module scope, and only one call should execute per invocation (branching between two calls is fine). ### Nesting — the rule worth memorising - **Outer scope with an explicit `cacheLife`:** its own lifetime wins, always, longer or shorter than the inner one. - **Outer scope without one:** it uses `default` (15 min revalidate), and an inner *shorter* lifetime drags it down. A longer inner one cannot extend it. - **A short-lived inner cache nested in an outer scope with no explicit lifetime is a prerender-time build error** — deliberately, because the propagation would otherwise be silent. The nested cache may be in an imported module or a dependency, which is what makes it hard to spot. This is the entire argument for the house rule: **state `cacheLife` in every scope.** It makes a cached function readable in isolation. ### Prerendering thresholds A short lifetime changes *where* content can be served from: - `revalidate: 0`, or `expire` under 5 minutes → excluded from prerenders; becomes a dynamic hole resolved at request time. - `stale` under 30 seconds → excluded from prefetches (a prefetch would expire before the user could click). The client enforces a 30-second floor. - `stale` ≥ 30 s but < 5 min → in the prerender, out of the App Shell. Of the presets, only `seconds` trips any of these. ## Where a cached result lives | Store | What | Notes | |---|---|---| | **Prerendered HTML** | The payload rendered to HTML | On disk when self-hosting, or platform storage behind a CDN. `revalidate`/`expire` control rebuilds | | **Shared server store** | The RSC payload | **Per-instance and in-memory by default** — ephemeral on serverless, persistent when self-hosted (`cacheMaxMemorySize`). `'use cache: remote'` moves it to a `cacheHandlers` backend shared across instances, at the cost of a network round trip that only pays off at a high hit rate | | **Browser** | The payload in a navigation or prefetch | Fresh for its `stale` window. `'use cache: private'` results live only here | All stores are scoped to one deployment. ### The three directives | Directive | Reads request data? | Stored | Use when | |---|---|---|---| | `'use cache'` | No | Server (in-memory by default) + prerender + client | The default choice | | `'use cache: remote'` | No | A durable shared cache handler | Multi-instance, and the hit rate justifies the round trip | | `'use cache: private'` | **Yes** — cookies, headers, searchParams directly | Client only, per session | Compliance constraints, or code that genuinely cannot be refactored to pass values as arguments | ## Revalidation Time-based via `cacheLife`; on-demand via `cacheTag` plus one of: ```ts 'use server' import { updateTag, revalidateTag, refresh } from 'next/cache' updateTag('products') // expire + re-read in the same response revalidateTag('products', 'max') // stale-while-revalidate, no immediate re-render refresh() // refetch uncached data only; cache untouched ``` They are commonly paired: a long `cacheLife('max')` with a `cacheTag`, busted on demand when an editor saves. See [server-actions.md](server-actions.md) for which one a given mutation wants. ## Rendering: PPR, the App Shell, and prefetching At build time Next.js renders the tree and sorts it: - `'use cache'` output → the **static shell** (if its lifetime is long enough). - `<Suspense>` → the fallback ships in the shell, the content streams at request time. - Module imports, `fs.readFileSync`, pure computation → resolved into the shell automatically. - Random values/timestamps → `connection()` + `<Suspense>`, or cache them. When a route's dynamic params are known, the shell contains concrete content. When they are not, the reusable URL-independent version is the **App Shell** — served instantly on first visit and upgraded in the background with the now-known params, which is what ISR looks like under this model. **Maximise the shell by pushing awaits down the tree.** A layout that awaits `params` at the top cannot be prerendered at all; pass the promise down and await inside a boundary: ```tsx export default function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) { return ( <div> <Sidebar /> <Suspense fallback={<h1>Loading...</h1>}> {params.then(({ slug }) => <SlugHeading slug={slug} />)} </Suspense> {children} </div> ) } ``` **Prefetching costs a server invocation per prefetchable link.** With `partialPrefetching`, the router prefetches each route's App Shell by default; `<Link prefetch={true}>` re-renders the destination with its URL resolved so `searchParams`- and `params`-dependent cached content joins the prefetch. That is a real bill — see [data-fetching-streaming.md](data-fetching-streaming.md). ## Debugging ```bash NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev # verbose cache logging (also ISR) NEXT_PRIVATE_DEBUG_CACHE=1 npm run start ``` In development, console logs replayed from cached functions are prefixed `Cache`. The dev overlay surfaces named insights — `blocking-route`, `blocking-prerender-random`, `blocking-prerender-current-time`, `blocking-prerender-crypto` — each with a concrete fix; Instant Insights flags navigations that are not instant. ### "Filling a cache during prerender timed out" A 50-second build hang, then that error. Cause: a Promise created **outside** a cached boundary is awaited **inside** one, so it can never resolve during prerender. The usual routes in are passing a `cookies()` store as a prop, closing over a request-scoped promise, or sharing a `Map` of in-flight fetches between cached and uncached code. Calling `cookies()` directly inside the scope fails immediately with `next-request-in-use-cache` instead — a different error for a related mistake. -
caching-model.md 7.7 KB
# The Previous Caching Model — layers, defaults, and the stale-response ladder Applies when `cacheComponents` is **not** enabled — which is still the default in Next.js 16. If it is enabled, read [cache-components.md](cache-components.md) instead; the two models do not interleave and advice from one is actively wrong in the other. Verified against Next.js 16.3.3 docs, 2026-08-25. ## Why this is the expensive area Caching defaults **inverted** between majors. The same code, unchanged, caches differently depending on which major it runs under — and the failure mode is a page that renders perfectly with data from an hour ago. | Major | `fetch()` with no `cache` option | |---|---| | ≤ 14 | **Cached** by default (`force-cache`) | | 15.x, 16.x | **Not cached** by default | Most "Next.js caching is broken" reports are a 14-era mental model applied to a 15/16 app, or the reverse. Establish the major first, every time. ## The four layers | Layer | Where | Caches | Lifetime | Opt out | |---|---|---|---|---| | **Request Memoization** | Server, per render pass | Duplicate `fetch` (and `React.cache`) calls with identical arguments | One render pass. Not a cache you tune | Nothing to opt out of — it exists so you can fetch the same data in three components without three round trips | | **Data Cache** | Server, persistent, across requests and deploys | `fetch` responses and `unstable_cache` results | Until revalidated by time or tag | `cache: 'no-store'` (the default in 15/16) | | **Full Route Cache** | Server, build/ISR output | The rendered HTML + RSC payload of a statically-rendered route | Until the route revalidates or you redeploy | Any request-time API, `dynamic = 'force-dynamic'`, or `revalidate = 0` | | **Client Router Cache** | Browser memory | RSC payloads of visited/prefetched routes | Per `staleTimes`; cleared entirely by a revalidation call in an action | `router.refresh()`, a mutation, a full reload | The layers are *ordered*. A stale value can be held by any one of them, and clearing the wrong one produces "I invalidated it and nothing happened." ## Opting in and out, deliberately ```ts // Per request — the only fetches that cache in 15/16 are the ones that ask. await fetch(url, { cache: 'force-cache' }) // cache indefinitely await fetch(url, { next: { revalidate: 3600 } }) // time-based await fetch(url, { next: { tags: ['products'] } }) // taggable, on-demand ``` ```ts // Non-fetch work (ORM, SDK, computation) import { unstable_cache } from 'next/cache' export const getCachedUser = unstable_cache( async (id: string) => db.select().from(users).where(eq(users.id, id)), ['user'], // key prefix { tags: ['user'], revalidate: 3600 }, ) ``` **This model's two stores are the ones that survive a deploy.** Both the `fetch` data cache and `unstable_cache` persist across builds; `'use cache'` entries never do, because the build id (or `deploymentId`) is part of their key — not even `remote` ones. If something must outlive a deploy, it belongs in one of these two, not in a Cache Components scope. ```ts // Deduplicate non-fetch reads within one render pass import { cache } from 'react' export const getPost = cache(async (id: string) => db.query.posts.findFirst(/* … */)) ``` ### Route segment config Exported from a `page`, `layout` or `route`. Statically analysable values only — `revalidate = 600` works, `revalidate = 60 * 10` does not. | Export | Values | Effect | |---|---|---| | `dynamic` | `'auto'` (default) \| `'force-dynamic'` \| `'error'` \| `'force-static'` | `force-dynamic` renders per request and forces every `fetch` to `no-store`. `error` fails the build if anything request-time is used. `force-static` makes `cookies()`/`headers()`/`useSearchParams()` return empty values — a quiet source of "why is the user always logged out" | | `revalidate` | `false` (default) \| `0` \| `number` | Route-level default in seconds. **The lowest value across the whole route wins**, layouts included, so one impatient child speeds up the entire route | | `fetchCache` | `'auto'` … `'force-no-store'` | Advanced override of every `fetch` default in the segment. Reach for it only when you need a whole-route guarantee | `runtime` is also a segment export, but `'edge'` is the deprecated path — see [proxy-and-runtimes.md](proxy-and-runtimes.md). ### On-demand invalidation ```ts revalidateTag('products', 'max') // SWR: serve stale now, refresh in background updateTag('products') // read-your-writes; Server Actions only revalidatePath('/products') // by URL; a convenience layer over tags ``` In 16, `revalidateTag` takes a `cacheLife` profile (or `{ expire: seconds }`) as its second argument. The single-argument form still runs but is deprecated — `'max'` is the recommended profile for long-lived content. ## The stale-response debug ladder Work down it. Stop at the first rung that explains the symptom; each rung produces a different fix, which is why guessing is expensive. 1. **Are you testing in `next dev`?** Development never caches pages. A caching bug is only observable under `next build && next start`. Half of all reported caching mysteries end here. 2. **Which model?** `grep -n cacheComponents next.config.*`. If it is on, this file does not apply. 3. **Which major?** `node -p "require('next/package.json').version"`. A 14→15 upgrade silently un-caches every unannotated `fetch`; a 15→16 upgrade changes `revalidateTag`. 4. **Turn on the log.** `NEXT_PRIVATE_DEBUG_CACHE=1 npm run start` reports cache hits, misses and ISR activity. This is the cheapest real evidence available. 5. **Is the value in the Data Cache or the Full Route Cache?** If the *page* is stale but a fresh API call returns new data, it is the route cache — check for a `revalidate` export, or that the route is static when you assumed dynamic. `next build` prints the rendering mode of every route; read that output. 6. **Is it the client?** A stale value that disappears on hard reload but survives in-app navigation is the Client Router Cache. Mutations that call `updateTag`/`revalidatePath`/`refresh` clear it; a bare `revalidateTag` with an SWR profile deliberately does not re-render. 7. **Multi-instance?** `revalidateTag` invalidates only the instance that ran it. Other pods keep serving their own copy until they independently expire. Coordination requires a cache handler implementing `refreshTags()` — see [deployment.md](deployment.md). 8. **CDN in front?** Dynamic pages emit `Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate`; fully static ones emit `public`. If a CDN is caching something marked private, or ignoring `s-maxage`, the stale copy is not Next.js's at all. ## Preloading, to kill the waterfall without caching anything Caching is often reached for when the real problem is sequencing. Start the fetch before the thing that blocks on it: ```ts import { cache } from 'react' import 'server-only' export const getItem = cache(async (id: string) => { /* … */ }) export const preload = (id: string) => { void getItem(id) } ``` ```tsx preload(id) // kick it off const ok = await checkAvailable() // do the other await meanwhile return ok ? <Item id={id} /> : null ``` See [data-fetching-streaming.md](data-fetching-streaming.md) for the fuller waterfall treatment. ## Migrating to Cache Components The move is not route-by-route: enabling `cacheComponents` changes the default for the whole app from "cache what we can infer" to "cache nothing unless told". Use the official migration guide and the `@next/codemod` upgrade tooling rather than hand-converting, then read [cache-components.md](cache-components.md) for the semantics you are converting *to*. -
data-fetching-streaming.md 8.2 KB
# Data Fetching, Streaming, and What a Loading State Costs Verified against Next.js 16.3.3 docs, 2026-08-25. ## Fetch where the data is used Server Components fetch directly — no client round trip, no exposed credentials, no `useEffect`. The instinct to lift every fetch to the page and prop-drill it down is a Pages Router habit that actively hurts here: it moves the `await` **up**, and everything above an `await` cannot be prerendered. ```tsx // Fetch in the component that renders it async function Reviews({ productId }: { productId: string }) { const reviews = await getReviews(productId) return <ul>{reviews.map((r) => <li key={r.id}>{r.body}</li>)}</ul> } ``` Duplicate `fetch` calls with identical arguments are memoized within a render pass; for non-`fetch` reads, wrap with React's `cache()` to get the same deduplication. ## Waterfalls Sequential `await`s serialise. Two shapes fix it: ```tsx // Parallel within one component const [user, posts] = await Promise.all([getUser(id), getPosts(id)]) ``` ```tsx // Or let independent components suspend independently — each streams when ready <Suspense fallback={<UserSkeleton />}><User id={id} /></Suspense> <Suspense fallback={<PostsSkeleton />}><Posts id={id} /></Suspense> ``` The second is usually better: it removes the waterfall *and* gets the fast half onto the screen first. Preloading (see [caching-model.md](caching-model.md)) covers the case where the two are in the same component but only one blocks. ## Suspense boundaries define the shell A `<Suspense>` boundary is the seam between "ships in the initial HTML" and "streams in later". The fallback goes in the static shell; the content arrives when it resolves. ```tsx export default function Page() { return ( <> <h1>My Blog</h1> {/* static */} <CachedPosts /> {/* 'use cache' → static shell */} <Suspense fallback={<p>Loading…</p>}> <LatestComments /> {/* request-time → streams */} </Suspense> </> ) } ``` Two properties worth stating plainly: - **`<Suspense>` does not make anything dynamic.** A component doing only synchronous work completes during prerendering whether or not it is wrapped. The boundary describes where a *hole* may appear, not that one will. - **Under Cache Components, reading `cookies()` inside a boundary no longer de-opts the route.** The static and cached parts still ship in the initial HTML; only the boundary streams. That is the single biggest behavioural difference from the pre-16 model, where one `cookies()` call anywhere turned the whole route dynamic. ### `loading.tsx` vs inline `<Suspense>` `loading.tsx` wraps a whole route segment — one boundary, whole-page granularity. Inline `<Suspense>` gives per-region granularity and is what lets the rest of the page be instant. Use `loading.tsx` as a floor, inline boundaries for anything you want visible immediately. ## What a loading state actually costs A fallback is not free, and this is where teams over-correct in both directions. **Costs of a boundary:** - Content behind it is **excluded from the initial HTML**, so it is invisible to anything that does not execute the stream. Bots and crawlers are handled separately (see below), but simple scrapers and some previews are not. - Streaming requires an unbuffered path end to end. Behind a buffering proxy the shell and the content arrive together and the boundary bought you nothing — see [deployment.md](deployment.md). - Every boundary is a layout shift risk. A fallback whose dimensions differ from the real content trades TTFB for CLS. - Nested boundaries mean nested reveals; too many produce a page that flickers into existence in six stages. **Costs of no boundary:** the whole route blocks on its slowest read. The user sees nothing — not a shell, not a nav — until the last query returns. The rule that follows: **put the boundary around the slow, uncertain, or personalised region, and only that region.** Cache what is shared; stream what is per-user; keep the frame static. ### Prefetching is a real bill With `partialPrefetching` enabled, the router prefetches each route's App Shell by default. `<Link prefetch={true}>` goes further — Next.js re-renders the destination with its URL resolved so that `searchParams`/`params`-dependent cached content joins the prefetch. **That is one server invocation per prefetchable link.** On a page with fifty product links this is a deliberate trade, not a free optimisation. 16.3 softened it: prefetches under a size threshold are bundled, layouts are deduplicated across links, and requests are cancelled when a link leaves the viewport. ## Runtime APIs and where to await them `cookies()`, `headers()`, `draftMode()`, `params` and `searchParams` are all async (since 15.0) and all request-time. Where you await them determines how much of the page can be prerendered. ```tsx // ❌ awaiting at the top of the layout — nothing above can be prerendered export default async function Layout({ params }: LayoutProps<'/shop/[slug]'>) { const { slug } = await params return <div><Sidebar /><h1>{slug}</h1></div> } // ✅ pass the promise down; await inside a boundary export default function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) { return ( <div> <Sidebar /> <Suspense fallback={<h1>Loading…</h1>}> {params.then(({ slug }) => <SlugHeading slug={slug} />)} </Suspense> {children} </div> ) } ``` The same move applies to every runtime API and to any slow `await`. "Push the await down" is the single highest-leverage structural habit in the App Router. For a per-request value from a non-request source (a UUID, a timestamp), call `connection()` first and wrap in `<Suspense>` — that is what tells the framework the work must not run at build time. ## Predictable reads do not need a boundary Module imports, `fs.readFileSync`, pure computation and synchronous embedded databases (`better-sqlite3`, `node:sqlite`) complete during prerendering and land in the static HTML automatically. A config file that never varies per request belongs at module scope, read once — not awaited inside a component where it becomes an uncached read that needs a boundary or a cache. ## Error boundaries `error.js` is the route-level convention. For component-level recovery, 16.3 added `catchError` from `next/error`, which — unlike a plain React error boundary — does not interfere with `notFound()` or `redirect()`, and hands the fallback a `retry()` that can re-fetch failed Server Components: ```tsx 'use client' import { catchError, type ErrorInfo } from 'next/error' function Fallback(props: { title: string }, { error, retry }: ErrorInfo) { return <div><h2>{props.title}</h2><p>{error.message}</p> <button onClick={() => retry()}>Try again</button></div> } export default catchError(Fallback) ``` Wrap error boundaries around the same subtrees as Suspense boundaries: the region that can fail independently is the region that should fail independently. ## Bots and crawlers get a different render Crawlers are detected by user agent and served a **full dynamic render** rather than the shell, because they need a complete document. The shell's work therefore runs at *request* time for them. If any part of the shell depends on build-time-only inputs, the page can render for a human and 500 for Googlebot. Make sure everything the shell needs is also reachable at request time. ## Guarding against regression Instant navigation is easy to lose by accident: a `cookies()` read added to a shared header, a `<Suspense>` boundary moved during a refactor. The `@next/playwright` `instant()` helper asserts what must be visible *without waiting for the network*, so the test fails whatever the cause: ```ts import { instant } from '@next/playwright' await instant(page, async () => { await page.click('a[href="/products/hats"]') await expect(page.locator('h1')).toContainText('Baseball Cap') }) ``` The DevTools Instant Insights panel surfaces the same regressions in development, and the Navigation Inspector pauses a navigation at its shell so you can see exactly what the user would see. `testing-ops` and `playwright-ops` own the wider test strategy. -
deployment.md 8.3 KB
# Deployment — self-hosting, multi-instance, CDNs, and Cloudflare Verified against Next.js 16.3.3 docs, 2026-08-25. Next.js runs perfectly well off Vercel. What it does *not* do is infer your infrastructure: several behaviours that are automatic on a platform with integrated storage and streaming become explicit configuration everywhere else. Those are the ones below. ## The four deployment shapes | Shape | Supports | Does not support | |---|---|---| | **Node.js server** (`next start`) | everything | — | | **Docker container** | everything, with the caveats below | — | | **Static export** (`output: 'export'`) | pure static sites | `use cache`, proxy, ISR, Route Handlers, image optimization (without a custom loader) | | **Adapters** | platform-specific | — | For containers, `output: 'standalone'` produces a minimal server bundle with only the traced dependencies — the difference between a ~1GB image and a small one. Copy `.next/static` and `public` alongside it; the trace does not include them. ## Environment variables: build time vs runtime `NEXT_PUBLIC_*` variables are **inlined into the JavaScript bundle at `next build`**. They are baked into the image and cannot be changed by the runtime environment — which is exactly the trap when one image is promoted through dev → staging → prod. Server-side variables are read at runtime **during dynamic rendering**. To read one in a component that would otherwise prerender, defer to request time first: ```tsx import { connection } from 'next/server' export default async function Component() { await connection() const value = process.env.MY_VALUE // now evaluated per request } ``` That is what makes a single promotable image possible. `register()` in `instrumentation.ts` runs code on server startup if you need boot-time setup. ## Caching and ISR when you own the disk Page cache and ISR share **one Next.js server cache**, stored on the local filesystem of each instance by default (plus ~50MB in memory). That is correct for a single `next start` with persistent disk, and wrong for everything else: on ephemeral compute the disk does not persist, and on Kubernetes every pod holds its own independent copy. ```js // next.config.js — one shared cache instead of N private ones module.exports = { cacheHandler: require.resolve('./cache-handler.js'), cacheMaxMemorySize: 0, // disable the in-memory layer } ``` A handler implements `get`, `set`, `revalidateTag`, and `resetRequestCache`. The Redis example in the Next.js repo is the usual starting point; production needs durable storage, eviction, error handling and tag coordination on top. For `'use cache'` backends specifically, the config key is **`cacheHandlers`** (plural) — that is what `'use cache: remote'` resolves against. Two different options with confusingly similar names. ### Automatic `Cache-Control` behaviour | Response | Header | |---|---| | Immutable build assets (hashed filenames) | `public, max-age=31536000, immutable` — cannot be overridden | | ISR pages | `s-maxage: <revalidate>, stale-while-revalidate` | | Dynamically rendered pages, and Draft Mode | `private, no-cache, no-store, max-age=0, must-revalidate` | A CDN in front must respect these *and* the cache-key variability, or you get either no CDN caching at all or — worse — a personalised page served to the wrong user. If a page is fully prerendered it emits `public` and is safe to cache at the edge. ## Multi-instance: the four things that break 1. **Server Function encryption key.** Closure variables are encrypted with a per-build key. Across instances, one instance cannot decrypt another's references. Set a stable `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` (base64, 16/24/32 bytes) at build time for every instance, or expect `Failed to find Server Action` under load with no deploy involved. 2. **Version skew.** Set `deploymentId`; static assets then carry `?dpl=…`, navigations send `x-deployment-id`, and a mismatch triggers a hard navigation instead of a broken one. When `deploymentId` is set, `generateBuildId` is inert and the deployment id is what varies `use cache` keys. Without it, use `generateBuildId` (e.g. the git hash) so every container in a deployment agrees on a build ID. 3. **Shared cache.** As above — `'use cache: remote'` plus a `cacheHandlers` backend, or accept per-instance caches. 4. **Tag coordination.** `revalidateTag()` invalidates only the instance that ran it; the others keep serving their own copy. Implement `refreshTags()` in the cache handler — it is called before each request and should sync tag state from shared storage. ## Streaming behind a proxy Streaming, `loading.tsx`, and PPR all require an unbuffered path **end to end**. nginx buffers by default: ```js module.exports = { async headers() { return [{ source: '/:path*{/}?', headers: [{ key: 'X-Accel-Buffering', value: 'no' }] }] }, } ``` Also check load balancers (chunked transfer or HTTP/2 — AWS ALB with Lambda integration is a known buffering case) and any reverse proxy in between. The failure is silent and expensive: PPR still "works", the shell and dynamic content simply arrive together and the entire TTFB benefit you built for disappears. Test it by watching whether the first bytes arrive before the slow query finishes, not by checking that the page renders. ## Image optimization Works with zero configuration under `next start`. On glibc-based Linux, sharp's memory allocator may need tuning to avoid runaway memory — a common "the container keeps getting OOM-killed" cause. Alternatives: a custom `loader` pointing at a dedicated image service, or `unoptimized` while keeping the rest of `next/image`. 16.x changed several defaults worth restating in your own config so they are choices: `minimumCacheTTL` 60s → 14400s (4h), `qualities` `[1..100]` → `[75]` with coercion to the nearest listed value, `maximumRedirects` unlimited → 3, and local IP optimization blocked unless `dangerouslyAllowLocalIP` is set. ## Graceful shutdown and `after()` `after()` is fully supported under `next start`. Send `SIGINT`/`SIGTERM` and **wait** — the server finishes in-flight requests and runs pending `after()` callbacks before exiting. Give the orchestrator a drain period of 10–30 seconds; a shorter `terminationGracePeriodSeconds` silently drops background work. ## The Cloudflare path Next.js runs on Cloudflare Workers through **`@opennextjs/cloudflare`** — an adapter that transforms the Next.js build output for the Workers Node.js-compat runtime. As of 2026-08-30 it supports all of Next.js 16 and the latest minors of 14 and 15, covering App Router, Route Handlers, SSG/SSR, PPR, ISR, `after()` and `'use cache'`. Verify current coverage before committing: the adapter tracks Next.js releases and this is exactly the fact most likely to have moved. Two things to decide before taking this route: - **Is Next.js the right shape for this workload at all?** If the app is primarily an API with a thin UI, a Workers-native stack is simpler and cheaper — see `hono-ops` for the API and `cloudflare-ops` for bindings, KV/R2/ D1, and Durable Objects. Choosing the adapter to run a mostly-API Next.js app on Workers is usually the expensive path. - **Where does the cache live?** The whole multi-instance section above applies with force: Workers are ephemeral and horizontally scaled, so the default in-memory `use cache` store is effectively no cache, and tag revalidation needs coordinated storage. Configuration of the Worker itself — `wrangler.jsonc`, compatibility flags, bindings, secrets, deployment — is `cloudflare-ops` territory. This skill stops at the seam. ## Pre-deploy checklist - [ ] Caching verified against `next build && next start`, never `next dev` - [ ] `next build` output reviewed — is every route in the rendering mode you expected? - [ ] `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` set and shared, if multi-instance - [ ] `deploymentId` (or `generateBuildId`) stable across containers in a deployment - [ ] Cache handler configured if instances > 1, with `refreshTags()` for tag coordination - [ ] Streaming unbuffered through every proxy in the path - [ ] `NEXT_PUBLIC_*` values correct for the environment this image was **built** for - [ ] Drain period long enough for in-flight `after()` work - [ ] `python scripts/audit-app-router.py --min-severity error .` clean -
optimization.md 7.4 KB
# Optimization — fonts, scripts, images, bundle Verified against Next.js 16.3.3 docs, 2026-08-30. `perf-ops` owns profiling method (how to measure, flamegraphs, load testing). This file owns the Next.js-specific levers and the order to pull them in. > **Order matters more than any individual lever.** The rendering and caching > decisions in [cache-components.md](cache-components.md) and > [data-fetching-streaming.md](data-fetching-streaming.md) dominate everything > here: a route that blocks on an uncached database read is not going to be > rescued by a smaller bundle. Fix what the page *waits* for, then what it > *ships*, then trim. ## Fonts — `next/font` `next/font` downloads font files at **build time** and self-hosts them from your own origin. That removes the third-party request to Google Fonts entirely, which is a privacy property as much as a performance one. ```tsx // app/layout.tsx — load once at module scope, never inside a component import { Inter } from 'next/font/google' const inter = Inter({ subsets: ['latin'], display: 'swap' }) export default function RootLayout({ children }: { children: React.ReactNode }) { return <html lang="en" className={inter.className}><body>{children}</body></html> } ``` The details that matter: - **Module scope, always.** A font call inside a component body re-runs per render and defeats the build-time handling. - **`subsets` is not optional in practice.** Without it you ship glyph ranges nobody on the page will use. - **Zero layout shift is the headline feature**: Next.js computes a size-adjusted fallback so the swap does not reflow. That only holds if you let it manage the fallback rather than hand-writing a `font-family` stack that bypasses it. - **Local fonts** use `next/font/local` and get the same treatment. - **Variable fonts** are usually the smaller total download when you use more than two weights — one file instead of four. ## Scripts — `next/script` Four strategies. Picking the wrong one is how a tag manager ends up blocking first paint. | Strategy | When it loads | For | |---|---|---| | `beforeInteractive` | Injected into the initial HTML, before any Next.js module | Bot detectors, cookie-consent managers. **Root layout only** | | `afterInteractive` | **Default.** Client-side, after some hydration | Tag managers, analytics | | `lazyOnload` | Browser idle time, after everything else | Chat widgets, social embeds | | `worker` | A web worker | **Experimental, and does not work in the App Router** — `pages/` only, behind `experimental.nextScriptWorkers` | - `beforeInteractive` scripts are always injected into `<head>` regardless of where you place the component, and run **once per document load** — a client navigation does not re-run them, including one that only changes a root param such as `/en` → `/fi`. - `onLoad`, `onReady` and `onError` are **Client Component only**. `onLoad` and `onError` cannot be combined with `beforeInteractive`; use `onReady` there. - `onReady` fires on first load *and* every subsequent remount — the right hook for anything that must re-instantiate after a route change (a map embed). The most common real win is demoting a script nobody needs early from the default `afterInteractive` down to `lazyOnload`. ## Images — `next/image` The component's job is to stop the three classic image failures: no layout shift, no oversized download, no render-blocking decode. - **`sizes` is the one people skip and the one that matters.** Without an accurate `sizes`, the browser assumes full viewport width and picks a far larger source than the layout needs. - **`priority`** on the LCP image only. On everything else it competes with the content that actually matters. - **`fill` + a positioned parent** for unknown intrinsic dimensions; otherwise give real `width`/`height` so the box is reserved. - **`placeholder="blur"`** is free for static imports; remote images need `blurDataURL`. 16.x defaults worth restating in your own config so they are choices (see [deployment.md](deployment.md) for the full list): `qualities` is `[75]` and the `quality` prop is **coerced to the nearest listed value**, so `quality={90}` silently becomes 75 until you list it; `minimumCacheTTL` is 14400s. Use `remotePatterns`, never the deprecated `images.domains` — that is the `images-domains-config` finding in `audit-app-router.py`. ## Bundle ### The boundary is the biggest lever Everything a `'use client'` module imports ships to the browser, transitively. The single largest bundle win in most App Router apps is moving the directive from a layout to the interactive leaf — which is why `client-component-route-file` is a rule in the audit script. See [server-client-boundary.md](server-client-boundary.md). ### `next/dynamic` For genuinely heavy, genuinely optional client code — a rich text editor, a charting library, a modal's contents: ```tsx const Chart = dynamic(() => import('./chart'), { ssr: false, loading: () => <Skeleton /> }) ``` `ssr: false` is only legal in a Client Component. Reach for this when the code is both large and not needed for first paint; used reflexively it just adds request waterfalls. ### `experimental.optimizePackageImports` Barrel-file packages export hundreds of modules; importing one named export can pull the lot. This makes the import load only what you use. ```js // next.config.js module.exports = { experimental: { optimizePackageImports: ['my-barrel-package'], }, } ``` **Still `experimental` in 16.3.3, and the docs explicitly say it is not recommended for production** — treat it as a measured experiment, not a default. A long list is already optimized automatically (`lucide-react`, `date-fns`, `lodash-es`, `@mui/material`, `@mui/icons-material`, `recharts`, `@headlessui/react`, `@heroicons/react/*`, `react-icons/*`, `rxjs`, `antd`, `effect`, and more), so check before adding one: you may be configuring something you already have. ### Measuring ```bash npx @next/bundle-analyzer # wire it into next.config, then build next build # per-route First Load JS, and the rendering mode ``` Read the `next build` table before reaching for tooling. It gives First Load JS per route *and* whether each route is static or dynamic — and the rendering mode is usually the more expensive of the two problems. ## Build and dev speed 16.x moved most of this into defaults; the remaining knobs are small: - **Turbopack is the default bundler.** `--webpack` opts out, which also opts out of the speedups. - **Filesystem caching is on by default in 16.3** for both dev and build; CI gains need the cache directory to persist between runs to matter. - **TypeScript 7** can be used for `next build` type-checking by bumping the local dependency — a large win on big codebases. - **React Compiler** (`reactCompiler: true`) auto-memoizes and is stable, but is **not** on by default and *increases* build time via Babel. The experimental `turbopackRustReactCompiler` removes the Babel round trip, with the gain conditional on having no other Babel transforms. ## What to check, in order 1. `next build` — is every route in the rendering mode you expect? 2. Does anything block the shell that should be behind `<Suspense>` or `'use cache'`? 3. Is the `'use client'` boundary at the leaf, or at a layout? 4. Fonts at module scope with `subsets`; LCP image with `priority` and `sizes`. 5. Third-party scripts demoted to the latest strategy that still works. 6. Only then: analyzer, `next/dynamic`, package-import optimization. -
proxy-and-runtimes.md 7.6 KB
# `proxy.ts` and the Runtimes Verified against Next.js 16.3.3 docs, 2026-08-25. ## Middleware is now Proxy Next.js 16 renamed `middleware.ts` to `proxy.ts`. `middleware.ts` still works but is deprecated and will be removed. The rename is a statement of intent: the team considers this feature a last resort, and "middleware" invited Express-style misuse. ```bash npx @next/codemod@canary middleware-to-proxy . ``` ```ts // proxy.ts — project root, or src/, alongside app/ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function proxy(request: NextRequest) { return NextResponse.redirect(new URL('/home', request.url)) } export const config = { matcher: '/about/:path*' } ``` Default or named `proxy` export; one file per project (import modules into it if the logic grows); `proxy.page.ts` if you have customised `pageExtensions`. **Version history worth carrying:** Node.js runtime for middleware became experimental in 15.2, stable in 15.5, and the **default** in 16.0 when it became `proxy`. ## What it is for, and what it is not **Good uses:** header rewriting, A/B rewrites, programmatic redirects based on request properties, optimistic auth redirects, CORS preflight. **Not for:** slow data fetching, session management, or authorization as a security boundary. `fetch` options `cache`, `next.revalidate` and `next.tags` have **no effect** here. For plain redirects, `redirects` in `next.config.ts` is cheaper and statically analysable. It is designed to be deployable to a CDN edge separately from your app, so **do not rely on shared modules or globals** between proxy and application code. Pass information forward via headers, cookies, rewrites, redirects, or the URL. ## Matchers — the biggest footgun in the file **Without a `matcher`, proxy runs on every request** — including `_next/static`, `_next/image`, and everything in `public/`. Auth logic there blocks your own CSS and images, and it is a favourite way to make a site mysteriously unstyled in production. ```js export const config = { matcher: [ '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', ], } ``` Rules: - Matcher values must be **statically analysable constants**. A variable is silently ignored. - `source` must start with `/`; supports named params (`:path`), modifiers (`*` zero-or-more, `?` zero-or-one, `+` one-or-more), parenthesised regex, and is anchored to the start of the path (path-to-regexp syntax). - Object form adds `has`, `missing` (header/query/cookie conditions) and `locale: false`. - **`_next/data` still runs proxy even when your negative matcher excludes it.** Deliberate: it stops you protecting a page and forgetting its data route. ## Execution order 1. `headers` from `next.config.js` 2. `redirects` from `next.config.js` 3. **Proxy** 4. `beforeFiles` rewrites 5. Filesystem routes (`public/`, `_next/static/`, `pages/`, `app/`) 6. `afterFiles` rewrites 7. Dynamic routes 8. `fallback` rewrites > **Server Actions are not separate routes in this chain.** They are POSTs to > the route they are used on, so a matcher that excludes a path also skips proxy > for that path's actions — and moving an action during a refactor can silently > remove its coverage. Authenticate inside every action; see > [server-actions.md](server-actions.md). ## The API surface ```ts export function proxy(request: NextRequest, event: NextFetchEvent) { … } export const proxy: NextProxy = (request, event) => { … } // shorthand type ``` - `request.cookies` — `get`, `getAll`, `set`, `delete`, `has`, `clear`. - `response.cookies` — `get`, `getAll`, `set`, `delete`. - `event.waitUntil(promise)` — keep the invocation alive for background work (logging, analytics) after the response is sent. - Return a `Response`/`NextResponse` directly to answer without hitting a route. **Setting request headers is not the same as setting response headers:** ```ts const requestHeaders = new Headers(request.headers) requestHeaders.set('x-user-id', id) const response = NextResponse.next({ request: { headers: requestHeaders } }) // upstream response.headers.set('x-served-by', 'proxy') // to the client ``` `NextResponse.next({ headers })` — without the nested `request` — sends them to the *client* instead. Keep headers small; oversized ones produce 431s at some backends. **RSC requests:** Next.js strips internal Flight headers (`rsc`, `next-router-state-tree`, `next-router-prefetch`) from `request.headers` so you cannot accidentally treat an RSC request differently from its HTML twin. `NextResponse.rewrite()` propagates what is needed automatically; a hand-rolled `fetch()` rewrite does not, and needs `skipProxyUrlNormalize` plus manual header forwarding. Advanced flags: `skipTrailingSlashRedirect` and `skipProxyUrlNormalize` in `next.config.js`, both from 13.1. ### Unit testing (experimental, since 15.1) ```js import { unstable_doesProxyMatch, isRewrite, getRewrittenUrl } from 'next/experimental/testing/server' expect(unstable_doesProxyMatch({ config, nextConfig, url: '/test' })).toEqual(false) const response = await proxy(new NextRequest('https://example.com/docs')) expect(isRewrite(response)).toEqual(true) ``` Testing the matcher is worth doing: a matcher bug is invisible locally and catastrophic in production. ## Node.js vs Edge runtime **The Node.js runtime is the default and, in 16, the runtime for proxy.** The `runtime` segment config option **is not available in proxy files and throws if set**. Edge remains available for route segments via `export const runtime = 'edge'`, but that is the deprecated path — reach for it only with a specific reason. ### What the Edge runtime does not have - **No native Node.js APIs.** No filesystem, no `node:crypto` (only WebCrypto), no `node:net`, no `node:child_process`. Most database drivers that open TCP sockets are therefore out; HTTP-based clients are in. - **No `require()`.** ES Modules only. `node_modules` work only if they ship ESM and avoid native APIs — which is where a working dependency suddenly fails after a runtime switch. - **No dynamic code evaluation:** `eval`, `new Function(string)`, `WebAssembly.compile`, `WebAssembly.instantiate` are disabled. A transitive dependency containing an unreachable `eval` still trips this; relax it narrowly with `unstable_allowDynamic` globs. - **No ISR.** - `revalidate` segment values are unavailable under `runtime = 'edge'`. ### What it does have `fetch`, `Request`/`Response`/`Headers`, `FormData`, `File`/`Blob`, `WebSocket`, `URL`/`URLPattern`/`URLSearchParams`, the stream APIs, `TextEncoder`/`Decoder`, `atob`/`btoa`, `crypto`/`SubtleCrypto`/`CryptoKey`, `structuredClone`, `Intl`, `WebAssembly` (instantiation aside), timers, `process.env`, and a polyfilled `AsyncLocalStorage`. **The practical rule:** the Edge runtime is a *reduced* JavaScript environment, not a faster Node. Since Node is now the default everywhere and has no API gaps, choosing Edge should be a deliberate answer to a latency or placement question — and if the answer is "I want to run at the edge", the honest comparison is a Workers-native stack. See `cloudflare-ops` and `hono-ops`, and [deployment.md](deployment.md) for running Next.js itself there. ## Self-hosting note Proxy works with zero configuration under `next start`. It is **not** supported in a static export, since it needs the incoming request. If you need full Node APIs in request interception, the usual move is to do the work in a layout as a Server Component (read `headers()`, `redirect()`) rather than in proxy at all, or express it as a `redirects`/`rewrites` rule with header/cookie/query matching. -
routing-and-rendering.md 7.3 KB
# Routing and Rendering — conventions, async params, advanced routes Verified against Next.js 16.3.3 docs, 2026-08-25. ## File conventions | File | Role | Notes | |---|---|---| | `layout.tsx` | Shared shell for a segment and everything below | Persists across navigation; does **not** re-render on route change within it. The root layout must render `<html>` and `<body>` | | `page.tsx` | The route's UI; makes the segment publicly routable | A directory without one is not a route | | `loading.tsx` | Suspense fallback for the whole segment | Sugar for wrapping the segment in `<Suspense>` | | `error.tsx` | Route-level error boundary | Client Component by definition; does not catch errors in the *same* segment's layout | | `not-found.tsx` | Rendered by `notFound()` and for unmatched URLs | | | `default.tsx` | Fallback for a parallel route slot | **Required for every slot since 16 — builds fail without it** | | `route.ts` | Route Handler (HTTP verbs) | Cannot coexist with `page.tsx` in the same segment | | `template.tsx` | Like a layout but remounts per navigation | Reach for it only when you need the remount | | `proxy.ts` | Request interception, project root | See [proxy-and-runtimes.md](proxy-and-runtimes.md) | Folders in parentheses `(group)` organise without adding a URL segment; folders with a leading underscore `_private` are excluded from routing entirely. ## Async params — the 15.0 breaking change `params`, `searchParams`, `cookies()`, `headers()` and `draftMode()` are all **Promises**. Synchronous access was removed in 16; there is no fallback path. ```tsx // ✅ export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params } ``` Metadata image routes changed with them: `params` is async there too, and the `id` from `generateImageMetadata` arrives as `Promise<string>`. `npx @next/codemod@canary upgrade latest` handles the mechanical half of this migration. `audit-app-router.py`'s `sync-params-prop` and `sync-request-api` rules catch what the codemod misses (hand-written types, helper functions). ### Typed route helpers Next.js generates `PageProps<'/route'>` and `LayoutProps<'/route'>` from your actual route tree, so the param shape is checked rather than asserted: ```tsx export default async function PostPage(props: PageProps<'/[lang]/posts/[slug]'>) { const { slug } = await props.params } ``` Types are generated during `next dev`/`next build`, or on demand with `next typegen` — worth wiring into CI so a renamed segment fails type-check rather than at runtime. ### Root params Params defined *above* the root layout (the classic `[lang]`) are effectively global, and prop-drilling them was the standing complaint. Since 16.3: ```tsx import { lang } from 'next/root-params' export default async function Page() { const language = await lang() } ``` Root params work inside `use cache` scopes, and **only the ones a cached function actually reads join its cache key**. Currently Server Components only — not route handlers or Server Actions. (The older `unstable_rootParams()` was removed in 16.) ## Static generation of dynamic routes ```tsx export async function generateStaticParams() { const posts = await getPosts() return posts.map((p) => ({ slug: p.slug })) } ``` Listed URLs are prerendered at build time. What happens to the rest depends on the model: - **Previous model:** unlisted params render on demand and are cached per the route's `revalidate` (classic ISR). - **Cache Components:** an unlisted URL is served the **App Shell** instantly on first visit, then upgraded in the background with its now-known params and cached for the next visitor. You get the loading shell *and* the eventual prerender, which the old model made you choose between. `generateStaticParams` is often the highest-leverage change available on a slow route: prerendering the top 200 URLs converts the common case from a render into a file read. ## Parallel and intercepting routes **Parallel routes** (`@slot`) render several independent subtrees into one layout, each with its own loading and error states: ``` app/dashboard/ ├── layout.tsx // receives { children, analytics, team } ├── page.tsx ├── @analytics/page.tsx ├── @analytics/default.tsx ← required ├── @team/page.tsx └── @team/default.tsx ← required ``` `default.tsx` is what a slot renders when the current URL does not match it — on a hard navigation, or a soft one that never activated the slot. **Since 16 every slot needs one and the build fails otherwise.** Returning `null` or calling `notFound()` reproduces the old implicit behaviour. Note also that parallel slots are rendered as separate chunks *whether or not they are displayed*, so an expensive slot costs even when hidden. **Intercepting routes** (`(.)`, `(..)`, `(...)`) render a route in the current layout's context on a soft navigation while a hard load gets the real page — the photo-modal pattern. Combined with a parallel slot, the modal is a slot and the full page is the fallback. ## Metadata ```tsx export const metadata: Metadata = { title: 'Static title' } export async function generateMetadata(props: PageProps<'/blog/[slug]'>) { const { slug } = await props.params return { title: (await getPost(slug)).title } } ``` Under Cache Components, uncached fetches and runtime reads inside `generateMetadata` and `generateViewport` surface the same insights and errors they would in the page — metadata is not a loophole in the rendering rules. File conventions (`opengraph-image.tsx`, `icon.tsx`, `sitemap.ts`, `robots.ts`) cover the asset side. ## Rendering strategy, read off the build `next build` prints the rendering mode of every route. Read it. It is the cheapest available answer to "is this page static?", and it is the fastest way to notice that one added `cookies()` call turned a static marketing page dynamic. Under Cache Components, the framework goes further: it *requires* every route to produce a static shell, and surfaces a validation insight naming the route and the fix (cache the access, move it behind a `<Suspense>` boundary, or opt the route out) when one cannot. ## Route Handlers `route.ts` exports HTTP verb functions. Use them for GETs, webhooks, third-party callers, custom headers/status, and streaming or binary responses — see [server-actions.md](server-actions.md) for the full action-vs-handler split. With Cache Components enabled, **`GET` route handlers follow the same prerendering model as pages**, which surprises people who expect a handler to be dynamic by default. ## Notable removals and behaviour changes in 16 | Removed / changed | Replacement | |---|---| | Sync `params`/`searchParams`/`cookies()`/`headers()`/`draftMode()` | `await` them | | `experimental.ppr`, `export const experimental_ppr` | `cacheComponents` | | `experimental.dynamicIO` | renamed `cacheComponents` | | `unstable_rootParams()` | `next/root-params` (16.3) | | `next lint` | Biome or ESLint directly; `next build` no longer lints | | `serverRuntimeConfig`, `publicRuntimeConfig` | environment variables | | AMP support | — (fully removed) | | Automatic `scroll-behavior: smooth` | `data-scroll-behavior="smooth"` on the document | | Parallel slots without `default.js` | now a build failure | | Turbopack | now the default bundler (`next build --webpack` to opt out) | | Node.js 18 | Node.js 20.9+, TypeScript 5.1+ | -
server-actions.md 7.4 KB
# Server Actions — endpoint model, security, and when not to use one Verified against Next.js 16.3.3 docs, 2026-08-25. ## What an action is on the wire `'use server'` tells the compiler to replace the function's implementation in client bundles with a **reference**: an action ID plus a dispatcher that POSTs back to the route the action is used on. The implementation never ships. The *endpoint* does. So an action is a public HTTP endpoint with a function-call ergonomics wrapper. Anyone who can send that POST invokes it — no form, no page render, no client code of yours involved. Treat every action as an untrusted entry point, in the same way you would treat an unauthenticated `POST /api/...`. ## The response model When an action triggers an immediate revalidation, Next.js runs the action and re-renders the current route **inside one HTTP request**. The response carries both the return value (consumed by `useActionState` or the awaited promise) and a fresh RSC payload the client commits as a seeded navigation. No follow-up fetch is needed. A re-render is included when the action: - calls `updateTag()` or `revalidatePath()` - calls `refresh()` - mutates cookies via `cookies()` (set/delete re-renders automatically) - calls `redirect()` — which throws a control-flow exception, so **nothing after it runs**; put revalidation calls before it `revalidateTag(tag, profile)` is the deliberate exception: it marks the tag for background refresh and does **not** re-render in the action response. The change appears on a later read. ### Sequential dispatch The client dispatches actions **one at a time**. Three rapid triggers run in series. `Promise.all` over Server Actions does not parallelise anything — do the parallel work inside a single action, fetch in parallel from a Server Component, or use a Route Handler. This is a property of the client dispatcher; server-side an action is an ordinary async function. ## Security ### What the framework gives you | Protection | Detail | |---|---| | CSRF check | `Origin` compared against `Host` / `X-Forwarded-Host`; mismatches rejected. Proxy/CDN domains need `serverActions.allowedOrigins` | | Body size limit | 1MB default; raise with `serverActions.bodySizeLimit` | | Encrypted action IDs | Action references are encrypted at build time | | Dead code elimination | Unused Server Functions are stripped, so they have no endpoint at all | | Closure encryption | Variables captured by an inline action are encrypted before reaching the client | A floor, not a substitute. None of it knows who the caller is. ### What you must do ```ts 'use server' import { auth } from '@/lib/auth' import { db } from '@/lib/db' // ❌ the whole record, including its id, comes from the client export async function completeItemUnsafe(item: Item) { await db.item.update({ where: { id: item.id }, data: { completed: true } }) } // ✅ take a reference; derive identity from the session; look up by ownership export async function completeItem(itemId: string) { const session = await auth() if (!session?.user) return const item = await db.item.findFirst({ where: { id: itemId, ownerId: session.user.id } }) if (!item) return await db.item.update({ where: { id: item.id }, data: { completed: true } }) } ``` 1. **Authenticate and authorize inside the action.** Rendering a form only for admins is not a security boundary. Read auth from cookies/headers — never accept a token as a parameter. 2. **A `proxy.ts` matcher is not a gate either.** Actions are POSTs to the route they live on, so an excluded path skips proxy for its actions too, and moving an action to another route can silently remove coverage. 3. **Schema validation checks shape, not entitlement.** A well-formed `Item` object can still name a row the caller does not own. Zod is necessary and insufficient. 4. **Constrain return values.** Returns are serialized to the client — shape them to what the UI renders, not raw database records. 5. **Escalate for destructive operations.** Elevated session checks or re-authentication for deletes, and a loud failure when a check is missed. With the experimental `authInterrupts` flag you can `throw unauthorized()` / `forbidden()` from `next/navigation` and let Next.js render `unauthorized.tsx` / `forbidden.tsx`. Centralising these guarantees in a Data Access Layer — one module that owns auth, validation and projection — is what stops the checks drifting apart across twenty action files. ## Choosing the cache update | API | Semantics | Reach for it when | |---|---|---| | `updateTag(tag)` | Expires the tag; the next read (including this response's re-render) waits for fresh data. **Actions only** | Read-your-own-writes — forms, settings, anything the user expects to see immediately | | `revalidateTag(tag, profile)` | Stale-while-revalidate against a `cacheLife` profile; no immediate re-render | Shared content that tolerates eventual consistency | | `revalidatePath(path)` | Invalidate by URL | One route affected, tagging is overkill | | `refresh()` | Refetch the current route's RSC payload; cache untouched. **Actions only** | The view depends on uncached state the action just changed (a notification count, a live metric) | None of these throw, so an action can call one and still return a value. `redirect()` does throw. ## Action or Route Handler? **Server Action** when: a mutation driven by your own UI, `<form action>` or a client transition, and you want the re-render in the same round trip. **Route Handler** when any of these are true: - it is a GET, or must be cacheable - a third party calls it (webhooks, mobile clients, cron) - you need custom status codes, headers, or a streaming/binary response - you need genuine client-side parallelism (see sequential dispatch above) - it is a public API surface with a versioned contract `rest-ops` and `api-design-ops` own the API-design half of that decision. ## Progressive enhancement `<form action={serverAction}>` submits without JavaScript. Wire state and pending UI with `useActionState` and `useFormStatus` rather than a bespoke `useState` dance, so the no-JS path keeps working. React owns those hooks — `react-ops` for their semantics. ## Configuration ```js // next.config.js module.exports = { experimental: { serverActions: { allowedOrigins: ['my-proxy.com', '*.my-proxy.com'], bodySizeLimit: '2mb', }, }, } ``` Widening `allowedOrigins` weakens the CSRF check — list exact hosts. ## Deployment: the skew failure Action IDs are build artifacts. New deployments generate new IDs — **Next.js rotates them at most every 14 days even when the source is unchanged** — so a client still running the previous build can invoke an ID the server no longer knows. It surfaces as [`Failed to find Server Action`](https://nextjs.org/docs/messages/failed-to-find-server-action). Mitigations, in order: - **`NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`** — a stable, base64-encoded AES key (16/24/32 bytes; Next.js generates 32) shared across every instance. Without it, an action encrypted by one instance cannot be decrypted by another, and the error appears even without a deploy. - **`deploymentId`** — enables version-skew detection so a mismatched client gets a hard navigation instead of a broken one. - **Rolling deployments** rather than abrupt cutovers when users are likely mid-mutation. - **Surface the error as a retry path** in the UI. A refresh recovers the user; a stack trace does not. See [deployment.md](deployment.md) for the multi-instance picture. -
server-client-boundary.md 6.8 KB
# The Server/Client Boundary What `'use client'` actually does, what crosses, and why the serialization error is the real error. React's own component model is `react-ops`; this is the Next.js boundary as an operational surface. Verified against Next.js 16.3.3 docs (React 19), 2026-08-25. ## `'use client'` marks a module graph entry point Not a component. Not a folder. Once a file carries the directive, **every module it imports, and every component it renders directly, is in the client bundle** — whether or not those files repeat the directive. The exception is the whole design space: components passed *as props* (including `children`) are not part of that module graph. They are rendered on the server and handed over as already-rendered output. ``` Server Component tree └── <Modal> ............................ 'use client' — bundled, hydrated └── {children} = <Cart /> ........ Server Component — rendered on the server, arrives as RSC payload, never bundled ``` Consequences to design around: - **Push the directive to the leaf.** A `'use client'` layout drags its whole subtree into the browser. Mark the search box, not the nav that contains it. - **A "client" component still renders on the server first.** It is prerendered to HTML, then hydrated. Code that touches `window` at module scope or in the render body breaks; put it in an effect or behind a mount check. - **Directives can be stripped by bundlers.** Library authors must configure the build to preserve them (the tsup/esbuild banner pattern) or every consumer needs a wrapper. ## What crosses, and what the error really means Props from server to client are serialized into the **RSC payload**. The supported set is React's, not JSON's: | Crosses | Does not cross | |---|---| | primitives, plain objects, arrays | class instances | | `Date`, `Map`, `Set`, TypedArrays, `ArrayBuffer` | functions (except Server Actions) | | React elements / `children` | Symbols, `WeakMap`/`WeakSet`, `URL` instances | | Promises (read on the client with `use()`) | anything with methods you intend to call | | Server Actions (as a reference) | — | > **The serialization error is a design signal, not an obstacle.** The instinct > — "make the parent a Client Component so the prop stops crossing" — usually > makes the bundle worse and the data exposure larger. The right read is: this > object is not the shape the UI needs. An ORM row is a class instance carrying > every column; a `Decimal`, a `Buffer`, a Mongoose document all fail for the > same reason. Project it down to the fields the component renders. That is > simultaneously the serialization fix, the bundle fix and the data-exposure fix. ## Interleaving patterns ### The `children` slot ```tsx 'use client' export default function Modal({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(false) return open ? <div className="modal">{children}</div> : null } ``` ```tsx // Server Component parent — <Cart /> stays on the server export default function Page() { return <Modal><Cart /></Modal> } ``` Any prop position works, not just `children`; `header`, `footer`, `sidebar` slots behave identically. This is the escape hatch for "I need client state around server-rendered content". ### Context providers React context does not exist in Server Components. Wrap `{children}`: ```tsx 'use client' export const ThemeContext = createContext({}) export default function ThemeProvider({ children }: { children: React.ReactNode }) { return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider> } ``` Render it **as deep in the tree as it can go**. A provider wrapping `<html>` does not make the tree client-side (children pass through), but it does move the boundary earlier than necessary and constrains what can be optimised above it. ### Streaming a promise instead of awaiting it ```tsx // Server: start the work, don't block the shell export default function Page() { const dataPromise = getData() // no await return <Suspense fallback={<Skeleton />}><Client dataPromise={dataPromise} /></Suspense> } ``` ```tsx 'use client' import { use } from 'react' export function Client({ dataPromise }: { dataPromise: Promise<Data> }) { const data = use(dataPromise) // suspends here, not in the parent return <List data={data} /> } ``` ### Third-party components without the directive A package using `useState` but shipping no `'use client'` errors when rendered directly from a Server Component. Re-export it through your own client module: ```tsx 'use client' import { Carousel } from 'acme-carousel' export default Carousel ``` ## Environment poisoning — the silent one Modules are shared between both graphs, so server code can be imported into the client by accident. Next.js inlines only `NEXT_PUBLIC_*` variables into the browser bundle; **every other `process.env.X` becomes an empty string** — no error, no warning, just a request that fails at runtime with an empty credential. ```ts import 'server-only' // turn the accident into a build error export async function getData() { return fetch(url, { headers: { authorization: process.env.API_KEY! } }) } ``` `server-only` and its counterpart `client-only` are marker packages; Next.js handles the imports internally and installing them is optional (do it if your lint rules object to extraneous deps). Next.js also ships type declarations for both, which matters under `noUncheckedSideEffectImports`. `audit-app-router.py` flags both halves of this: `client-secret-env` for the non-public variable in a client module, `client-imports-server-only` for the import that will fail the build. ## Where things actually run | Phase | What happens | |---|---| | Server render | Server Components → RSC payload (rendered output, Client Component placeholders + JS references, props passed across) | | Server render | Client Components prerendered to HTML using that payload | | First load | HTML paints a non-interactive preview → RSC payload reconciles the trees → JS hydrates | | Later navigations | RSC payload is prefetched and cached; Client Components render entirely on the client, no server HTML | Rendering is split per route segment — layouts, pages, and **every parallel route slot, displayed or not**. ## Checklist for a boundary that stays cheap - [ ] Is the directive on the smallest interactive leaf, not a layout? - [ ] Does anything crossing the boundary carry more data than the UI renders? - [ ] Do server-only modules import `server-only`? - [ ] Are providers wrapping `{children}` rather than the document? - [ ] Does any client module reference a non-`NEXT_PUBLIC_` env var? - [ ] Does a "make this a Client Component" fix exist only to silence a serialization error? Reshape the data instead. -
testing.md 7.2 KB
# Testing a Next.js App Verified against Next.js 16.3.3 docs, 2026-08-30. `testing-ops` owns test strategy in general; `playwright-ops` and `cypress-ops` own their runners. This file owns what is *different* about testing the App Router — which is mostly a story about what you cannot unit-test and what to do instead. ## The uncomfortable fact first **Async Server Components are not supported by the React testing libraries.** An `async function Page()` returning a promise of an element is not something React Testing Library or the jsdom-based runners can render. The official recommendation is end-to-end tests for anything that is an async Server Component. This is not a temporary tooling gap to route around with a clever mock — it falls out of the RSC model. The practical consequence is a **shifted test pyramid**: | Layer | What it covers here | |---|---| | **Unit** | Pure logic, data-access functions, validators, and Client Components. The bulk of your assertions still live here — but you get there by *extracting* logic out of components | | **Integration** | Route Handlers, Server Actions called as functions, `proxy.ts` matchers | | **E2E** | Anything that is an async Server Component, streaming behaviour, navigation, and the whole rendered route | The design response: **keep components thin and put the logic somewhere testable.** A Server Component that awaits a well-tested data function and maps it to JSX barely needs a test of its own; a Server Component with branching business logic inside it is untestable by construction, and that is the signal to extract. ## Unit-testable surfaces ### Data access The highest-value tests in most App Router codebases. A Data Access Layer — one module owning auth, validation, and projection — is unit-testable in full and is also where the security guarantees live (see [server-actions.md](server-actions.md)). ```ts // lib/items.ts — plain async functions, no framework export async function completeItemFor(userId: string, itemId: string) { … } ``` Test that, then let the action be a three-line wrapper. ### Server Actions An action is an exported async function. Import it and call it directly — runtime imports like `cookies()` are the thing to mock, and *that* is an argument for reading them in the caller rather than inside the action. Test the security posture explicitly, because it is the part that has no UI: - unauthenticated caller → rejected - authenticated but non-owning caller → rejected (this is the one people skip) - malformed input → rejected before any write - happy path → exactly one write, and the returned shape carries no extra columns Name these tests for the adversary, not the function: `deletes-another-users-item.test.ts` tells the next reader what evil is being blocked. ### Route Handlers Ordinary request-in/response-out functions. Construct a `Request`, call `GET`, assert on the `Response` — status, headers, and body. No framework harness needed. ### `proxy.ts` Since 15.1, `next/experimental/testing/server` provides helpers, and the matcher is worth testing precisely because a matcher bug is invisible locally and catastrophic in production: ```js import { unstable_doesProxyMatch, isRewrite, getRewrittenUrl } from 'next/experimental/testing/server' expect(unstable_doesProxyMatch({ config, nextConfig, url: '/_next/static/chunk.js' })).toEqual(false) const response = await proxy(new NextRequest('https://example.com/docs')) expect(isRewrite(response)).toEqual(true) expect(getRewrittenUrl(response)).toEqual('https://other-domain.com/docs') ``` Assert the **exclusions**, not just the inclusions. "Does it run on `/dashboard`" is the easy half; "does it stay off `_next/static`" is the half that breaks the site. ## End-to-end E2E carries more weight here than in a client-rendered app, so it is worth building deliberately rather than as an afterthought. ### Test the production build ```bash next build && next start ``` `next dev` never caches pages, so **every caching behaviour is unobservable in dev**. An E2E suite pointed at the dev server cannot catch a caching regression at all — it is testing a different program. ### Guard instant navigation Instant navigation is easy to lose by accident: a `cookies()` read added to a shared header, a `<Suspense>` boundary moved during a refactor. The `@next/playwright` `instant()` helper asserts what is visible **without waiting for the network**, so the test fails whatever the cause: ```ts import { expect, test } from '@playwright/test' import { instant } from '@next/playwright' test('product title is available immediately', async ({ page }) => { await page.goto('/products/shoes') await instant(page, async () => { await page.click('a[href="/products/hats"]') await expect(page.locator('h1')).toContainText('Baseball Cap') await expect(page.getByText('Checking inventory...')).toBeVisible() }) await expect(page.getByText('12 in stock')).toBeVisible() }) ``` This is the closest thing the framework offers to a regression gate on rendering architecture, and it is cheap. The DevTools Navigation Inspector and Instant Insights panel are the interactive equivalents while developing. ### What else deserves E2E coverage - **Streaming**: that the fallback appears *before* the slow content, not with it — the assertion that catches a buffering proxy (see [deployment.md](deployment.md)). - **Form actions without JavaScript.** Progressive enhancement is a claim; a `javaScriptEnabled: false` context is the test that makes it true. - **The bot path.** Crawlers get a full dynamic render rather than the shell, so a shell built from build-time-only data can 500 for Googlebot while working for every human. A test with a crawler user agent is the only cheap way to see it. ## Client Components Ordinary React testing — `react-ops` and `testing-ops` own the patterns. Two Next-specific notes: - Mock `next/navigation` (`useRouter`, `usePathname`, `useSearchParams`), not `next/router` — that is the Pages Router module and mocking it in an App Router test silently does nothing. - A Client Component still renders on the server first. If a test passes but production shows a hydration error, the cause is usually `window` or `Date`/`Math.random` read during render rather than in an effect. ## What not to test - **Framework behaviour.** That `revalidateTag` invalidates a tag is Vercel's test, not yours. Test *your* invalidation choice — that a mutation makes the user's own change visible immediately, which is a `updateTag`-vs-`revalidateTag` decision with a real user-facing difference. - **Cache timings.** Asserting a 15-minute revalidate produces a slow, flaky suite. Assert the *tag* is applied and that the mutation path calls the right invalidation API. ## Wiring - **Vitest or Jest** for unit/integration. Async Server Components remain out of scope in both. - **Playwright or Cypress** for E2E; `@next/playwright` only exists for Playwright, and `instant()` is a real reason to prefer it here. - **Gate on the production build in CI**, and run `python scripts/audit-app-router.py --min-severity error .` alongside the suite — it catches the class of defect that has no natural test (a client module reading a secret env var, a `force-static` page silently emptying `cookies()`). -
upgrading.md 7.9 KB
# Upgrading — 14 → 15 → 16, and Pages → App Verified against Next.js 16.3.3 docs, 2026-08-30. `migrate-ops` owns generic migration method (dependency audit, codemods, rollback strategy). This file owns the Next.js-specific deltas: what actually changes in behaviour, in what order to take it, and which changes are silent. > **The defining property of these upgrades: the dangerous changes do not > error.** A 14 → 15 jump compiles and boots, then serves uncached data where it > used to serve cached. Type errors you will find; semantic inversions you have > to go looking for. ## Take one major at a time `14 → 15 → 16`, each landing green before the next. The two majors change different things — 15 inverts data defaults, 16 changes the rendering and invalidation model — and a combined jump makes it impossible to attribute a regression to either. ```bash npx @next/codemod@canary upgrade latest # mechanical rewrites npx @next/codemod@canary middleware-to-proxy . ``` The codemod is necessary and not sufficient. It rewrites call sites it can see; it does not rewrite your hand-written types, your helper functions, or your assumptions. ## 14 → 15: the data defaults invert | Was | Is | |---|---| | `fetch()` **cached** by default | `fetch()` **uncached** by default | | `cookies()`, `headers()`, `draftMode()` sync | async — must be `await`ed | | `params`, `searchParams` plain objects | `Promise` — must be `await`ed | | GET Route Handlers cached by default | uncached by default | | Client Router Cache reused page segments | `staleTimes.dynamic` defaults to 0 | The async APIs are loud: TypeScript and the runtime both complain. **The `fetch` default is silent**, and it is the one that matters. Every `fetch` in the codebase that relied on the implicit cache is now hitting origin on every request. The visible symptoms are a cost spike and latency, not an error. The migration is mechanical but must be deliberate — annotate, do not blanket: ```ts await fetch(url, { cache: 'force-cache' }) // was the old default await fetch(url, { next: { revalidate: 3600 } }) // usually what you actually wanted ``` Resist `export const fetchCache = 'default-cache'` at the top of every route to "restore" 14's behaviour. It reinstates the exact implicit caching the upgrade exists to remove, and you will be debugging it again in a year. **Check before you finish:** run `next build` and compare the per-route rendering modes against the 14 build. Routes that silently went from static to dynamic are the regression. ## 15 → 16: rendering, invalidation, and tooling Removals and renames (full table in [routing-and-rendering.md](routing-and-rendering.md)): - `middleware.ts` → **`proxy.ts`**, Node.js runtime, `runtime` config now throws - `revalidateTag(tag)` → `revalidateTag(tag, profile)`; `updateTag`/`refresh` added - `experimental.ppr` / `experimental_ppr` / `experimental.dynamicIO` → `cacheComponents` - Parallel route slots **require** `default.js` — a hard build failure - Sync `params`/`cookies()`/`headers()` support removed entirely (15 deprecated, 16 deleted) - `next lint` gone; `next build` no longer lints — wire ESLint or Biome yourself, or lint silently stops running in CI - `serverRuntimeConfig`/`publicRuntimeConfig` gone → env vars - Node 20.9+, TypeScript 5.1+, Turbopack default Behaviour changes that are silent, and therefore the ones to check: - `images.minimumCacheTTL` 60s → **14400s** (4h). Images update far less often. - `images.qualities` → `[75]`, with `quality` **coerced to the nearest listed value**. A `quality={90}` prop silently becomes 75 until you list it. - `images.maximumRedirects` unlimited → 3. - Local-IP image optimization blocked unless `dangerouslyAllowLocalIP`. - Prefetching rewritten — more requests, smaller total transfer. ### Turbopack is now the default bundler `next build --webpack` opts out. Custom webpack config, unusual loaders, or a Babel-dependent toolchain are the cases that need it. Note 16 *auto-enables* Babel when it finds a Babel config rather than erroring — convenient, and a quiet build-time cost if that config is a leftover. ## Adopting Cache Components `cacheComponents: true` is **not** a 16 upgrade step. It is a separate project, taken deliberately after 16 is stable. Enabling it changes the default for the whole app from "cache what we can infer" to "cache nothing unless told", and turns PPR on as the rendering model. Expect the framework to start *failing the build* on things it previously tolerated: uncached reads with no `<Suspense>` boundary, request APIs inside cached scopes, short-lived caches nested in unlabelled ones. That is the feature — it is refusing to let a route exist that cannot produce a static shell. Order that works: 1. Land 16 without the flag. Stabilise. 2. Turn the flag on in a branch and read the dev overlay's insights; each names a route and a fix. 3. Work outside-in: give every uncached/runtime read either a `<Suspense>` boundary or a `'use cache'` scope with an explicit `cacheLife`. 4. Replace `unstable_cache`/`fetch`-cache usage **only where it should not survive a deploy** — those two stores persist across builds and `'use cache'` deliberately does not. 5. Re-check `revalidateTag` call sites: under the new model, `updateTag` is what gives read-your-own-writes. Use the official *migrating to Cache Components* guide for the route-by-route mechanics; the semantics you are converting *to* are in [cache-components.md](cache-components.md). ## Pages Router → App Router The two routers **coexist** in one app, which is the whole migration strategy: move route by route, verify, repeat. Do not attempt a big-bang rewrite. | Pages | App | |---|---| | `getServerSideProps` | `async` Server Component, or request-time read | | `getStaticProps` + `revalidate` | `'use cache'` + `cacheLife`, or `fetch` revalidate | | `getStaticPaths` | `generateStaticParams` | | `_app` / `_document` | root `layout.tsx` | | `next/head` | `metadata` / `generateMetadata` | | `pages/api/*` | `route.ts` handlers (or Server Actions for UI mutations) | | `useRouter` from `next/router` | `next/navigation`: `useRouter`, `usePathname`, `useSearchParams` | Sequencing that avoids the usual pain: 1. **Leaf routes first.** Something self-contained with real traffic, so you learn the boundary on a page you can actually observe. 2. **Shared layout last.** Moving `_app` early forces every child to move with it. 3. **Expect the boundary tax up front.** Component libraries without `'use client'`, context providers, and anything touching `window` all need handling before the first route lands — see [server-client-boundary.md](server-client-boundary.md). 4. **Keep `pages/api` until the consumers move.** Route Handlers are a like-for-like replacement, so there is no reason to do it in the same change. ## Rollback Every one of these is revertible **only if the cache state is**. A deploy that changes the caching model changes what is in the shared store; rolling the code back does not roll the store back, and `use cache` entries are keyed by build id so they vanish anyway. - Keep the previous build deployable, and change one variable per deploy. - Have a cache-invalidation lever ready (`revalidatePath('/', 'layout')`, a handler flush, or a `deploymentId` bump) so "roll back the code" can be followed by "and clear what it wrote". - Watch origin request volume, not just error rate. The 15 upgrade's failure mode is a working site that costs five times more. ## Verification checklist - [ ] `next build` rendering modes diffed against the previous major - [ ] Origin/database request volume compared before and after - [ ] `python scripts/audit-app-router.py .` clean at `--min-severity error` - [ ] Every `revalidateTag` call site reviewed for the two-argument form - [ ] Lint still actually runs in CI (`next lint` no longer exists) - [ ] Image quality/TTL props still produce what you expect - [ ] `proxy.ts` matcher still excludes static assets after the rename
-
-
scripts
-
audit-app-router.py 27.1 KB
#!/usr/bin/env python3 """Static hazard scan of a Next.js App Router tree: boundary, caching, runtime. The App Router's expensive mistakes are the quiet ones. A sync `cookies()` still type-checks, a `'use client'` file reading a non-public env var compiles fine and ships an empty string, a `'use cache'` scope that reads request data can pass `next build` and only fail under `next start`, and a Server Action with no auth check is a public POST endpoint that looks like a function call. This scans for the mechanically-detectable members of that family so an agent finds them before production does, rather than re-deriving the same twelve greps every task. It is a linter, not a compiler: it reads text, so it reports where to look, not proof of a bug. `review` findings are prompts for judgement by design. Usage: audit-app-router.py [OPTIONS] <PATH> Input: PATH = a Next.js project root (app/ or src/app/ auto-detected) or any directory/file to scan. No stdin. Version: rules are gated on the project's Next.js major, read from node_modules/next or package.json. Six of them describe breakages that did not exist before 15/16, so running them unversioned against an older app would flag correct code. Override with --assume-major when the version cannot be read (scanning a bare subdirectory). Output: stdout = findings, one TSV row per finding (severity<TAB>rule<TAB>file:line<TAB>detail), or a --json envelope. Data only. Stderr: the verdict line, notices, errors. Exit: 0 no findings at or above --min-severity, 2 usage, 3 path not found, 10 findings present (the domain signal - not an error) Examples: audit-app-router.py . audit-app-router.py --min-severity error src/app audit-app-router.py --json . | jq '.data[] | select(.severity=="error")' audit-app-router.py --rules sync-request-api,client-secret-env . audit-app-router.py --assume-major 15 ./legacy-app """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path EX_OK = 0 EX_USAGE = 2 EX_NOTFOUND = 3 EX_FINDINGS = 10 SCHEMA = "claude-mods.nextjs-ops.app-audit/v1" SEVERITIES = ("review", "warn", "error") # ascending SEV_RANK = {s: i for i, s in enumerate(SEVERITIES)} CODE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} SKIP_DIRS = {"node_modules", ".next", ".git", "dist", "build", "out", ".turbo", "coverage"} # Every rule the scanner knows: (severity, min_major, why). Kept as data so # --help, --rules validation, version gating and the JSON meta all read from one # place. # # min_major is the Next.js major at which the rule first became true. This skill's # central instruction is "establish the version before answering a caching # question", and a linter that ignores its own advice is worse than no linter: run # unversioned against a Next.js 14 app, six of these rules would flag correct code. # 0 means the rule is version-independent. RULES: dict[str, tuple[str, int, str]] = { "sync-request-api": ("error", 15, "cookies()/headers()/draftMode() are async since Next.js 15 - must be awaited"), "sync-params-prop": ("error", 15, "params/searchParams are Promises since Next.js 15 - type as Promise and await"), "request-api-in-use-cache": ("error", 15, "request APIs inside a 'use cache' scope throw next-request-in-use-cache"), "client-secret-env": ("error", 0, "non-NEXT_PUBLIC_ env var in a 'use client' module is replaced with an empty string"), "client-imports-server-only": ("error", 0, "'use client' module importing server-only fails the build"), "parallel-route-no-default": ("error", 16, "parallel route slot without default.js fails the build since Next.js 16"), "nondeterministic-in-use-cache": ("warn", 15, "Math.random/Date.now/randomUUID in a cached scope freeze one value for all users"), "middleware-file": ("warn", 16, "middleware.ts is deprecated since Next.js 16 - rename to proxy.ts"), "edge-runtime-segment": ("warn", 16, "runtime='edge' is the deprecated path; Node.js is the default and has no API gaps"), "revalidate-tag-single-arg": ("warn", 16, "revalidateTag(tag) single-arg form is deprecated - pass a cacheLife profile or use updateTag"), "images-domains-config": ("warn", 0, "images.domains is deprecated - use images.remotePatterns"), "force-static-with-request-api": ("error", 13, "dynamic='force-static' makes cookies()/headers() return EMPTY values, silently"), "client-component-route-file": ("warn", 13, "'use client' on a layout/page/template pulls its whole subtree into the browser bundle"), "proxy-without-matcher": ("warn", 13, "proxy/middleware with no matcher runs on every request, static assets included"), "blanket-force-dynamic": ("warn", 13, "dynamic='force-dynamic' opts the route out of all static rendering and caching"), "action-without-auth": ("review", 0, "'use server' module with no visible auth check - actions are public POST endpoints"), } # Route-segment files whose subtree a 'use client' directive would drag into the # client bundle. `route.ts` is excluded: it is server-only by construction. ROUTE_FILE_STEMS = {"layout", "page", "template", "default"} # The major this skill documents; used when the project's version can't be read. ASSUMED_MAJOR = 16 # --- patterns --------------------------------------------------------------- DIRECTIVE_RE = re.compile(r"""^\s*['"](use (?:client|server|cache(?::\s*\w+)?))['"]\s*;?\s*$""") # An awaited/thenned call is fine; a bare one is the bug. Also tolerate # `cookies` passed as a value (no parens), which this deliberately misses. SYNC_REQ_RE = re.compile(r"(?<![.\w])(cookies|headers|draftMode)\s*\(\s*\)") AWAITED_RE = re.compile(r"(await|\.then|use)\s*\(?\s*$") PARAMS_SYNC_TYPE_RE = re.compile(r"\b(params|searchParams)\s*:\s*\{(?P<body>[^{}]*)") # A *type* body names types (`id: string`, `slug: PostId`); a *value* body names # values (`{ slug }`, `{ slug: post.slug }`). Without this split, an ordinary # object literal that happens to carry a `params` key - `track('view', { params: # { slug } })` - reads as a sync params prop and the rule flags correct code. TYPE_MEMBER_RE = re.compile(r"\w+\??\s*:\s*[A-Za-z_$][\w$]*(?:<[^>]*>)?(?:\[\])?\s*(?:[;,}]|$)") # Destructuring a Promise is the bug (`const { id } = params`). Aliasing one is # the documented fix (`const slugPromise = params`), so only the braced form # counts. Anchored with `$` because splitlines() has already eaten the newline - # the original character class ended in `\n` and therefore never matched at EOL. PARAMS_SYNC_DESTRUCTURE_RE = re.compile( r"\{[^{}]*\}\s*=\s*(?:props\.)?(params|searchParams)\s*(?:[;,)]|$)" ) REQ_API_CALL_RE = re.compile(r"(?<![.\w])(cookies|headers)\s*\(\s*\)") NONDET_RE = re.compile(r"(Math\.random\s*\(|Date\.now\s*\(|new Date\s*\(\s*\)|crypto\.randomUUID\s*\()") CLIENT_ENV_RE = re.compile(r"process\.env\.([A-Za-z_][A-Za-z0-9_]*)") SERVER_ONLY_IMPORT_RE = re.compile( r"""(?:from\s*|require\s*\(\s*|import\s+)['"]server-only['"]""" ) EDGE_RUNTIME_RE = re.compile(r"""export\s+const\s+runtime\s*=\s*['"]edge['"]""") SEGMENT_DYNAMIC_RE = re.compile(r"""export\s+const\s+dynamic\s*=\s*['"](force-static|force-dynamic)['"]""") MATCHER_RE = re.compile(r"\bmatcher\s*:") IMAGES_DOMAINS_RE = re.compile(r"^\s*domains\s*:\s*\[") EXPORT_ASYNC_RE = re.compile(r"export\s+(?:default\s+)?async\s+function\s") # Single-argument revalidateTag: one balanced-free argument, no comma at depth 0. REVALIDATE_TAG_RE = re.compile(r"revalidateTag\s*\(([^()]*)\)") # Deliberately broad. This rule is `review`, so a miss (silence on an unguarded # action) costs more than a false hit, and real projects name their Data Access # Layer whatever they like - `requireOwner`, `canEdit`, `assertTenant`. Matching # the *shape* of a guard call (require*/assert*/ensure*/can*/check*) alongside the # usual vocabulary is what stops the rule flagging a correctly-guarded module. AUTH_TOKEN_RE = re.compile( r"\b(" r"auth\w*|session|getUser|currentUser|getToken|" r"unauthorized|forbidden|permission\w*|polic(?:y|ies)|guard\w*|" r"authoriz\w*|authoris\w*|abac|rbac|acl|ability|tenant|owner\w*|" r"(?:require|assert|ensure|verify|check|can|must|with)[A-Z]\w*" r")\b", ) def strip_comment(line: str) -> str: """Crude single-line comment strip. Good enough to keep `// revalidateTag(x)` out of the findings; deliberately does not parse block comments or strings.""" stripped = line.lstrip() if stripped.startswith(("//", "*", "/*")): return "" idx = line.find("//") return line[:idx] if idx >= 0 else line def file_directives(lines: list[str]) -> set[str]: """Module-level directives: those appearing before any real statement.""" found: set[str] = set() for raw in lines[:20]: s = raw.strip() if not s or s.startswith(("//", "/*", "*")): continue m = DIRECTIVE_RE.match(raw) if m: found.add(m.group(1)) continue break # first non-directive statement ends the prologue return found def cache_scopes(lines: list[str]) -> list[tuple[int, int, bool]]: """Line ranges (start, end, is_file_level) covered by a `use cache` directive. The documented way to use request data with caching is to read it in an UNCACHED function and pass the value into a cached one - and those two functions routinely live in the same module. A whole-file heuristic therefore flags the exact pattern the docs prescribe, so the scope has to end where the enclosing function does. Brace counting, not parsing: braces inside strings, template literals or regex literals will skew the depth. That trades a rare missed scope end for never dragging an unrelated function into a cached scope, which is the direction a linter should err in. """ scopes: list[tuple[int, int, bool]] = [] depth = 0 open_scope: tuple[int, int] | None = None # (start_line, depth_at_directive) for i, raw in enumerate(lines, 1): line = strip_comment(raw) m = DIRECTIVE_RE.match(raw) if m and m.group(1).startswith("use cache"): if depth == 0: # File-level: covers everything from here to the end. scopes.append((i, len(lines), True)) elif open_scope is None: open_scope = (i, depth) depth += line.count("{") - line.count("}") if open_scope is not None and depth < open_scope[1]: scopes.append((open_scope[0], i, False)) open_scope = None if open_scope is not None: # unbalanced file; be generous scopes.append((open_scope[0], len(lines), False)) return scopes def walk_tree(root: Path) -> tuple[list[Path], list[Path]]: """One pruned traversal returning (code files, parallel-route slot dirs). os.walk with in-place dirnames pruning, not rglob: rglob descends into node_modules and filters afterwards, so it enumerates every dependency file in the project before discarding it. On a real Next.js install that is tens to hundreds of thousands of stat calls for nothing, and rglob('@*') for the slot check would pay it a second time. Both results come from this one walk. """ if root.is_file(): return ([root] if root.suffix in CODE_SUFFIXES else []), [] code: list[Path] = [] slots: list[Path] = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) here = Path(dirpath) for d in dirnames: # `@types` is the DefinitelyTyped convention, not a route slot. if d.startswith("@") and d != "@types": slots.append(here / d) for name in sorted(filenames): if Path(name).suffix in CODE_SUFFIXES: code.append(here / name) return code, slots def rel(path: Path, root: Path) -> str: try: return path.relative_to(root).as_posix() except ValueError: return path.as_posix() def detect_next_major(root: Path) -> tuple[int | None, str]: """Return (major, source). Prefer the *resolved* install over the manifest range: `"next": "^15.0.0"` in package.json can be satisfied by 15.5, and only node_modules knows which. Falls back to the declared range, then to nothing. """ if root.is_file(): root = root.parent for base in (root, root.parent): installed = base / "node_modules" / "next" / "package.json" if installed.is_file(): try: ver = str(json.loads(installed.read_text(encoding="utf-8")).get("version", "")) except (OSError, json.JSONDecodeError): ver = "" m = re.match(r"\s*(\d+)", ver) if m: return int(m.group(1)), f"node_modules ({ver})" manifest = base / "package.json" if manifest.is_file(): try: pkg = json.loads(manifest.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue for field in ("dependencies", "devDependencies", "peerDependencies"): spec = (pkg.get(field) or {}).get("next") if not isinstance(spec, str): continue # ^16.3.3, ~15.2, >=15 <17, 16.x - take the first number present. m = re.search(r"(\d+)", spec) if m: return int(m.group(1)), f"package.json ({spec})" # "latest"/"canary"/a git URL carry no major we can trust. return None, f"package.json ({spec}) - no major to parse" return None, "not found" def scan_file(path: Path, root: Path, in_app_tree: bool) -> list[dict]: try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] lines = text.splitlines() where = rel(path, root) directives = file_directives(lines) is_client = "use client" in directives is_server_module = "use server" in directives scopes = cache_scopes(lines) imports_next_headers = "next/headers" in text findings: list[dict] = [] def add(rule: str, line: int, detail: str, severity: str | None = None): findings.append({ "severity": severity or RULES[rule][0], "rule": rule, "file": where, "line": line, "detail": detail, }) for n, raw in enumerate(lines, 1): line = strip_comment(raw) if not line.strip(): continue if imports_next_headers: for m in SYNC_REQ_RE.finditer(line): before = line[: m.start()] if AWAITED_RE.search(before) or "await" in before: continue add("sync-request-api", n, f"`{m.group(1)}()` is not awaited - it returns a Promise since Next.js 15") if in_app_tree: m = PARAMS_SYNC_TYPE_RE.search(line) if m and "Promise" not in line and TYPE_MEMBER_RE.search(m.group("body")): add("sync-params-prop", n, f"`{m.group(1)}` typed as a plain object - it is `Promise<...>` since Next.js 15") m = PARAMS_SYNC_DESTRUCTURE_RE.search(line) if m and "await" not in line: add("sync-params-prop", n, f"`{m.group(1)}` destructured without `await`") enclosing = next(((s, e, fl) for s, e, fl in scopes if s < n <= e), None) if enclosing is not None: start, _end, at_file_level = enclosing m = REQ_API_CALL_RE.search(line) if m: scope = ("file-level 'use cache'" if at_file_level else f"the 'use cache' scope opened at line {start}") add("request-api-in-use-cache", n, f"`{m.group(1)}()` inside {scope} - read it outside and pass the value as an argument") m = NONDET_RE.search(line) if m: add("nondeterministic-in-use-cache", n, f"`{m.group(1).strip()}` in a cached scope - one value is frozen into the cache entry for every user") if is_client: for m in CLIENT_ENV_RE.finditer(line): name = m.group(1) if name.startswith("NEXT_PUBLIC_") or name in ("NODE_ENV",): continue add("client-secret-env", n, f"`process.env.{name}` in a 'use client' module - inlined as an empty string, never the value") if SERVER_ONLY_IMPORT_RE.search(line): add("client-imports-server-only", n, "'use client' module imports `server-only` - this is a build error by design") if EDGE_RUNTIME_RE.search(line): add("edge-runtime-segment", n, "`runtime = 'edge'` is the deprecated path; the Node.js runtime is the default and has no API gaps") m = SEGMENT_DYNAMIC_RE.search(line) if m and m.group(1) == "force-dynamic": add("blanket-force-dynamic", n, "`dynamic = 'force-dynamic'` opts the whole route out of static rendering and forces every " "fetch to no-store - often reached for to 'fix' a stale page when the real cause is one " "uncached read; confirm it is deliberate") for m in REVALIDATE_TAG_RE.finditer(line): arg = m.group(1).strip() if arg and "," not in arg: add("revalidate-tag-single-arg", n, "single-argument `revalidateTag()` is deprecated - pass a cacheLife profile " "(e.g. 'max') for SWR, or `updateTag()` in an action for read-your-writes") if path.name.startswith("next.config") and IMAGES_DOMAINS_RE.search(line): add("images-domains-config", n, "`images.domains` is deprecated - use `images.remotePatterns`") # Comments are stripped first: a `// TODO: check auth` note is precisely the # case this rule exists to flag, so it must not satisfy the token search. code_only = "\n".join(strip_comment(line) for line in lines) # force-static does not error on a request API - it makes cookies(), headers() # and useSearchParams() return EMPTY values. The page renders, the user looks # logged out, and nothing anywhere says why. Worth an error on its own. fs = SEGMENT_DYNAMIC_RE.search(code_only) if fs and fs.group(1) == "force-static": m = REQ_API_CALL_RE.search(code_only) or re.search(r"useSearchParams\s*\(", code_only) if m: add("force-static-with-request-api", 1, "`dynamic = 'force-static'` in a module that reads request data - cookies(), headers() " "and useSearchParams() are forced to return empty values here, with no error") if is_client and in_app_tree and path.stem in ROUTE_FILE_STEMS: add("client-component-route-file", 1, f"'use client' on `{path.name}` - the directive is a module-graph entry point, so this " "segment's imports all ship to the browser; move it to the interactive leaf instead") if is_server_module and EXPORT_ASYNC_RE.search(code_only) and not AUTH_TOKEN_RE.search(code_only): add("action-without-auth", 1, "'use server' module exports actions but names no auth/session check - " "every action is a public POST endpoint reachable without the UI") return findings def scan_project(root: Path, major: int) -> tuple[list[dict], dict]: """Scan a project root or subtree. Returns (findings, meta-ish counters). `major` gates rules that only became true at a later Next.js version.""" app_dirs = [d for d in (root / "app", root / "src" / "app") if d.is_dir()] findings: list[dict] = [] files = 0 code_files, slot_dirs = walk_tree(root) for path in code_files: files += 1 in_app_tree = any( str(path).startswith(str(d)) for d in app_dirs ) or ("/app/" in path.as_posix() or path.as_posix().endswith("/app")) findings.extend(scan_file(path, root, in_app_tree)) if root.is_dir(): # Project-level structural checks: file conventions, not file contents. for base in (root, root / "src"): for name in ("middleware.ts", "middleware.js", "middleware.tsx"): mw = base / name if mw.is_file(): findings.append({ "severity": RULES["middleware-file"][0], "rule": "middleware-file", "file": rel(mw, root), "line": 1, "detail": "middleware.ts is deprecated since Next.js 16 - rename to proxy.ts " "and rename the export to `proxy` (npx @next/codemod@canary middleware-to-proxy .)", }) # A request interceptor with no matcher runs on EVERY request - # _next/static, _next/image and public/ included - so auth logic there # blocks the app's own CSS. Checked at the file level because the # absence of a config export is what matters, not any single line. for name in ("proxy.ts", "proxy.js", "proxy.tsx", "middleware.ts", "middleware.js", "middleware.tsx"): f = base / name if not f.is_file(): continue try: body = f.read_text(encoding="utf-8", errors="replace") except OSError: continue if not MATCHER_RE.search("\n".join(strip_comment(l) for l in body.splitlines())): findings.append({ "severity": RULES["proxy-without-matcher"][0], "rule": "proxy-without-matcher", "file": rel(f, root), "line": 1, "detail": f"{name} exports no `config.matcher` - it will run on every request, " "including _next/static, _next/image and public/ assets", }) for slot in slot_dirs: if not any((slot / f"default{ext}").is_file() for ext in (".tsx", ".ts", ".jsx", ".js")): findings.append({ "severity": RULES["parallel-route-no-default"][0], "rule": "parallel-route-no-default", "file": rel(slot, root), "line": 1, "detail": f"parallel route slot `{slot.name}` has no default.js - " "builds fail since Next.js 16; add one returning null or calling notFound()", }) # Version gate, applied once at the end so a rule never has to know it. findings = [f for f in findings if major >= RULES[f["rule"]][1]] return findings, {"files_scanned": files} def main(argv: list[str]) -> int: p = argparse.ArgumentParser( prog="audit-app-router.py", description="Static hazard scan of a Next.js App Router tree (boundary, caching, runtime).", epilog=( "Rules (severity, the Next.js major the rule first applies to, why):\n" + "".join(f" {sev:<6} next>={mm:<3} {name:<30} {why}\n" for name, (sev, mm, why) in sorted(RULES.items(), key=lambda kv: (-SEV_RANK[kv[1][0]], kv[0]))) + "\nExamples:\n" " audit-app-router.py .\n" " audit-app-router.py --min-severity error src/app\n" " audit-app-router.py --json . | jq '.data[] | select(.severity==\"error\")'\n" " audit-app-router.py --rules sync-request-api,client-secret-env .\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument("path", help="project root, app directory, or a single file") p.add_argument("--min-severity", choices=SEVERITIES, default="review", help="drop findings below this severity (default: review = report all)") p.add_argument("--rules", default="", help="comma-separated rule names to run (default: all)") p.add_argument("--assume-major", type=int, default=None, metavar="N", help="treat the project as Next.js N.x instead of detecting it " f"(detection falls back to {ASSUMED_MAJOR} when the version cannot be read)") p.add_argument("--limit", type=int, default=500, help="maximum findings to emit (default: 500)") p.add_argument("--json", action="store_true", help="emit a JSON envelope") p.add_argument("-q", "--quiet", action="store_true", help="suppress the stderr verdict line") try: args = p.parse_args(argv) except SystemExit as exc: return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK) if args.limit < 1: print("error: --limit must be >= 1", file=sys.stderr) return EX_USAGE selected = set() if args.rules.strip(): for name in args.rules.split(","): name = name.strip() if not name: continue if name not in RULES: print(f"error: unknown rule {name!r} (see --help for the rule list)", file=sys.stderr) return EX_USAGE selected.add(name) root = Path(args.path) if not root.exists(): print(f"error: path not found: {root}", file=sys.stderr) return EX_NOTFOUND root = root.resolve() if args.assume_major is not None: if args.assume_major < 1: print("error: --assume-major must be >= 1", file=sys.stderr) return EX_USAGE major, source = args.assume_major, "--assume-major" else: detected, source = detect_next_major(root) major = detected if detected is not None else ASSUMED_MAJOR if detected is None: source = f"{source}; assuming {ASSUMED_MAJOR}.x" findings, meta = scan_project(root, major) floor = SEV_RANK[args.min_severity] findings = [f for f in findings if SEV_RANK[f["severity"]] >= floor] if selected: findings = [f for f in findings if f["rule"] in selected] findings.sort(key=lambda f: (-SEV_RANK[f["severity"]], f["file"], f["line"], f["rule"])) truncated = len(findings) > args.limit findings = findings[: args.limit] if args.json: print(json.dumps({ "data": findings, "meta": {"count": len(findings), "files_scanned": meta["files_scanned"], "min_severity": args.min_severity, "truncated": truncated, "next_major": major, "next_major_source": source, "schema": SCHEMA}, }, indent=2)) else: for f in findings: print(f"{f['severity']}\t{f['rule']}\t{f['file']}:{f['line']}\t{f['detail']}") if not args.quiet: by_sev = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITIES} note = " (truncated)" if truncated else "" print( f"audit-app-router: {len(findings)} finding(s){note} in {meta['files_scanned']} file(s) " f"- {by_sev['error']} error, {by_sev['warn']} warn, {by_sev['review']} review " f"[next {major}.x via {source}]", file=sys.stderr, ) return EX_FINDINGS if findings else EX_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:])) -
check-nextjs-facts.py 10.7 KB
#!/usr/bin/env python3 """Staleness verifier for nextjs-ops: the documented Next.js major line and the named ecosystem packages must stay real, stated, and current. nextjs-ops encodes caching and rendering semantics that are major-version bound. Next.js 15 made `fetch` uncached by default and the request APIs async; Next.js 16 added Cache Components (`use cache`), renamed middleware to `proxy.ts`, and changed `revalidateTag`'s signature. A skill that still says "Next.js 16" after 17 ships would hand an agent a confidently-wrong caching table, which is worse than no table at all (SKILL-RESOURCE-PROTOCOL.md §7). Two modes: --offline (default, safe for PR CI): structural consistency, no network. * assets/nextjs-facts.json parses, carries the schema + an as_of date * every catalogued fact's prose_token is still named in the skill prose (SKILL.md + references/*.md + assets/next.config.template.ts) * SKILL.md still carries a dated "Verified against Next.js <major>.x (<YYYY-MM-DD>)" currency note whose major matches the catalog --live (scheduled freshness job, never a PR gate): does each package still resolve on npm, and has next's major moved off the documented line? Usage: check-nextjs-facts.py [--offline | --live] [--catalog FILE] [--skill DIR] [--json] [--timeout S] [-q] Input: argv flags only (no stdin). Output: stdout = findings (TSV rows, or a --json envelope). Data only. Stderr: the verdict line, notices, errors. Exit: 0 ok, 2 usage, 3 catalog/skill missing, 4 catalog unparseable, 7 npm unreachable (live, advisory - never a real failure), 10 drift found (offline: fact no longer named / currency note gone or mismatched; live: package gone from npm or a major drifted) Examples: check-nextjs-facts.py --offline # PR CI: facts <-> prose consistency check-nextjs-facts.py --live # weekly: is next still 16.x on npm? check-nextjs-facts.py --offline --json | jq '.data[]' """ from __future__ import annotations import argparse import json import re import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path EX_OK = 0 EX_USAGE = 2 EX_NOTFOUND = 3 EX_UNPARSEABLE = 4 EX_UNAVAILABLE = 7 EX_DRIFT = 10 SCHEMA = "claude-mods.nextjs-ops.facts/v1" FACT_KEYS = ( "next", "react", "codemod", "playwright_helper", "opennext_cloudflare", "server_only", ) HERE = Path(__file__).resolve().parent DEFAULT_CATALOG = HERE.parent / "assets" / "nextjs-facts.json" DEFAULT_SKILL = HERE.parent NPM_REGISTRY = "https://registry.npmjs.org" # The currency note is the single most load-bearing line in the skill: it tells a # reader which semantics the caching tables describe. Its shape is asserted, not # just its presence. CURRENCY_RE = re.compile( r"Verified against Next\.js (\d+)\.x \((\d{4}-\d{2}-\d{2})\)", re.IGNORECASE ) AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def load_catalog(path: Path) -> dict: if not path.is_file(): print(f"error: facts catalog not found: {path}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) try: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict) or data.get("schema") != SCHEMA: raise ValueError(f"schema must be {SCHEMA!r}") if not AS_OF_RE.match(str(data.get("as_of", ""))): raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}") for key in FACT_KEYS: fact = data.get(key) if not isinstance(fact, dict) or "prose_token" not in fact or "package" not in fact: raise ValueError(f"fact {key!r} missing prose_token/package") return data except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr) raise SystemExit(EX_UNPARSEABLE) def read_corpus(skill_dir: Path) -> tuple[str, str]: """Return (skill_md_text, all_prose_text) across SKILL.md + references/*.md + assets/next.config.template.ts (the template names the version-gated config flags, so it drifts with the majors too).""" doc = skill_dir / "SKILL.md" if not doc.is_file(): print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) skill_md = doc.read_text(encoding="utf-8", errors="replace") parts = [skill_md] ref_dir = skill_dir / "references" if ref_dir.is_dir(): for ref in sorted(ref_dir.glob("*.md")): parts.append(ref.read_text(encoding="utf-8", errors="replace")) template = skill_dir / "assets" / "next.config.template.ts" if template.is_file(): parts.append(template.read_text(encoding="utf-8", errors="replace")) return skill_md, "\n".join(parts) def check_offline(catalog: dict, skill_dir: Path) -> list[dict]: skill_md, corpus = read_corpus(skill_dir) lower = corpus.lower() findings: list[dict] = [] documented = str(catalog["next"].get("documented_major")) m = CURRENCY_RE.search(skill_md) if not m: findings.append({ "check": "currency-note", "status": "drift", "detail": "no dated 'Verified against Next.js <major>.x (YYYY-MM-DD)' note in SKILL.md", }) elif m.group(1) != documented: findings.append({ "check": "currency-note", "status": "drift", "detail": f"currency note says {m.group(1)}.x but catalog documents {documented}.x", }) else: findings.append({ "check": "currency-note", "status": "ok", "detail": f"currency note {m.group(1)}.x dated {m.group(2)}", }) for key in FACT_KEYS: token = str(catalog[key]["prose_token"]) if token.lower() in lower: findings.append({"check": f"fact:{key}", "status": "ok", "detail": f"{token!r} named in skill prose"}) else: findings.append({"check": f"fact:{key}", "status": "drift", "detail": f"prose_token {token!r} no longer named in skill prose"}) return findings def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]: """Return (status, version-or-detail). status in ok|notfound|unavailable.""" url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='@')}/latest" req = urllib.request.Request( url, headers={"User-Agent": "claude-mods-nextjs-ops-check/1", "Accept": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: payload = resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as exc: if exc.code in (404, 410): return "notfound", str(exc.code) return "unavailable", str(exc.code) except (urllib.error.URLError, TimeoutError, OSError): return "unavailable", "" try: return "ok", json.loads(payload).get("version", "") except json.JSONDecodeError: return "unavailable", "bad-json" def check_live(catalog: dict, timeout: float) -> list[dict]: findings: list[dict] = [] for key in FACT_KEYS: fact = catalog[key] pkg = str(fact["package"]) status, ver = _npm_latest(pkg, timeout) if status == "notfound": findings.append({"check": f"npm:{key}", "status": "drift", "detail": f"{pkg} gone from npm - renamed/removed, review skill"}) continue if status != "ok": findings.append({"check": f"npm:{key}", "status": "unavailable", "detail": f"npm registry unreachable for {pkg}"}) continue documented = fact.get("documented_major") m = re.match(r"\s*(\d+)", ver) latest_major = m.group(1) if m else "" if documented is not None and latest_major and latest_major != str(documented): findings.append({ "check": f"npm:{key}", "status": "drift", "detail": f"{pkg}@{ver} major {latest_major} != documented {documented}.x - review skill", }) else: findings.append({"check": f"npm:{key}", "status": "ok", "detail": f"latest {ver}"}) return findings def main(argv: list[str]) -> int: p = argparse.ArgumentParser( prog="check-nextjs-facts.py", description="Verify nextjs-ops' Next.js major + package facts stay stated (offline) and current (live).", epilog=( "Examples:\n" " check-nextjs-facts.py --offline\n" " check-nextjs-facts.py --live\n" " check-nextjs-facts.py --offline --json | jq '.data[]'\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) mode = p.add_mutually_exclusive_group() mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)") mode.add_argument("--live", action="store_true", help="probe npm for package/major drift") p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON") p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/ + assets/)") p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)") p.add_argument("--json", action="store_true", help="emit a JSON envelope") p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary") try: args = p.parse_args(argv) except SystemExit as exc: return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK) catalog = load_catalog(Path(args.catalog)) live = args.live findings = (check_live(catalog, args.timeout) if live else check_offline(catalog, Path(args.skill))) drift = [f for f in findings if f["status"] == "drift"] unavailable = [f for f in findings if f["status"] == "unavailable"] if args.json: print(json.dumps({ "data": findings, "meta": {"count": len(findings), "mode": "live" if live else "offline", "as_of": catalog.get("as_of"), "schema": SCHEMA}, }, indent=2)) else: for f in findings: print(f"{f['check']}\t{f['status']}\t{f['detail']}") if not args.quiet: verdict = "DRIFT" if drift else "UNAVAILABLE" if unavailable else "OK" print(f"check-nextjs-facts: {verdict} ({len(findings)} checks, {len(drift)} drift)", file=sys.stderr) if drift: return EX_DRIFT if unavailable: return EX_UNAVAILABLE return EX_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
-
-
tests
-
fixtures
-
app-sample
-
app
-
clean
-
page.tsx 520 B · in bundle
-
-
dashboard
-
@analytics
-
page.tsx 150 B · in bundle
-
-
-
legacy
-
route.ts 153 B
// FIXTURE bait: the deprecated edge runtime opt-in. export const runtime = 'edge' export async function GET() { return Response.json({ ok: true }) }
-
-
lib
-
cached.ts 256 B
'use cache' // FIXTURE baits: request API and non-determinism inside a file-level cache scope. import { headers } from 'next/headers' export async function getBanner() { const h = await headers() return { host: h.get('host'), nonce: Math.random() } }
-
-
marketing
-
layout.tsx 327 B · in bundle
-
page.tsx 349 B · in bundle
-
-
ui
-
widget.tsx 203 B · in bundle
-
-
actions.ts 267 B
'use server' // FIXTURE baits: no auth check anywhere, and the deprecated single-arg revalidateTag. import { revalidateTag } from 'next/cache' export async function deleteEverything(id: string) { await db.items.delete({ where: { id } }) revalidateTag('items') } -
page.tsx 267 B · in bundle
-
-
middleware.ts 185 B
// FIXTURE bait: the file convention itself is the finding (deprecated in 16). import { NextResponse } from 'next/server' export function middleware() { return NextResponse.next() } -
next.config.ts 312 B
// FIXTURE (nextjs-ops tests) - deliberately wrong. Not a template; see // assets/next.config.template.ts for the correct starter. import type { NextConfig } from 'next' const nextConfig: NextConfig = { cacheComponents: true, images: { domains: ['images.example.com'], }, } export default nextConfig -
package.json 258 B
{ "//": "FIXTURE (nextjs-ops tests). Pins the major so audit-app-router.py's version gate is deterministic; it is never installed.", "name": "nextjs-ops-fixture", "private": true, "dependencies": { "next": "^16.3.3", "react": "^19.2.0" } }
-
-
clean-app
-
app
-
dashboard
-
@stats
-
default.tsx 130 B · in bundle
-
page.tsx 56 B · in bundle
-
-
-
editor
-
page.tsx 230 B · in bundle
-
-
items
-
actions.ts 444 B
'use server' // A correctly guarded action - but the guard comes from a project DAL with a // house name, not from a package literally called "auth". import { requireOwner } from '@/lib/guard' import { revalidateTag } from 'next/cache' export async function completeItem(itemId: string) { const item = await requireOwner(itemId) await db.item.update({ where: { id: item.id }, data: { completed: true } }) revalidateTag('items', 'max') }
-
-
legal
-
page.tsx 264 B · in bundle
-
-
lib
-
data.ts 478 B
// The documented "read runtime data outside, pass the value in" pattern: // an uncached reader and a cached consumer living in the SAME module. import { cookies } from 'next/headers' import { cacheLife } from 'next/cache' export async function getSessionId() { const store = await cookies() return store.get('session')?.value ?? null } export async function getDashboard(sessionId: string | null) { 'use cache' cacheLife('hours') return fetchDashboard(sessionId) }
-
-
shop
-
[slug]
-
layout.tsx 511 B · in bundle
-
page.tsx 447 B · in bundle
-
-
layout.tsx 290 B · in bundle
-
-
ui
-
editor.tsx 293 B · in bundle
-
panel.tsx 384 B · in bundle
-
-
-
next.config.ts 211 B
import type { NextConfig } from 'next' const nextConfig: NextConfig = { cacheComponents: true, images: { remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }] }, } export default nextConfig -
package.json 360 B
{ "//": "FIXTURE (nextjs-ops tests): a CORRECT Next.js 16 app. Every construct here is either the documented-correct form or a near-miss that a naive regex would flag. It must produce ZERO findings - that is the whole point of the file.", "name": "nextjs-ops-clean-fixture", "private": true, "dependencies": { "next": "^16.3.3", "react": "^19.2.0" } } -
proxy.ts 394 B
// Correct: a matcher that excludes static assets, so the interceptor does not // run on _next/static, _next/image or public/ files. import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function proxy(request: NextRequest) { return NextResponse.next() } export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], }
-
-
-
run.sh 12.2 KB
#!/usr/bin/env bash # Offline self-test for the nextjs-ops skill — structure, frontmatter, and the # script contracts (SKILL-RESOURCE-PROTOCOL §2, §5, §7, §10). # # Usage: tests/run.sh # Input: none (self-contained; no network, no node/next install required) # Output: TAP-ish progress on stderr; final PASS/FAIL line. # Exit: 0 all pass (or skipped on unsupported platform), 1 any failure. # # Examples: # tests/run.sh # bash skills/nextjs-ops/tests/run.sh set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fail=0 pass=0 note() { printf ' %s %s\n' "$1" "$2" >&2; } ok() { pass=$((pass+1)); note "ok " "$1"; } bad() { fail=$((fail+1)); note "FAIL" "$1"; } # Resolve a *working* python (python3, else python). The bare `command -v` is not # enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero. PY="" for cand in python3 python; do if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then PY="$cand"; break fi done if [ -z "$PY" ]; then echo "SKIP: no working python interpreter on this platform" >&2 exit 0 fi # 1. Required directories exist for d in scripts references assets tests; do [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/" done # 2. SKILL.md frontmatter house rules # CONTRACT: these assertions require the frontmatter to keep `name: nextjs-ops`, # `license: MIT`, and `metadata.author: claude-mods` — a trim/cleanup lane that # edits the frontmatter must keep them or update these assertions in the same # commit (see SKILL-CREATION-PROTOCOL Step 5). skill="$here/SKILL.md" if [ -f "$skill" ]; then ok "SKILL.md present" grep -q '^name: nextjs-ops$' "$skill" && ok "name matches directory" || bad "name != nextjs-ops" grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT" grep -q '^ author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author" else bad "SKILL.md missing" fi # 3. Every reference on disk is cited from SKILL.md (no dead weight) for ref in "$here"/references/*.md; do base="references/$(basename "$ref")" grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base" done # 4. Every SKILL.md-cited bundled resource exists on disk for res in assets/next.config.template.ts assets/nextjs-facts.json \ scripts/audit-app-router.py scripts/check-nextjs-facts.py \ tests/fixtures/app-sample/package.json \ tests/fixtures/clean-app/package.json; do [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res" done # Helper: assert an exact exit code. ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$? [ "$got" = "$want" ] && ok "$lbl (exit $got)" || bad "$lbl (want $want got $got)"; } # 5. audit-app-router.py — script contract + behaviour on the bundled fixture audit="$here/scripts/audit-app-router.py" fixture="$here/tests/fixtures/app-sample" clean="$fixture/app/clean/page.tsx" cleanapp="$here/tests/fixtures/clean-app" "$PY" -m py_compile "$audit" && ok "audit: py_compile clean" || bad "audit: py_compile failed" "$PY" "$audit" --help 2>/dev/null | grep -q "Examples:" && ok "audit: --help has Examples" || bad "audit: --help missing Examples" ec 0 "audit: --help exits 0" "$PY" "$audit" --help ec 2 "audit: bad flag -> 2" "$PY" "$audit" --bogus "$fixture" ec 2 "audit: unknown rule -> 2" "$PY" "$audit" --rules no-such-rule "$fixture" ec 2 "audit: --limit 0 -> 2" "$PY" "$audit" --limit 0 "$fixture" ec 3 "audit: missing path -> 3" "$PY" "$audit" /no/such/path ec 10 "audit: fixture has findings -> 10" "$PY" "$audit" "$fixture" # Behaviour: app-sample/ is a minefield carrying at least one bait per rule, so # every rule must fire. (sync-params-prop has two: a sync type annotation and a # sync destructure, which are separate code paths.) # CONTRACT: tests/fixtures/app-sample/ and these numbers move together — adding # a rule to audit-app-router.py means adding its bait to the fixture. out="$("$PY" "$audit" "$fixture" 2>/dev/null)" [ "$(printf '%s\n' "$out" | grep -c .)" = "17" ] && ok "audit: 17 findings" || bad "audit: finding count != 17" for rule in sync-request-api sync-params-prop request-api-in-use-cache \ client-secret-env client-imports-server-only parallel-route-no-default \ nondeterministic-in-use-cache middleware-file edge-runtime-segment \ revalidate-tag-single-arg images-domains-config action-without-auth \ force-static-with-request-api client-component-route-file \ proxy-without-matcher blanket-force-dynamic; do n="$(printf '%s\n' "$out" | grep -cF " $rule ")" [ "$n" -ge 1 ] && ok "audit: rule fires: $rule" || bad "audit: rule $rule never fired" done # The negative controls are the load-bearing half: a linter that flags correct # code is worse than no linter. app/clean/page.tsx is a single correct page; # clean-app/ is the adversarial version - a whole app built from constructs a # naive regex misreads (the documented read-outside-pass-in pattern with the # cached and uncached functions in ONE module, params aliased rather than # destructured, an unrelated object literal carrying a `params:` key, a guard # named requireOwner rather than auth). It must stay at zero findings. # CONTRACT: a rule added to audit-app-router.py needs its near-miss added here, # not only its bait in app-sample/. Every construct in clean-app/ is a false # positive this suite has already caught once. ec 0 "audit: negative control is clean" "$PY" "$audit" "$clean" ec 0 "audit: adversarial clean app is clean" "$PY" "$audit" "$cleanapp" scanned="$("$PY" "$audit" --json "$cleanapp" 2>/dev/null | "$PY" -c 'import json,sys; print(json.load(sys.stdin)["meta"]["files_scanned"])')" [ "${scanned:-0}" -ge 7 ] \ && ok "audit: clean app actually scanned ($scanned files)" \ || bad "audit: clean app scanned $scanned files - a zero finding count would be meaningless" # The destructure path shipped dead once: its character class ended in a literal # newline, but splitlines() has already removed the newline, so # `const { id } = params` at end-of-line never matched and the branch never ran. # Assert the exact bait, not just a rule-level count that the type path satisfies. printf '%s\n' "$out" | grep -q "app/page.tsx:5 .*destructured" \ && ok "audit: sync-params destructure path fires (regression: was dead code)" \ || bad "audit: sync-params destructure path is dead again" # Severity floor actually filters, and results stay sorted worst-first. err_out="$("$PY" "$audit" --min-severity error "$fixture" 2>/dev/null)" [ "$(printf '%s\n' "$err_out" | grep -c .)" = "8" ] && ok "audit: 8 errors" || bad "audit: error count != 8" printf '%s\n' "$err_out" | grep -qv '^error ' && bad "audit: --min-severity error leaked lower severities" \ || ok "audit: --min-severity error filters cleanly" [ "$(printf '%s\n' "$out" | head -1 | cut -f1)" = "error" ] && ok "audit: sorted worst-first" || bad "audit: not sorted worst-first" # --rules selects, and --limit truncates while flagging it in the envelope. rule_out="$("$PY" "$audit" --rules middleware-file "$fixture" 2>/dev/null)" [ "$(printf '%s\n' "$rule_out" | grep -c .)" = "1" ] && ok "audit: --rules narrows to one" || bad "audit: --rules did not narrow" # Version gating: the fixture pins next ^16.3.3, so all 16 rules apply. Against # an older major the eight rules describing 15/16-only breakages must go silent — # a linter that flags correct code is the failure mode this gate exists to stop. # CONTRACT: these counts follow the min_major column in the script's RULES table. ec 2 "audit: --assume-major 0 -> 2" "$PY" "$audit" --assume-major 0 "$fixture" v15="$("$PY" "$audit" --assume-major 15 "$fixture" 2>/dev/null)" [ "$(printf '%s\n' "$v15" | grep -c .)" = "13" ] && ok "audit: 13 findings at next 15" || bad "audit: next-15 count != 13" v14="$("$PY" "$audit" --assume-major 14 "$fixture" 2>/dev/null)" [ "$(printf '%s\n' "$v14" | grep -c .)" = "8" ] && ok "audit: 8 findings at next 14" || bad "audit: next-14 count != 8" for gated in sync-request-api sync-params-prop request-api-in-use-cache \ parallel-route-no-default nondeterministic-in-use-cache \ middleware-file edge-runtime-segment revalidate-tag-single-arg; do printf '%s\n' "$v14" | grep -qF " $gated " \ && bad "audit: $gated fired at next 14 (rule postdates that major)" \ || ok "audit: $gated correctly silent at next 14" done # --json envelope parses with the documented schema (stdout is data-only). # Capture first: findings exit 10, and under pipefail that would sink the pipe. audit_json="$("$PY" "$audit" --json "$fixture" 2>/dev/null)" printf '%s' "$audit_json" \ | "$PY" -c 'import json,sys; d=json.load(sys.stdin); m=d["meta"]; assert m["schema"]=="claude-mods.nextjs-ops.app-audit/v1"; assert m["count"]==17; assert m["truncated"] is False; assert m["next_major"]==16, m; assert "package.json" in m["next_major_source"], m; assert {f["rule"] for f in d["data"]} >= {"sync-request-api","action-without-auth"}' \ && ok "audit: --json envelope parses" || bad "audit: --json envelope broken" lim_json="$("$PY" "$audit" --json --limit 2 "$fixture" 2>/dev/null)" printf '%s' "$lim_json" \ | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["count"]==2; assert d["meta"]["truncated"] is True' \ && ok "audit: --limit truncates and says so" || bad "audit: --limit envelope wrong" # 6. check-nextjs-facts.py — staleness verifier contract (§7), offline-safe verifier="$here/scripts/check-nextjs-facts.py" "$PY" -m py_compile "$verifier" && ok "verifier: py_compile clean" || bad "verifier: py_compile failed" grep -qE '^Examples:$' "$verifier" && ok "verifier: has Examples block" || bad "verifier: no Examples block (docstring)" ec 0 "verifier: --help exits 0" "$PY" "$verifier" --help ec 0 "verifier: --offline consistent" "$PY" "$verifier" --offline ec 2 "verifier: bad flag -> 2" "$PY" "$verifier" --bogus ec 2 "verifier: --offline --live -> 2" "$PY" "$verifier" --offline --live ec 3 "verifier: missing catalog -> 3" "$PY" "$verifier" --offline --catalog /no/such/catalog.json "$PY" "$verifier" --offline --json -q 2>/dev/null \ | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.nextjs-ops.facts/v1"; assert d["meta"]["as_of"]' \ && ok "verifier: --json envelope parses (stdout clean)" || bad "verifier: --json envelope broken" # Error paths against synthetic catalogs: malformed -> 4, drifted major -> 10. tmp="$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}/nextjs-ops-test.$$")" mkdir -p "$tmp" printf 'not json' > "$tmp/bad.json" ec 4 "verifier: malformed catalog -> 4" "$PY" "$verifier" --offline --catalog "$tmp/bad.json" sed 's/"documented_major": "16"/"documented_major": "99"/' "$here/assets/nextjs-facts.json" > "$tmp/drift.json" ec 10 "verifier: drifted major -> 10" "$PY" "$verifier" --offline --catalog "$tmp/drift.json" sed 's/Next\.js 16/Next.js 99/' "$here/assets/nextjs-facts.json" > "$tmp/token.json" ec 10 "verifier: drifted prose token -> 10" "$PY" "$verifier" --offline --catalog "$tmp/token.json" rm -rf "$tmp" # 7. The currency note is the skill's most load-bearing line: it tells a reader # which semantics the caching tables describe. Assert its exact shape here too, # so deleting it fails the skill suite and not only the verifier. grep -qE 'Verified against Next\.js 16\.x \([0-9]{4}-[0-9]{2}-[0-9]{2}\)' "$skill" \ && ok "SKILL.md carries the dated currency note" || bad "SKILL.md currency note missing/reshaped" # 8. Template sanity: the starter names its load-bearing, version-gated options tmpl="$here/assets/next.config.template.ts" for marker in "cacheComponents" "partialPrefetching" "cacheLife" "remotePatterns" \ "deploymentId" "X-Accel-Buffering" "allowedOrigins"; do grep -qF "$marker" "$tmpl" && ok "config template carries: $marker" || bad "config template missing: $marker" done # The template must not demonstrate what the audit script flags. "$PY" "$audit" --min-severity warn "$tmpl" >/dev/null 2>&1 [ "$?" = "0" ] && ok "config template is audit-clean" || bad "config template trips its own audit rules" echo "nextjs-ops tests: $pass passed, $fail failed" >&2 [ "$fail" = "0" ] && { echo "PASS" >&2; exit 0; } || { echo "FAIL" >&2; exit 1; }
-
-
SKILL.md 18.6 KB
--- name: nextjs-ops description: "Next.js App Router operations - the server/client boundary, the two caching models, Server Actions security, streaming, proxy.ts and deployment. Use for: next.js, nextjs, app router, use cache, cacheComponents, Cache Components, cacheLife, cacheTag, revalidateTag, updateTag, revalidatePath, unstable_cache, PPR, partial prerendering, stale data in production, use client, use server, server actions, RSC payload, serialization boundary, next/headers, async params, cookies not awaited, loading.tsx, Suspense boundary, generateStaticParams, ISR, proxy.ts, middleware.ts deprecated, edge runtime, next build vs next start, self-hosting Next.js, standalone output, Failed to find Server Action, upgrade to Next.js 16, Pages Router to App Router, force-static, force-dynamic, next/font, next/script, bundle size, testing server components." license: MIT allowed-tools: "Read Write Bash Grep Glob" metadata: author: claude-mods related-skills: "react-ops, typescript-ops, tailwind-ops, payloadcms-ops, cloudflare-ops, hono-ops, auth-ops, testing-ops" --- # Next.js Operations The App Router as an operational surface: what crosses the server/client boundary, which caching model the app is actually on, why a response is stale, and what a Server Action really is on the wire. React itself belongs to `react-ops`; styling to `tailwind-ops`; the CMS above to `payloadcms-ops`; the Workers runtime below to `cloudflare-ops` / `hono-ops`. This skill is the framework. > Verified against Next.js 16.x (2026-08-30) — `next@16.3.3`, docs snapshot > 2026-08-25, React 19. Semantics changed materially at **15.0** (`fetch` > uncached by default, request APIs async) and again at **16.0** (Cache > Components, `proxy.ts`, `revalidateTag` signature). **Establish the app's > version before answering any caching question** — the right answer for 14 is > the wrong answer for 16. **Staleness check:** `python scripts/check-nextjs-facts.py --offline` asserts the version-bearing facts are still named in the prose and that the currency note above matches the catalog; `--live` confirms each package's npm major. Catalog: `assets/nextjs-facts.json`. ## Orient first: which model is this app on? Three greps, before any advice. Getting this wrong is the single largest source of confidently-wrong Next.js answers. ```bash node -p "require('next/package.json').version" # the major decides everything grep -rn "cacheComponents" next.config.* # Cache Components on/off ls proxy.* middleware.* src/proxy.* src/middleware.* 2>/dev/null ``` | Signal | Model in force | Read | |---|---|---| | `cacheComponents: true` | **Cache Components** — nothing cached unless `'use cache'` says so; PPR is the default rendering | `references/cache-components.md` | | No `cacheComponents` (16.x default) | **Previous model** — `fetch` uncached by default, route-segment config, `unstable_cache` | `references/caching-model.md` | | Next.js ≤ 14 | Legacy — `fetch` cached by *default*; most "why is this stale" bugs live here | `references/caching-model.md` | ## Decision Tree ``` What are you doing with Next.js? │ ├─ "It's serving stale data" / "my change doesn't appear" │ └─ Orient (above) → references/caching-model.md (debug ladder) │ ├─ Deciding what to cache, for how long, and how to bust it │ └─ references/cache-components.md (use cache, cacheLife, cacheTag) │ or references/caching-model.md if cacheComponents is off │ ├─ "use client" errors, serialization failures, context, env leaks │ └─ Below + references/server-client-boundary.md │ ├─ Mutations: Server Action vs Route Handler, auth, validation │ └─ Below + references/server-actions.md │ ├─ Data fetching, waterfalls, Suspense/loading.tsx, what streams │ └─ references/data-fetching-streaming.md │ ├─ Routes, async params, layouts, parallel/intercepting routes, metadata │ └─ references/routing-and-rendering.md │ ├─ proxy.ts (formerly middleware.ts), matchers, edge vs Node runtime │ └─ references/proxy-and-runtimes.md │ ├─ Shipping it: self-host, Docker, multi-instance, CDN, Cloudflare │ └─ references/deployment.md │ ├─ Upgrading 14/15 -> 16, or Pages Router -> App Router │ └─ references/upgrading.md │ ├─ Fonts, scripts, images, bundle size, build speed │ └─ references/optimization.md │ ├─ Testing it (and what simply cannot be unit-tested) │ └─ references/testing.md │ └─ Auditing an existing app for the known footguns └─ python scripts/audit-app-router.py <project-root> ``` ## The boundary is a module graph, not a folder `'use client'` marks an **entry point into the client module graph**. Everything that file imports — and everything those files import — is bundled for the browser, whether or not it carries the directive. Components passed *through* as `children` or props are not imported by it, so they stay on the server and arrive as already-rendered output. That single asymmetry explains most boundary design: ```tsx // ❌ marking the layout client pulls the whole tree into the bundle 'use client' export default function Layout({ children }) { /* ... */ } // ✅ keep the layout on the server; make only the interactive leaf a client entry export default function Layout({ children }) { return <nav><Logo /><Search /></nav> // Search is the 'use client' file } ``` **A serialization error at the boundary is the real error.** Props crossing server → client are serialized into the RSC payload; a class instance, a function, a `URL`, a Symbol or a `Date`-bearing ORM row cannot make the trip. The fix is almost never "wrap it in a Client Component" — it is to stop sending the un-serializable thing and send the shape the UI renders. That is also the security fix: returning a raw database row to the client publishes every column on it. Two more rules that fall out of the same graph: - **Providers wrap `{children}`, not the tree.** A `'use client'` provider that accepts children keeps the subtree on the server. Render it as deep as it can go so the static parts stay static. - **Environment poisoning is silent.** Only `NEXT_PUBLIC_*` variables reach the browser; anything else becomes an empty string in a client module — no error, just a request that fails at runtime with an empty `Authorization` header. `import 'server-only'` turns that into a build error instead. The `client-secret-env` rule in `audit-app-router.py` catches it statically. Depth, interleaving patterns, and the third-party-component wrapper: `references/server-client-boundary.md`. ## Caching: the reason this skill exists Caching is where Next.js costs teams the most hours, because the defaults inverted between majors and the failure mode is a *correct-looking* stale page. **The mental model that survives version changes:** work is either (a) known at build time, (b) cached with a stated lifetime, or (c) request-time. Every caching bug is one of those three misclassified. ### Under Cache Components (`cacheComponents: true`) ```tsx import { cacheLife, cacheTag } from 'next/cache' async function BlogPosts() { 'use cache' // opt IN — this is the only thing that caches cacheLife('hours') // ALWAYS state it; omitting it means the implicit 'default' cacheTag('posts') // the handle you invalidate by return <List posts={await getPosts()} /> } ``` Non-negotiables: - **Arguments and captured closure variables form the cache key.** Different inputs, different entries. That is also why a per-user value in scope silently multiplies your entries. - **Request APIs cannot be read inside a cached scope** — `cookies()`, `headers()`, `searchParams`, and dynamic `params` throw `next-request-in-use-cache`, *and the restriction follows the call stack* into helpers. Read them outside and pass the value in as an argument. - **Set `cacheLife` explicitly in every scope.** Without it, an inner short-lived cache can silently shorten the outer one (and, if the outer has no explicit profile, that combination is a prerender-time build error). - **The default store is per-instance and in-memory** — on serverless it often does not survive between requests. `'use cache: remote'` is the durable, shared variant, and it costs a network round trip. | Profile | `stale` (client) | `revalidate` (server) | `expire` | |---|---|---|---| | `default` | 5 min | 15 min | never | | `seconds` | 30 s | 1 s | 1 min | | `minutes` | 5 min | 1 min | 1 hr | | `hours` | 5 min | 1 hr | 1 day | | `days` | 5 min | 1 day | 1 week | | `weeks` | 5 min | 1 week | 30 days | | `max` | 5 min | 30 days | 1 year | `revalidate: 0` or `expire` under 5 minutes drops the content out of the prerender entirely; `stale` under 30 seconds drops it out of prefetches. Of the presets only `seconds` trips either. Full semantics, nesting rules, `use cache: private` / `remote`: `references/cache-components.md`. ### Under the previous model (the 16.x default) `fetch` is **not** cached unless you ask (`cache: 'force-cache'` or `next: { revalidate: n }`); non-`fetch` work caches via `unstable_cache`; route segments are steered by `dynamic`, `revalidate` and `fetchCache` exports. The layers — request memoization, data cache, full route cache, client router cache — and the ladder for finding which one is holding the stale value are in `references/caching-model.md`. ### Invalidating after a mutation — pick by what must change | API | Semantics | Use when | |---|---|---| | `updateTag(tag)` | expires **and** re-reads in the same response | read-your-own-writes; the user must see their change now. Actions only | | `revalidateTag(tag, profile)` | stale-while-revalidate; **no** immediate re-render | shared content that tolerates eventual consistency | | `revalidatePath(path)` | invalidate one URL | a single route is affected and tagging is overkill | | `refresh()` | refetch uncached data only, cache untouched | the view depends on state outside the cache. Actions only | The single-argument `revalidateTag('x')` form is deprecated in 16 — that is the `revalidate-tag-single-arg` finding. ## Server Actions are public endpoints An action is not a function call. `'use server'` compiles the implementation away from the client bundle and leaves an **action ID that POSTs back to the route**. Anyone who can send that POST can invoke it, with no form, no page render, and no UI-level gate in the way. ```ts 'use server' export async function completeItem(itemId: string) { const session = await auth() // 1. authenticate if (!session?.user) throw new Error('Unauthorized') const item = await db.item.findFirst({ // 2. authorize by ownership, where: { id: itemId, ownerId: session.user.id }, // re-read from a trusted source }) if (!item) return await db.item.update({ where: { id: item.id }, data: { completed: true } }) } ``` - **Take a reference, not the record.** A client legitimately says *which* item; it does not get to supply the row's contents or its ownership. Schema validation checks shape, never entitlement. - **Rendering is not a gate.** "The form only renders for admins" is not authorization, and neither is a `proxy.ts` matcher — actions are POSTs to the route they live on, so moving one to another route can silently drop it out of matcher coverage. - Framework protections you get for free: `Origin`-vs-`Host` CSRF check, a 1MB body cap, encrypted action IDs, dead-code elimination of unused actions, and closure-variable encryption. They are a floor, not a substitute. - **Prefer a Route Handler** for GETs, webhooks, third-party callers, file streaming, anything needing custom status/headers, and anything that must run in parallel — the client dispatches actions **one at a time**, so `Promise.all` over actions is sequential. Deployment, `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`, and the "Failed to find Server Action" skew failure: `references/server-actions.md`. ## Landmines The non-obvious ones, in rough order of hours lost. 1. **`next dev` never caches pages.** Development renders on demand, so every caching bug is invisible until `next build && next start`. Verify caching against a production build, or you are testing a different program. 2. **A cached scope reading request data can pass `next build` and fail under `next start`.** On a dynamically-rendered route the `next-request-in-use-cache` error only surfaces when the route actually runs. 3. **The build hangs for 50 seconds, then dies.** A Promise created outside a `'use cache'` boundary (a `cookies()` store passed as a prop, a shared `Map` of in-flight fetches) is being awaited inside one. It cannot resolve during prerender. The error names the timeout, not the prop that caused it. 4. **`await params` at the top of a layout de-opts the whole subtree.** Awaiting request data high in the tree shrinks the static shell to nothing. Pass the promise down and await it inside a `<Suspense>` boundary instead — the same move applies to `cookies()`, `headers()` and `searchParams`. 5. **`proxy.ts` without a `matcher` runs on every request** — including `_next/static`, `_next/image` and `public/`. Auth logic there blocks your own CSS. Conversely, `_next/data` still runs proxy even when excluded, on purpose. 6. **Parallel route slots now require `default.js`.** Since 16, a `@slot` without one fails the build outright. 7. **Bots get a different render.** Crawlers are detected by user agent and served a full dynamic render instead of the static shell — so a shell built from build-time-only data can 500 for Googlebot while working for every human. 8. **Streaming dies quietly behind a buffering proxy.** nginx, and some cloud load balancers, buffer by default: PPR still "works", but the shell and the dynamic content land together and the entire TTFB benefit disappears. 9. **New deploy, "Failed to find Server Action".** Action IDs rotate per build (at most every 14 days even when source is unchanged). Multi-instance deployments need a shared `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`; clients mid-mutation need a retry path, not a stack trace. 10. **`revalidateTag()` only invalidates the instance it ran on.** Across pods, tag state must be synced by a cache handler implementing `refreshTags()`. 11. **`unstable_cache` and `'use cache'` are not the same store.** `use cache` entries are keyed by build id and never survive a deploy — even `remote` ones. If something must persist across deploys, it is not a `use cache` job. 12. **`Math.random()`/`Date.now()` inside a cached scope freeze one value for everyone.** Call `connection()` first and wrap in `<Suspense>` for a per-request value; cache it deliberately if one shared value is what you want. ## Bundled resources | Resource | Use it when | |---|---| | [`scripts/audit-app-router.py`](scripts/audit-app-router.py) | Auditing or inheriting an app — 16 static rules for the landmines above, **gated on the project's detected Next.js major** | | [`scripts/check-nextjs-facts.py`](scripts/check-nextjs-facts.py) | CI / freshness: are this skill's version facts still true? | | [`assets/next.config.template.ts`](assets/next.config.template.ts) | Starting a 16.x config, or auditing an inherited one | | [`assets/nextjs-facts.json`](assets/nextjs-facts.json) | The dated fact catalog the verifier reads | The audit script takes this skill's own advice: it reads the project's Next.js major from `node_modules/next` (or `package.json`) and **suppresses the rules that postdate it** — eight of the sixteen describe breakages introduced in 15 or 16, so running them against a 14-era app would flag correct code. The verdict line always states the major it gated on. Use `--assume-major N` when scanning a bare subdirectory where the version cannot be read. ```bash # Inherit an unfamiliar app: what will bite, worst first python scripts/audit-app-router.py /path/to/project # CI gate: block only on the errors python scripts/audit-app-router.py --min-severity error . # Machine-readable, for triage or a report python scripts/audit-app-router.py --json . | jq '.data[] | select(.severity=="error")' # Scanning a subtree with no package.json in reach python scripts/audit-app-router.py --assume-major 15 ./packages/web/app # Is this skill still describing reality? python scripts/check-nextjs-facts.py --offline ``` ## References | File | Covers | |---|---| | [references/caching-model.md](references/caching-model.md) | The previous model: four cache layers, defaults per major, the stale-response debug ladder | | [references/cache-components.md](references/cache-components.md) | `use cache`, cache keys, `cacheLife`/`cacheTag`, `private` vs `remote`, PPR and prefetch | | [references/server-client-boundary.md](references/server-client-boundary.md) | `use client` graph, serialization, interleaving, context, environment poisoning | | [references/server-actions.md](references/server-actions.md) | Endpoint model, auth/validation, action vs route handler, config, skew | | [references/data-fetching-streaming.md](references/data-fetching-streaming.md) | Fetch patterns, waterfalls, Suspense/`loading.tsx`, what a loading state costs | | [references/routing-and-rendering.md](references/routing-and-rendering.md) | File conventions, async `params`, dynamic/parallel/intercepting routes, metadata | | [references/proxy-and-runtimes.md](references/proxy-and-runtimes.md) | `proxy.ts`, matchers, execution order, Node vs Edge runtime API gaps | | [references/deployment.md](references/deployment.md) | Self-hosting, Docker, multi-instance, CDN behaviour, the Cloudflare path | | [references/upgrading.md](references/upgrading.md) | 14 -> 15 -> 16 deltas (which are silent), Cache Components adoption, Pages -> App | | [references/optimization.md](references/optimization.md) | `next/font`, `next/script` strategies, image props, bundle and build speed | | [references/testing.md](references/testing.md) | The shifted pyramid: async Server Components are E2E-only; action security tests; `instant()` | ## Cross-references - **`react-ops`** — React itself: hooks, component architecture, state, the React-level Server Components model. - **`typescript-ops`** — the type system behind `PageProps`/`LayoutProps` and strict-mode discipline. - **`tailwind-ops`** — styling; **`payloadcms-ops`** — Payload 3, which is Next.js-native and inherits every boundary rule here. - **`cloudflare-ops`** / **`hono-ops`** — the Workers runtime, bindings and Hono-based APIs. Next.js on Cloudflare goes through `@opennextjs/cloudflare`; see `references/deployment.md` for the seam, and those skills for the platform. - **`auth-ops`** — session and token design that Server Actions depend on; **`testing-ops`** — the test strategy this framework's boundaries need.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.