
Nextjs Ppr Patterns
- 70 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
nextjs-ppr-patterns is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- nextjs-ppr-patterns
- AI & Agent Building
- AI-coding skill
Nextjs Ppr Patterns by the numbers
- 70 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,700 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill nextjs-ppr-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 70 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with nextjs-ppr-patterns.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when nextjs-ppr-patterns is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to nextjs-ppr-patterns: nextjs-ppr-patterns; AI & Agent Building; AI-coding skill.
Files
Next.js 16 Partial Prerendering Patterns
Partial Prerendering (PPR) for the Next.js 16 App Router under the Cache Components model — the decisions PPR forces and how to settle them, written so an agent applies them while writing or reviewing code. Contains 21 rules across 6 categories, ordered from easy to complex: enable PPR → understand the static/dynamic boundary → cache → handle runtime data → compose whole pages → build forms and wizards. Each rule corrects a specific wrong default of a model defaulting to Next.js 14/15; there is no rule for things the model already gets right.
Version-specific. This skill targets Next.js 16 (PPR viacacheComponents, React 19.2). The Next.js 14/15experimental.pprflag andexport const experimental_pprroute export were removed — seesetup-enable-cache-components. For migrating an existing app, see the migration guide.
Write, then verify. These rules are for authoring PPR; they can't tell you what actually rendered. To empirically deconstruct the boundary — diff the static shell against the hydrated DOM to find the dynamic holes, locate the'use client'islands, measure loading, and explain why a route is dynamic — drivenext buildand a real browser per _debug-boundaries.md.
When to Apply
- Building or reviewing a Next.js 16 page that mixes static chrome with personalized, real-time, or per-request content
- Enabling or migrating PPR (
cacheComponents), or seeing deadexperimental.ppr/experimental_pprcode - Deciding where
<Suspense>boundaries go, or debugging anUncached data was accessed outside of <Suspense>build error - Adding
'use cache',cacheLife,cacheTag, or choosingupdateTag/revalidateTag/refreshafter a mutation - Composing forms, multi-step wizards, dashboards, or streaming server data into interactive Client Components
- Empirically verifying or debugging what actually rendered — which parts are in the static shell vs streamed, where the CSR/SSR boundary is, and why a route went dynamic
Rule Categories
| # | Category | Prefix | Covers |
|---|---|---|---|
| 1 | Setup & Mental Model | setup- | Enabling PPR with cacheComponents; the removed experimental flags; dynamic-by-default / opt-in caching inversion |
| 2 | The Suspense Boundary | shell- | <Suspense> as the static/dynamic seam; the build error; boundary granularity; what Suspense does not do |
| 3 | Caching with 'use cache' | cache- | Directive levels; automatic keys; runtime values as props; pass-through; cacheLife/cacheTag; serverless durability |
| 4 | Runtime APIs & Non-Determinism | runtime- | Async request APIs forcing a boundary; generateStaticParams; connection() for randomness/time |
| 5 | Page Composition Recipes | compose- | Single hole → parallel dashboard → Promise + use() streaming → not opting the whole app out of the shell |
| 6 | Forms, Mutations & Wizards | mutate- | updateTag vs revalidateTag vs refresh; URL-driven wizard steps; <Activity> field preservation |
Quick Reference
1. Setup & Mental Model
- `setup-enable-cache-components` — Enable PPR via
cacheComponents: true; theexperimental.ppr/experimental_pprflags are removed - `setup-dynamic-by-default` — Everything renders at request time; caching is opt-in via
'use cache'(andfetchis no longer cached)
2. The Suspense Boundary
- `shell-suspense-is-the-boundary` —
<Suspense>is the static-shell/dynamic-stream seam, not a spinner - `shell-wrap-uncached-data` — Uncached/runtime reads must be wrapped (or
'use cache'd) or the build errors - `shell-suspense-does-not-force-dynamic` — Suspense alone does not make synchronous work dynamic
- `shell-place-boundaries-low` — Wrap the dynamic leaf, not the whole page, so the shell stays large
3. Caching with 'use cache'
- `cache-use-cache-directive` — Mark static/cacheable work at the function / component / page / layout / file level
- `cache-keys-are-automatic` — Arguments and closures form the cache key; pass varying inputs as args
- `cache-pass-runtime-values-as-props` — You can't read
cookies()/headers()inside a cached scope; pass values in - `cache-pass-through-children-and-actions` — Pass dynamic
childrenand Server Actions through a cached component untouched - `cache-set-lifetime-and-tags` —
cacheLifecontrols TTL,cacheTagenables on-demand invalidation - `cache-in-memory-not-durable-serverless` — In-memory cache isn't durable on serverless; use
'use cache: remote'
4. Runtime APIs & Non-Determinism
- `runtime-request-apis-force-a-boundary` — Async
cookies/headers/searchParams/paramsforce a dynamic boundary - `runtime-keep-param-routes-static` —
generateStaticParamskeeps[slug]routes in the static shell - `runtime-gate-nondeterminism-with-connection` — Gate
Math.random/Date.now/cryptobehindconnection(), or cache the value
5. Page Composition Recipes
- `compose-single-dynamic-hole` — The baseline: static shell + one
<Suspense>hole - `compose-parallel-holes` — One boundary per widget → parallel streaming, no waterfall
- `compose-stream-to-client-with-use` — Pass an un-awaited Promise and unwrap with
use()in a Client Component - `compose-do-not-opt-out-the-shell` — Don't defer the whole app to silence a boundary error
6. Forms, Mutations & Wizards
- `mutate-updatetag-vs-revalidatetag` —
updateTag(read-your-writes) vsrevalidateTag(tag, profile)(SWR) vsrefresh() - `mutate-wizard-url-driven-steps` — URL-driven steps + static chrome +
<Activity>field preservation
How to Use
Read a reference file when its decision comes up. Each rule names the wrong default it corrects, then shows the canonical way (with an incorrect/correct contrast only where the wrong way is a real trap). If you're starting cold, read setup- first — the rest assumes the dynamic-by-default mental model.
- Section definitions — category structure and ordering
- Boundary debugging — empirically deconstruct the static/dynamic boundary and loading with
next buildand chrome-devtools-mcp (via mcporter); use it when a PPR result surprises you or you're chasing ablocking-routeerror - Rule template — for adding new rules
- AGENTS.md — auto-built table of contents across all rules
Related Skills
nextjs— broader Next.js 16 App Router best practices (caching, server components, routing, hygiene)opinionated-nextjs-patterns— full opinionated architecture (data layer, mutations, client boundaries) that uses these PPR patternsreact-fetch-cache-patterns— request orchestration and client-side caching for data-heavy React UIs
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| references/_debug-boundaries.md | Empirical CSR/SSR boundary & loading debugging (next build + chrome-devtools-mcp via mcporter) |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and source references |
Next.js 16 App Router — Partial Prerendering / Cache Components (React 19.2)
Version 0.2.0 dot-skills May 2026
---
Abstract
Partial Prerendering (PPR) patterns for the Next.js 16 App Router under the Cache Components model, from the simplest static-shell-plus-one-hole page to forms and multi-step wizards. Corrects the wrong defaults of a model trained on Next.js 14/15: the removed experimental.ppr flags, implicit caching, Suspense as the static/dynamic boundary, the 'use cache' directive and its constraints, async runtime APIs, server-to-client Promise streaming with use(), and read-your-writes mutations with updateTag. Includes an empirical boundary-debugging playbook that deconstructs what actually rendered static vs dynamic by diffing the static shell against the hydrated DOM, using next build signals and chrome-devtools-mcp driven via mcporter.
---
Table of Contents
1. Setup & Mental Model
- 1.1 Enable PPR with cacheComponents, not the removed experimental flags
- 1.2 Treat everything as dynamic by default and opt into caching
2. The Suspense Boundary
- 2.1 Know that Suspense alone does not make work dynamic
- 2.2 Place Suspense boundaries around the dynamic leaf, not the page
- 2.3 Treat Suspense as the static/dynamic boundary, not a spinner
- 2.4 Wrap uncached or runtime reads in Suspense or the build fails
3. Caching with `'use cache'`
- 3.1 Control cache lifetime and invalidation with cacheLife and cacheTag
- 3.2 Know that in-memory use cache is not durable on serverless
- 3.3 Let arguments and closures form the cache key automatically
- 3.4 Mark static and cacheable work with the use cache directive
- 3.5 Pass dynamic children and Server Actions through a cached component
- 3.6 Read runtime APIs outside the cache and pass values in as props
4. Runtime APIs & Non-Determinism
- 4.1 Gate randomness and time behind connection or cache the value
- 4.2 Keep dynamic-segment routes in the shell with generateStaticParams
- 4.3 Know that request APIs force a dynamic boundary and are async
5. Page Composition Recipes
- 5.1 Build the canonical page as a static shell with one dynamic hole
- 5.2 Do not opt the whole app out of the static shell to silence an error
- 5.3 Give each independent widget its own boundary to stream in parallel
- 5.4 Stream server data into a client component with an unawaited Promise and use
6. Forms, Mutations & Wizards
- 6.1 Drive wizard steps from the URL and let Activity preserve field state
- 6.2 Pick updateTag for read-your-writes after a form mutation
---
References
1. https://nextjs.org/blog/next-16 2. https://nextjs.org/docs/app/getting-started/partial-prerendering 3. https://nextjs.org/docs/app/getting-started/caching 4. https://nextjs.org/docs/app/getting-started/fetching-data 5. https://nextjs.org/docs/app/api-reference/directives/use-cache 6. https://nextjs.org/docs/app/api-reference/directives/use-cache-private 7. https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents 8. https://nextjs.org/docs/app/api-reference/functions/cacheLife 9. https://nextjs.org/docs/app/api-reference/functions/cacheTag 10. https://nextjs.org/docs/app/api-reference/functions/connection 11. https://nextjs.org/docs/app/api-reference/functions/generate-static-params 12. https://nextjs.org/docs/app/guides/preserving-ui-state 13. https://nextjs.org/docs/app/guides/upgrading/version-16 14. https://raw.githubusercontent.com/steipete/agent-scripts/refs/heads/main/skills/browser-use/SKILL.md 15. https://raw.githubusercontent.com/steipete/agent-scripts/refs/heads/main/skills/browser-use/mcporter-config.md
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Name the wrong default this rule corrects and its concrete consequence, in 1-3 sentences. For this skill that usually means: what a model defaulting to Next.js 14/15 does here, and why it breaks (or silently misbehaves) under Next.js 16 Cache Components. Explain the why — the model generalizes from the reason, not the instruction. Don't restate something the model already does correctly.
// The canonical Next.js 16 way. Real, domain-realistic names — not foo/bar.
export default async function CheckoutPage() {
return (
<Suspense fallback={<CartSkeleton />}>
<Cart />
</Suspense>
)
}Reference: Source title
<!-- Add an Incorrect (…): / Correct (…): pair ONLY when the wrong way is a genuine, common trap (e.g. the removed experimental.ppr flag, reading cookies() inside use cache, single-arg revalidateTag). Keep the diff minimal. A strawman foil is worse than a single good example. -->
{
"name": "nextjs-ppr-patterns",
"version": "0.2.0",
"organization": "dot-skills",
"technology": "Next.js 16 App Router — Partial Prerendering / Cache Components (React 19.2)",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Partial Prerendering (PPR) patterns for the Next.js 16 App Router under the Cache Components model, from the simplest static-shell-plus-one-hole page to forms and multi-step wizards. Corrects the wrong defaults of a model trained on Next.js 14/15: the removed experimental.ppr flags, implicit caching, Suspense as the static/dynamic boundary, the 'use cache' directive and its constraints, async runtime APIs, server-to-client Promise streaming with use(), and read-your-writes mutations with updateTag. Includes an empirical boundary-debugging playbook that deconstructs what actually rendered static vs dynamic by diffing the static shell against the hydrated DOM, using next build signals and chrome-devtools-mcp driven via mcporter.",
"references": [
"https://nextjs.org/blog/next-16",
"https://nextjs.org/docs/app/getting-started/partial-prerendering",
"https://nextjs.org/docs/app/getting-started/caching",
"https://nextjs.org/docs/app/getting-started/fetching-data",
"https://nextjs.org/docs/app/api-reference/directives/use-cache",
"https://nextjs.org/docs/app/api-reference/directives/use-cache-private",
"https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents",
"https://nextjs.org/docs/app/api-reference/functions/cacheLife",
"https://nextjs.org/docs/app/api-reference/functions/cacheTag",
"https://nextjs.org/docs/app/api-reference/functions/connection",
"https://nextjs.org/docs/app/api-reference/functions/generate-static-params",
"https://nextjs.org/docs/app/guides/preserving-ui-state",
"https://nextjs.org/docs/app/guides/upgrading/version-16",
"https://raw.githubusercontent.com/steipete/agent-scripts/refs/heads/main/skills/browser-use/SKILL.md",
"https://raw.githubusercontent.com/steipete/agent-scripts/refs/heads/main/skills/browser-use/mcporter-config.md"
]
}
Debugging the boundary — what actually rendered static vs dynamic
The rules in this skill tell you how to write PPR. They can't tell you what you actually got. With Cache Components the static/dynamic split is implicit — it falls out of where your <Suspense> boundaries, 'use cache' directives, and runtime reads happen — so a code read is a hypothesis, not proof. The questions only the running build/app can answer:
- Which routes/segments are in the static shell, and which render at request time?
- What content actually landed in the shell vs streamed in as a dynamic hole? (the real PPR boundary)
- Where is the CSR/SSR boundary — which subtrees are server-rendered HTML vs hydrated
'use client'islands? - How does loading behave — is the fallback in the shell, how long until the hole swaps in, does it shift layout (CLS)?
- Why is a route dynamic when you expected it static (a stray
cookies(), an uncachedfetch, aMath.random())?
Reach for this when a PPR result surprises you, when chasing a blocking-route build error, or to verify a page renders as the shell-, runtime-, and compose- rules intend. Use the two instruments below in order — the build is cheapest and most authoritative; drive a browser when the verdict depends on what was actually received and when. Stay read-only: you are observing the app, not mutating it.
---
Instrument 1 — Build-time signal (next build)
The build is the cheapest source of truth for the static/dynamic split: it runs the prerender and reports, per route, what it could put in the shell.
next buildRead the route legend in the summary:
○(Static) — fully prerendered into the shell.◐(Partial Prerender) — a static shell plus dynamic holes streamed at request time; this is
PPR working as intended. At build time Next.js emits the static HTML shell plus a `postponedState` blob for the holes (adapter/build output identifies these as renderingMode: 'PARTIALLY_STATIC').
ƒ(Dynamic) — rendered entirely at request time. If a route you expected to be partial (◐)
shows as ƒ, a runtime read escaped to the page root — see runtime-request-apis-force-a-boundary and shell-place-boundaries-low.
The build is also where the boundary contract is enforced: uncached/runtime data that isn't wrapped or cached fails here with Uncached data was accessed outside of <Suspense> (the blocking-route error — see shell-wrap-uncached-data). A clean build is your first proof the boundaries are placed legally. If the error only surfaces in CI / next build rather than the dev overlay, run next build --debug-prerender for a prerender stack trace that names the exact component that read uncached data.
For cache hits/misses, turn on verbose cache logging (also works in dev); in dev, the per-request log also splits Compile vs Render time so you can see which segments do request-time work:
NEXT_PRIVATE_DEBUG_CACHE=1 npm run build
# In dev, console logs from a 'use cache' scope are prefixed with `Cache`.---
Instrument 2 — chrome-devtools-mcp via mcporter (the rendered truth)
When the verdict depends on what the browser received and when, drive a real Chrome via `chrome-devtools-mcp`, invoked from the CLI with `mcporter`. Configure it once per the mcporter Chrome config; the chrome-devtools server reattaches to your existing logged-in profile (use chrome-isolated only for a deliberately clean session). See the browser-use skill for the workflow. The thin browser-use wrapper covers navigate/snapshot/click/fill/evaluate; the richer introspection below (console, performance traces, throttling) comes from the underlying chrome-devtools-mcp tools — call them directly. Confirm arg shapes first; they evolve:
mcporter list chrome-devtools --schema # authoritative arg shapes
mcporter call chrome-devtools.list_pages --args '{}' --output text # smoke test / find the tab
mcporter call chrome-devtools.select_page --args '{"pageId":0}' --output text # target the tab (id from list_pages)
mcporter daemon restart # if calls hang or the list is staleThe static shell vs the hydrated DOM — diff them to find the holes
This is the single most direct way to see the PPR boundary. The raw HTML response is exactly the static shell (it's what ships before any JS); the live DOM is the shell plus everything that streamed in. The difference is your set of dynamic holes.
# A) The static shell — raw HTML, no JS executed. Dynamic holes appear here as their
# Suspense fallback/skeleton, NOT as real content (see shell-suspense-is-the-boundary).
curl -s https://app.local/dashboard > /tmp/ppr-shell.html
# B) The hydrated DOM after streaming completes.
mcporter call chrome-devtools.navigate_page --args '{"type":"url","url":"https://app.local/dashboard"}' --output text
mcporter call chrome-devtools.evaluate_script --output text \
--args '{"function":"() => document.documentElement.outerHTML"}' > /tmp/ppr-live.html
# C) What is in (B) but not (A) = the content that streamed in (the dynamic holes).
diff <(sort /tmp/ppr-shell.html) <(sort /tmp/ppr-live.html) | head -40Sanity checks on the result:
- A personalized/uncached value (a name from
cookies(), a live count) present inppr-shell.html
means it leaked into the static shell — likely a missing boundary or wrongly-cached request data (cache-pass-runtime-values-as-props).
- A skeleton/fallback present in the shell but the real widget only in the live DOM is PPR working as
intended (compose-single-dynamic-hole).
Locate the CSR/SSR boundary (hydration islands)
'use client' components are the only interactive (hydrated) islands; everything else is static server HTML. React 19 stamps no public DOM marker for hydration roots, so confirm islands behaviorally rather than by selector, and pull console messages to catch hydration mismatches (the classic CSR/SSR-boundary bug where server and client HTML disagree):
# The static-shell-vs-DOM diff above already shows which subtrees are dynamic. A control is a
# client island if interacting with it changes state WITHOUT a navigation:
mcporter call chrome-devtools.take_snapshot --args '{}' --output text # get fresh uids
mcporter call chrome-devtools.click --args '{"uid":"<from snapshot>","includeSnapshot":true}' --output text
# Hydration mismatches and other client errors surface in the console:
mcporter call chrome-devtools.list_console_messages --args '{}' --output textLoading behavior — fallback→content swap, streaming, CLS
Record a trace around the load to measure how long the hole shows its fallback and whether the swap shifts layout — the runtime proof behind compose-parallel-holes and shell-place-boundaries-low.
mcporter call chrome-devtools.performance_start_trace --args '{"reload":true}' --output text
# (page loads under trace)
mcporter call chrome-devtools.performance_stop_trace --args '{}' --output json # summary includes CLS + long tasks
# Drill into a named insight — pick the name from the list the trace reports (e.g. LCPBreakdown):
mcporter call chrome-devtools.performance_analyze_insight --args '{"insightName":"LCPBreakdown"}' --output jsonSlow networks expose streaming you can't see locally — throttle, then re-run the trace:
mcporter call chrome-devtools.emulate --args '{"cpuThrottlingRate":4}' --output text---
Reading the evidence
| Symptom observed | Likely cause | Rule |
|---|---|---|
Route is ƒ (fully dynamic) when you expected partial | A runtime read (cookies/headers/searchParams) or uncached fetch at the page root | runtime-request-apis-force-a-boundary, shell-place-boundaries-low |
Uncached data was accessed outside of <Suspense> at build | Uncached/runtime data not wrapped or 'use cache'd | shell-wrap-uncached-data |
Personalized value appears in curl shell HTML | It leaked into the static shell (missing boundary, or cached request data) | cache-pass-runtime-values-as-props, shell-suspense-is-the-boundary |
| Whole page is one big fallback / empty shell | One boundary wrapped too much, or the app opted out of the shell | shell-place-boundaries-low, compose-do-not-opt-out-the-shell |
| Hydration mismatch in the browser console | Non-deterministic value rendered without connection()/cache | runtime-gate-nondeterminism-with-connection |
| Hole never streams in / build hangs ~50s | A runtime Promise passed into a 'use cache' scope | cache-pass-runtime-values-as-props |
Guardrails
- Read-only.
curl, traces, console reads, andevaluate_scriptreads observe the app.
click/fill are only for driving to the state under test — never to mutate real data, and not against a production account that can write.
- Reattach, don't spawn. Prefer the
chrome-devtools(existing-profile) server; use
chrome-isolated only for a deliberately signed-out session.
- Don't commit artifacts.
/tmp/ppr-*.html, trace JSON, and screenshots are evidence for the
investigation, not repo files.
Sections
This file defines the categories and their order. The prefix in parentheses is the filename prefix that groups rules. Categories are ordered by importance × frequency — what a model defaulting to Next.js 14/15 gets most often and most expensively wrong with Partial Prerendering goes first. This is an API-correctness skill for Next.js 16's Cache Components model, not a performance skill, so categories carry no impact tier. The order is also a learning arc: enable PPR → understand the static/dynamic boundary → cache → handle runtime data → compose whole pages → handle forms and wizards.
---
1. Setup & Mental Model (setup)
Description: How PPR is turned on in Next.js 16 and the rendering-model inversion every other rule depends on. The experimental.ppr flag and export const experimental_ppr route export were removed in Next.js 16; PPR is now the default behavior of Cache Components (cacheComponents: true). Caching also flipped from implicit to opt-in: everything is dynamic at request time unless you mark it 'use cache'. Get this wrong and nothing else applies.
2. The Suspense Boundary (shell)
Description: <Suspense> is the literal seam between the static prerendered shell and the dynamically streamed holes — not just a loading-spinner nicety. Covers where the boundary goes, why the fallback ships in the shell while children stream, the build error you get when uncached data escapes a boundary, the subtlety that Suspense does not by itself make work dynamic, and choosing boundary granularity so the static shell stays as large as possible.
3. Caching with 'use cache' (cache)
Description: Opting work back into the static shell with the 'use cache' directive. Covers the directive's levels (function / component / page / layout / file), the automatically-generated cache key, the hard constraint that runtime APIs cannot be read inside a cached scope (pass values as props), passing dynamic children/Server Actions through a cached component, cacheLife/cacheTag, and why in-memory caching does not persist across serverless invocations.
4. Runtime APIs & Non-Determinism (runtime)
Description: The inputs that force a component to render at request time. Covers cookies()/headers()/draftMode()/searchParams/params (all async now) forcing a dynamic boundary, keeping dynamic-segment routes in the static shell with generateStaticParams, and gating non-deterministic operations (Math.random(), Date.now(), crypto.randomUUID()) behind connection() so they don't break prerendering.
5. Page Composition Recipes (compose)
Description: Assembling whole pages, from the simplest to the most involved. Covers the canonical single-hole page, multiple independent holes streaming in parallel, streaming server data into an interactive Client Component via an un-awaited Promise + use(), and why you should not "fix" a build error by opting the entire app out of the static shell.
6. Forms, Mutations & Wizards (mutate)
Description: The interactive, stateful end of the spectrum. Covers choosing updateTag (read-your-writes) vs revalidateTag(tag, profile) (stale-while-revalidate) vs refresh() after a mutation, and building a multi-step wizard whose step is URL-driven, whose chrome is static, and whose in-progress field state survives navigation via React's <Activity> (used automatically by Cache Components).
Know that in-memory use cache is not durable on serverless
The model assumes a 'use cache' entry computed once is reused across all requests everywhere. By default entries are stored in-memory (LRU). On serverless each request can hit a fresh instance, so a runtime cache entry may re-execute on every request (build-time caching still works normally); on a self-hosted / long-lived server, in-memory entries do persist across requests. When you need a durable, shared runtime cache (Redis/KV), opt the scope into 'use cache: remote' — at the cost of a network roundtrip and platform fees. This distinction mainly bites runtime caching on serverless.
async function getExchangeRates() {
'use cache: remote' // shared, durable handler — survives across serverless invocations
cacheLife('minutes')
const res = await fetch('https://api.acme.com/fx')
return res.json()
}Reference: use cache — runtime caching considerations
Let arguments and closures form the cache key automatically
The model worries that caching breaks per-user or per-parameter content, or reaches for manual key arrays (unstable_cache's keyParts). With 'use cache' the key is generated automatically from the function's identity plus its serialized arguments and any closed-over variables — different inputs produce different entries. So pass the varying input as an argument and let the framework key on it; don't build keys by hand. (Arguments must be serializable: primitives, plain objects/arrays, Date/Map/Set — not class instances, functions, or JSX/children, which use the pass-through pattern instead.)
async function getOrders(customerId: string, status: 'open' | 'closed') {
'use cache'
// customerId and status are part of the cache key automatically →
// one cache entry per (customer, status) combination.
return db.order.findMany({ where: { customerId, status } })
}Reference: use cache — cache keys
Read runtime APIs outside the cache and pass values in as props
To personalize a cached component, the model calls cookies() / headers() / searchParams inside the 'use cache' function. That throws — a server-stored cached scope ('use cache' or 'use cache: remote') cannot read request APIs. (Passing the un-awaited Promise in instead is worse: the build hangs ~50s, then times out with a cache-fill error.) Read the runtime value in an uncached parent, then pass the plain value as an argument; it becomes part of the cache key, giving you one cached entry per value.
Incorrect (request API inside a cached scope — throws):
async function Recommendations() {
'use cache'
const userId = (await cookies()).get('uid')?.value // not allowed inside use cache
return <Carousel items={await getRecs(userId)} />
}Correct (read outside, pass the value in):
import { cookies } from 'next/headers'
async function RecommendationsSection() {
const userId = (await cookies()).get('uid')?.value // runtime read in an uncached component
return <Recommendations userId={userId} />
}
async function Recommendations({ userId }: { userId?: string }) {
'use cache'
// userId is part of the cache key → one entry per user
return <Carousel items={await getRecs(userId)} />
}Alternative (`'use cache: private'`, experimental): When moving the read out isn't practical, or compliance forbids storing the data server-side, the experimental 'use cache: private' directive can read cookies()/headers()/searchParams inside the cached scope. But it caches only in the browser's memory (per-user, never stored on the server, requires a cacheLife with stale ≥ 30s) and is not recommended for production. Prefer passing values as props; reach for this only when you can't.
Reference: use cache — request-time APIs constraint
Pass dynamic children and Server Actions through a cached component
The model assumes 'use cache' on a wrapper caches everything rendered inside it, so it avoids wrapping any dynamic UI — or it tries to call a passed-in Server Action inside the cached body. Neither is right: a cached component can receive children (and Server Actions) and pass them through untouched without affecting its cache entry, as long as it doesn't introspect or invoke them. This is how you keep a cached shell wrapped around dynamic content, or render a form whose action is dynamic, without losing the cache.
async function CachedShell({
children,
publish,
}: {
children: React.ReactNode
publish: () => Promise<void>
}) {
'use cache'
const nav = await getNav() // cached
return (
<div>
<SiteNav items={nav} />
{children} {/* passed through — not cached, never introspected */}
<PublishButton action={publish} /> {/* action passed through, never called here */}
</div>
)
}Reference: use cache — interleaving
Control cache lifetime and invalidation with cacheLife and cacheTag
The model controls revalidation with export const revalidate = 3600 or fetch(..., { next: { revalidate, tags } }). Inside a 'use cache' scope those don't apply. Use cacheLife(profile) for the TTL and cacheTag(tag) for on-demand invalidation, both called inside the cached function. Without cacheLife the default profile applies (≈5 min client stale, 15 min server revalidate, no time-based expiry) — frequently staler or fresher than you intend, so set it explicitly.
import { cacheLife, cacheTag } from 'next/cache'
async function getBlogPosts() {
'use cache'
cacheLife('hours') // built-in profile (also: 'minutes', 'days', 'max', or custom)
cacheTag('posts') // lets a Server Action invalidate this entry by tag later
const res = await fetch('https://api.acme.com/posts')
return res.json()
}Reference: use cache — revalidation
Mark static and cacheable work with the use cache directive
To make data or UI static, the model reaches for Next.js 14/15 mechanisms — export const revalidate, unstable_cache, or fetch(..., { next: { revalidate } }). Under Cache Components the single mechanism is the 'use cache' directive at the top of an async function (data-level), a component / page / layout (UI-level), or a whole file (caches every export — all must then be async). To prerender an entire route, add it to both the layout and the page, which are cached as separate entry points.
// Function level — cache a query
export async function getProducts() {
'use cache'
return db.product.findMany()
}
// Component level — cache a component's rendered output (goes into the static shell)
export async function FeaturedProducts() {
'use cache'
const products = await getProducts()
return <ProductGrid products={products} />
}Reference: use cache directive
Do not opt the whole app out of the static shell to silence an error
Hitting the "uncached data outside <Suspense>" build error, the model "fixes" it by wrapping <body> in <Suspense fallback={null}> in the root layout. That makes every request block on full render and discards the static shell for the entire app — the exact opposite of PPR. Wrap the actual offending leaf instead. Reserve the empty-boundary-above-<body> pattern for a route that genuinely must be fully dynamic, and isolate it with multiple root layouts. (The same care applies when generateMetadata / generateViewport read uncached data — handle it locally, not app-wide.)
Incorrect (kills the shell app-wide to silence one error):
// app/layout.tsx
import { Suspense } from 'react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<Suspense fallback={null}>
<body>{children}</body> {/* whole app now defers to request time */}
</Suspense>
</html>
)
}Correct (boundary around the component that actually reads dynamic data):
// app/layout.tsx stays static; fix it where the dynamic read happens
function HeaderCart() {
return (
<Suspense fallback={<CartSkeleton />}>
<MiniCart /> {/* the component that actually calls cookies() */}
</Suspense>
)
}Reference: Caching — opting out of the static shell
Give each independent widget its own boundary to stream in parallel
For a dashboard of independent widgets, the model awaits them one after another in a single component — each await blocks the next, so the slowest fetch gates the whole page (a request waterfall). Give each independent widget its own <Suspense>: the shell ships instantly and each widget streams in as soon as its data resolves, independently. For fetches that must be combined in one component, start them together and Promise.all them rather than awaiting in sequence.
Incorrect (sequential awaits — one slow widget blocks all):
export default async function Dashboard() {
const revenue = await getRevenue() // blocks…
const signups = await getSignups() // …then this…
const tickets = await getOpenTickets() // …then this
return (
<>
<Revenue data={revenue} />
<Signups data={signups} />
<Tickets data={tickets} />
</>
)
}Correct (independent holes stream in parallel):
import { Suspense } from 'react'
export default function Dashboard() {
return (
<>
<Suspense fallback={<CardSkeleton />}><Revenue /></Suspense>
<Suspense fallback={<CardSkeleton />}><Signups /></Suspense>
<Suspense fallback={<CardSkeleton />}><Tickets /></Suspense>
</>
)
}Reference: Fetching Data — parallel data fetching
Build the canonical page as a static shell with one dynamic hole
Faced with a page that mixes static and personalized content, the model makes the whole page either static (stale personalization) or fully dynamic (no instant shell). The canonical PPR page is neither: static chrome renders into the shell and ships instantly, and exactly the personalized/uncached leaf sits behind one <Suspense>. This is the simplest case and the baseline every other recipe builds on.
import { Suspense } from 'react'
import { cookies } from 'next/headers'
export default function HomePage() {
return (
<main>
{/* Static shell — instant */}
<Hero />
<FeatureGrid />
{/* One dynamic hole — streams in at request time */}
<Suspense fallback={<GreetingSkeleton />}>
<PersonalGreeting />
</Suspense>
</main>
)
}
async function PersonalGreeting() {
const name = (await cookies()).get('name')?.value
return name ? <p>Welcome back, {name}</p> : <p>Welcome</p>
}Reference: Partial Prerendering
Stream server data into a client component with an unawaited Promise and use
When a dynamic hole needs client interactivity (sorting, filtering, charts), the model either awaits the data in the Server Component — blocking the shell until it resolves — or fetches it client-side in useEffect, creating a request waterfall with no server streaming. Instead, start the fetch in the Server Component without awaiting, pass the Promise to a 'use client' component, and unwrap it with React's use() under a <Suspense> boundary. The data streams from the server while the client component stays interactive.
// app/reports/page.tsx — Server Component
import { Suspense } from 'react'
import { SalesChart } from './sales-chart'
export default function ReportsPage() {
const salesPromise = getSales() // do NOT await — kicks off the fetch immediately
return (
<Suspense fallback={<ChartSkeleton />}>
<SalesChart salesPromise={salesPromise} />
</Suspense>
)
}// app/reports/sales-chart.tsx — Client Component
'use client'
import { use, useState } from 'react'
export function SalesChart({ salesPromise }: { salesPromise: Promise<Sale[]> }) {
const sales = use(salesPromise) // suspends until the streamed data arrives
const [range, setRange] = useState<'30d' | '90d'>('30d')
return <Chart data={sales} range={range} onRangeChange={setRange} />
}Reference: Fetching Data — streaming with the use API
Pick updateTag for read-your-writes after a form mutation
After a form mutation the model calls single-argument revalidateTag('orders'). In Next.js 16 that form is deprecated and gives stale-while-revalidate — the user who just submitted sees the old data on the next render (the classic stale-form bug). Pick by intent: in a Server Action use updateTag(tag) for read-your-writes (expire + re-read fresh within the same request, so the user sees their change immediately); use revalidateTag(tag, profile) — now requiring a cacheLife profile like 'max' — for background SWR of content that tolerates eventual consistency; use refresh() to re-fetch only uncached data (e.g. a header count) without touching the cache.
Incorrect (deprecated single-arg — user sees the stale list):
'use server'
import { revalidateTag } from 'next/cache'
export async function createOrder(data: FormData) {
await db.order.create({ data: parseOrder(data) })
revalidateTag('orders') // SWR: the new order isn't visible on the immediate re-render
}Correct (read-your-writes):
'use server'
import { updateTag } from 'next/cache'
export async function createOrder(data: FormData) {
await db.order.create({ data: parseOrder(data) })
updateTag('orders') // the new order is visible immediately
}Drive wizard steps from the URL and let Activity preserve field state
For a multi-step form/wizard the model holds the current step in useState and re-mounts the tree per step — so refresh/back/deep-link lose the step, and moving between steps wipes entered values. Drive the active step from the URL (searchParams or route segments) so it is shareable and reload-safe; keep the wizard chrome (progress bar, layout) static in the shell with only the step body behind a <Suspense>. With Cache Components, React <Activity> (applied automatically at the route level, keeping up to 3 routes mounted with display: none) preserves each step's in-progress field state across navigations — so don't add a global store just to retain inputs. Reset stale field/success state deliberately after a successful submit (e.g. a useLayoutEffect cleanup), since the preserved DOM would otherwise show it again.
// app/onboarding/page.tsx — step lives in the URL: /onboarding?step=billing
import { Suspense } from 'react'
const STEPS = ['account', 'billing', 'review'] as const
export default async function Wizard({
searchParams,
}: {
searchParams: Promise<{ step?: string }>
}) {
const { step = 'account' } = await searchParams
return (
<>
<WizardProgress steps={STEPS} current={step} /> {/* static chrome in the shell */}
<Suspense fallback={<StepSkeleton />}>
<Step name={step} /> {/* Activity preserves fields when the user steps back */}
</Suspense>
</>
)
}Reference: Preserving UI state with Activity
Gate randomness and time behind connection or cache the value
The model freely calls Math.random(), Date.now(), or crypto.randomUUID() inside a component. Under Cache Components these can't run during prerender — their value would be frozen into the static HTML — so they error. Choose intent: defer to request time by awaiting connection() before the call (and wrap the component in <Suspense>), or 'use cache' the value so every visitor deliberately sees the same one until revalidation.
import { connection } from 'next/server'
import { Suspense } from 'react'
async function RequestId() {
await connection() // opt into request time; nothing before this runs during prerender
return <p>Request ID: {crypto.randomUUID()}</p>
}
export default function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<RequestId />
</Suspense>
)
}Reference: Caching — non-deterministic operations
Keep dynamic-segment routes in the shell with generateStaticParams
The model assumes a dynamic-segment route like app/blog/[slug] can't be part of the static shell, so it streams everything behind a boundary. In fact params prerenders statically when you provide samples via generateStaticParams: those paths render into the shell at build time, and only un-listed params fall back to request-time (which then needs a <Suspense> boundary). Supplying generateStaticParams is how you keep known dynamic routes fully static.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const slugs = await getAllPostSlugs()
return slugs.map((slug) => ({ slug })) // these paths prerender into the shell
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params // async in Next.js 16
return <Article post={await getPost(slug)} />
}Reference: Caching — runtime APIs (params)
Know that request APIs force a dynamic boundary and are async
The model reads cookies() / headers() / searchParams / params at the top of a page, often synchronously. In Next.js 16 these are async (must be awaited) and reading one marks that component request-time dynamic — so it must sit inside <Suspense> (or have its value passed into a 'use cache' child). Reading a request API at the page root therefore makes the whole route dynamic and discards the static shell. Push the read down into a small Suspense-wrapped leaf so the rest of the page stays static.
import { cookies } from 'next/headers'
import { Suspense } from 'react'
export default function AccountPage() {
return (
<>
<AccountHeader /> {/* static — stays in the shell */}
<Suspense fallback={<GreetingSkeleton />}>
<Greeting /> {/* reading cookies keeps this dynamic and inside the boundary */}
</Suspense>
</>
)
}
async function Greeting() {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return <p>Theme: {theme}</p>
}Reference: Caching — working with runtime APIs
Treat everything as dynamic by default and opt into caching
In Next.js 14/15 the model assumes routes are static by default and fetch is implicitly cached. Cache Components inverts this: all dynamic code runs at request time by default, and fetch is no longer cached. You make work static or cached by opting in with the 'use cache' directive — not by removing export const dynamic. The practical consequence: a page you assume is static is actually rendered per request unless you explicitly cache its data, so reason from "dynamic until proven cached."
// Dynamic by default: this fetch runs on every request and is NOT cached.
export default async function PricingPage() {
const res = await fetch('https://api.acme.com/plans')
const plans = await res.json()
return <PlanList plans={plans} />
}
// Opt back into the static shell explicitly:
async function getPlans() {
'use cache' // now cached and prerendered into the shell
const res = await fetch('https://api.acme.com/plans')
return res.json()
}Enable PPR with cacheComponents, not the removed experimental flags
A model defaulting to Next.js 14/15 turns on Partial Prerendering with experimental.ppr in the config and export const experimental_ppr = true per route. Both were removed in Next.js 16 — the config key is gone and the route export does nothing. PPR is now the default behavior of Cache Components, enabled once with the top-level cacheComponents: true (this also replaces the old experimental.dynamicIO flag). There is no per-route opt-in export anymore.
Incorrect (Next.js 14/15 — removed in 16):
// next.config.ts
const nextConfig = {
experimental: { ppr: 'incremental' }, // removed
}
// app/dashboard/page.tsx
export const experimental_ppr = true // removed route exportCorrect (Next.js 16):
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // PPR is the default behavior of Cache Components
}
export default nextConfigReference: Next.js 16 — Cache Components
Place Suspense boundaries around the dynamic leaf, not the page
The model wraps the whole page (or a large layout region) in one <Suspense>. That collapses the static shell down to just the fallback — you lose the instant-shell benefit PPR exists for, and a single slow dependency blocks the entire view. Push each boundary as low as possible, around the specific component that reads dynamic data, so the maximum amount of surrounding UI stays static and ships immediately.
Incorrect (boundary too high — almost nothing is static):
export default function ProductPage() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header /> {/* static, but now trapped behind the boundary */}
<ProductInfo /> {/* static */}
<LiveInventory /> {/* the only genuinely dynamic part */}
</Suspense>
)
}Correct (boundary around the dynamic leaf only):
export default function ProductPage() {
return (
<>
<Header />
<ProductInfo />
<Suspense fallback={<InventorySkeleton />}>
<LiveInventory />
</Suspense>
</>
)
}Reference: Caching — putting it all together
Know that Suspense alone does not make work dynamic
The model assumes wrapping a component in <Suspense> makes it dynamic (and that removing the boundary makes it static). It doesn't. Dynamism comes from reading runtime or uncached data, not from the boundary. A component that only does synchronous or cached work completes during prerender and lands in the static shell even when wrapped — the fallback never shows. So Suspense is necessary to contain dynamic content but does not create it: don't add a boundary hoping to defer synchronous work, and don't expect one to "make a page dynamic."
import { Suspense } from 'react'
// Purely synchronous → resolves at build time and lands in the static shell,
// Suspense or not. The fallback will never render.
function FormattedTotal({ cents }: { cents: number }) {
const amount = (cents / 100).toLocaleString('en-GB', {
style: 'currency',
currency: 'GBP',
})
return <p>{amount}</p>
}
function OrderSummary() {
// Wrapping synchronous work in Suspense changes nothing — the fallback never shows.
return (
<Suspense fallback={<Spinner />}>
<FormattedTotal cents={4999} />
</Suspense>
)
}Reference: Caching — streaming uncached data
Treat Suspense as the static/dynamic boundary, not a spinner
The model treats <Suspense> as a loading-spinner convenience. Under PPR it is the architectural seam: everything outside a boundary is prerendered into the static shell and sent to the browser instantly; at the boundary, the fallback also ships in the shell while the children stream in at request time. Where you draw the boundary literally decides what is static versus dynamic — so place it deliberately, not just "wherever something loads."
import { Suspense } from 'react'
export default function BlogPage() {
return (
<>
{/* Static — prerendered into the shell, sent instantly */}
<header>
<h1>Welcome to the Blog</h1>
</header>
{/* Boundary: the skeleton ships in the shell; LatestPosts streams at request time */}
<Suspense fallback={<BlogListSkeleton />}>
<LatestPosts />
</Suspense>
</>
)
}Reference: Fetching Data — streaming with Suspense
Wrap uncached or runtime reads in Suspense or the build fails
With Cache Components, any component that reads uncached or runtime data must be either inside <Suspense> or marked 'use cache'. Omit both and the build fails with Uncached data was accessed outside of <Suspense> — there is no silent fallback to a fully dynamic page like in Next.js 15. The error is the framework forcing you to declare the boundary; the fix is to extract the data-reading component and wrap it, not to suppress it.
Incorrect (build error — uncached fetch at the page root):
export default async function DashboardPage() {
const orders = await fetch('https://api.acme.com/orders').then((r) => r.json())
return <OrdersTable orders={orders} />
}Correct (extract the dynamic part, wrap it in a boundary):
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<Suspense fallback={<OrdersTableSkeleton />}>
<Orders />
</Suspense>
)
}
async function Orders() {
const orders = await fetch('https://api.acme.com/orders').then((r) => r.json())
return <OrdersTable orders={orders} />
}Reference: Caching — how rendering works
Related skills
FAQ
What does nextjs-ppr-patterns do?
nextjs-ppr-patterns is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use nextjs-ppr-patterns?
When you need to helps with ai & agent building tasks during ai-assisted development, or when nextjs-ppr-patterns is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
nextjs-ppr-patterns; AI & Agent Building; AI-coding skill.