next-cache-components-optimizer

Drive a Next.js route to instant navigation by setting up an agentic loop, under Cache Components / PPR, on initial load (hard navigation) and client-side navigation (soft navigation). Encode the goal as a failing @next/playwright instant() e2e and work it to green, one verified route at a time; the shipped test then guards against regression. Use when asked to make a route's navigation instant (its static shell commits immediately), fix a route whose static shell isn't prerendered/served/prefetched, grow a route's static shell or fix its slow first paint, diagnose which Suspense boundary keeps a route out of its static shell, or write the instant() e2e guard for one. Requires Next.js 16.3+ with cacheComponents; directs an upgrade if older.

Install
npx skills add 'https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-optimizer'
Download bundle ↓
canary · bfcf687Scanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
---name: next-cache-components-optimizerdescription: >  Drive a Next.js route to instant navigation by setting up an agentic loop,  under Cache Components / PPR, on initial load (hard navigation) and  client-side navigation (soft navigation). Encode the goal as a failing  @next/playwright instant() e2e and work it to green, one verified route at a  time; the shipped test then guards against regression. Use when asked to make  a route's navigation instant (its static shell commits immediately), fix a  route whose static shell isn't prerendered/served/prefetched, grow a route's  static shell or fix its slow first paint, diagnose which Suspense boundary  keeps a route out of its static shell, or write the instant() e2e guard for  one. Requires Next.js 16.3+ with cacheComponents; directs an upgrade if older.--- # next-cache-components-optimizer Set up an agentic optimization loop that drives a Next.js route from "notinstant" to "instant" and keeps it there. The loop is test-driven: encode thegoal as a failing `@next/playwright` `instant()` test, work it to green, andship the test as the regression guard. Run it once per target route. Work thephases P → G in order; each ends in a gate. Fix recipes live in two lazily-readreferences — `reference/patterns.md` (before→after for each blocker type) and`reference/real-app-patterns.md` (parallel routes, auth gates, the empty-shelland responsive-skeleton failure modes). Read one only when its phase pointsthere. ## What is invariant, and what is yours One thing here is fixed. The rest is yours. Read this before treating anycommand, platform, or env var below as a requirement. - **Invariant: the verification loop.** Maximizing the shell is worthless  unless you can prove it. The proof is an automated check: under a lock that  gates dynamic data, the static shell still commits. RED shows the gap, GREEN  shows it closed, the test ships as the regression guard. It must run on a  production-like build and must not be able to pass vacuously. Stand the loop  up once; every later optimization is then verifiable by construction. The  loop is the deliverable, not any one route.- **The mechanism: `@next/playwright` `instant()`.** This skill uses  [`instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests)  as a ruler, not a stopwatch (phase A). It comes from  `@next/playwright` (installed alongside `@playwright/test`, on the same  release line as `next`), so it isn't tied to any host. Keep it. Timing a  navigation by hand is too flaky to trust, and is the failure mode this skill  exists to prevent.- **Yours: the rig.** How you build, deploy, authenticate, configure  Playwright, and loop belongs to your stack, not to this skill. A local  `next build && next start`, a CI/staging container, and a per-push preview  deploy are equally valid rigs; the verdict comes from the build, never the  platform. Phase 0 maps the invariant onto your repo. Read every platform  name, env-var spelling, and command below as an example to translate, not a  requirement. ## Two navigations, two loading states A route reaches the user two ways, and both must be instant: - **Initial load (hard navigation)** commits the route's prerendered static  shell; deferred parts stream in behind their loading skeletons (Suspense  fallbacks, `loading.tsx`).- **Client-side navigation (soft navigation)** commits the destination's  prefetched App Shell — the `<Link>` default under Partial Prefetching —  re-rendering only the segments that change. The fix patterns are identical for both; the test differs only in how thenavigation is driven ("Driving the navigation in tests" below). The two shellscan differ; guard the one you ship, both when both matter(`reference/real-app-patterns.md`). ## Goal Maximizing the static shell is the optimization objective: the most meaningfulprerendered content commits immediately, and only genuinely per-request datastreams in afterward. The shipped test deterministically encodes **present ∧instant**; **non-blank** is the additional bar the workflow enforces byjudgment (D1/D2/E), because an `instant()` pass alone is satisfied by a blank`fallback={null}` shell (the empty-shell failure mode,`reference/real-app-patterns.md`). `instant()` is a ruler, not a stopwatch: assert that the shell appears underthe lock; do not time it. A trustworthy verdict requires a production build(phase A). The GREEN under the lock is the deterministic verdict; each gate keeps ittrustworthy. ## Reporting to the user This loop is meant to run unattended, so it doesn't stop to ask between steps.Work the navigation the user named, finish it, and stop. What matters is how youword and present the results, not how often you interrupt. The mechanics below —the rig, RED, GREEN, the gates — are your scaffolding; the user never needs tohear those words. - **Speak their language.** Describe the gap and the result in terms of what the  user sees: "navigating to the dashboard waited on the charts query before  anything painted; now the layout and skeletons paint instantly and the charts  stream in" — not RED/GREEN, the lock, or the phase letters.- **Show, don't tell.** When you report a route, drive the browser (or attach  before/after screenshots) so the user watches the shell commit immediately and  the data stream in, rather than reading a claim. Identical before and after  means the fix did nothing — roll it back.- **Present a run as a list of results the user can click through** — one line  per navigation: the route, what commits instantly, and what streams in — not a  transcript of the loop.- **Only surface a question for a genuine fork:** a fix that would change  behavior, a security-sensitive read, or a route that's dynamic by design (a  per-link-prefetch candidate, not a shell to grow). A clean instant fix is not  a fork — keep going. With no one to ask (an unattended run), don't block: take  the safe default and note the assumption — for a cache-freshness choice,  defer the read behind `<Suspense>` (always fresh, still instant) rather than  guess a `cacheLife`. ## The workflow ```- [ ] P  PREREQS      Next.js 16.3+ with cacheComponents: true; upgrade first → below- [ ] 0  SETUP        once per repo: discover + write instant-nav.rig.md     → rig-template.md- [ ] A  RIG          production build with the testing API exposed          → below- [ ] B  BASELINE     unlocked: the marker renders for the test user         → test-template.md- [ ] C  RED          locked instant(): the shell does not commit            → test-template.md- [ ] C-gate          VERIFY-RED: stop until the RED is trustworthy          → reference/red-test-robustness.md- [ ] D  FIX          push each Suspense boundary down to the data it guards → reference/patterns.md- [ ]      D1 reuse the route's existing loading UI; do not hand-build skeletons- [ ]      D2 the shell matches the real render at every breakpoint  → reference/real-app-patterns.md- [ ] E  PARITY       the refactor changed only whether the route is instant- [ ] F  DIFFERENTIAL revert only the fix → RED; re-apply → GREEN            → reference/red-test-robustness.md- [ ] G  REVIEW       PR checklist (below)``` Phases B and C build the test; only the locked test from C ships. --- ## P. PREREQUISITES: current Next.js with Cache Components The workflow depends on framework capabilities that ship with current Next.js: - **Next.js 16.3+ with `cacheComponents: true`** in `next.config.ts`. Without  Cache Components there is no static shell to optimize.- **`@next/playwright`** on the same release line as the project's `next`; it  provides `instant()`. Verify with `npm ls next @next/playwright` (or the  project's package manager) and align them if they differ. The matching  testing API is in the `next` runtime, gated by the  `experimental.exposeTestingApiInProductionBuild` config flag (phase A). If the project does not meet these, upgrade first (`npx @next/codemod upgrade`automates most of it), then enable Cache Components in `next.config.ts`: ```tsexport default { cacheComponents: true }``` Enabling the flag surfaces the blocking routes to resolve first; the[`next-cache-components-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-adoption)skill drives that adoption. Reach for this optimizer once the app builds underCache Components. This gate is deliberate: the skill targets current Next.js, and none of theverdicts below are meaningful on older versions. ## 0. SETUP: discover this project's rig, once per repo The principles in this skill are fixed; the infrastructure they run on isyours. On first use in a repository, discover how the project builds, deploys,authenticates, and tests (inspect the repository first, and ask the user onlywhat it cannot answer), then write the answers to a committed`instant-nav.rig.md`. Every later run reads that file instead ofrediscovering. The required build, test context, navigation contracts,iteration loop, and file template are in **`rig-template.md`**. If the repo has no Playwright e2e harness yet, standing up a minimal one(`@next/playwright`, a config with `baseURL`, one authenticated path) is partof this step; the loop does not assume a pre-existing suite. ## A. RIG: a production build with the testing API exposed Stand up the rig described by `instant-nav.rig.md`. Two invariants hold onevery platform: 1. **Never measure on `next dev`.** It does not prefetch, and its lock is   unreliable for blocking routes, so a dev `instant()` result is not a valid   RED or GREEN.2. **The measured build must expose the testing API.** Otherwise `instant()`   silently no-ops and the test passes vacuously (see   `reference/red-test-robustness.md`). The lock-engagement proof is the phase-C   RED itself: the unfixed target route is the known-blocking route, and its   RED under the lock shows the lock engages on this build (C-gate); the   self-validating variant in `test-template.md` is the in-band guarantee. Wire   `experimental.exposeTestingApiInProductionBuild` to a condition that is   true for every build you measure and never true in production:    ```ts   experimental: {     // Use the condition your platform provides, and record it in the rig file:     //   local:       an explicit opt-in, as below     //   generic CI:  process.env.DEPLOY_ENV === 'staging'     //   Vercel:      process.env.VERCEL_ENV === 'preview'     exposeTestingApiInProductionBuild:       process.env.EXPOSE_TESTING_API === '1',   }   ``` The rig is any production-like build that exposes the testing API: a local`next build && next start`, a CI/staging container, and a preview deploy areall equally valid; the verdict comes from the build, not the platform. See`rig-template.md` for the setup requirements. For any deployed or remote build, poll the rig's LIVENESS probe to confirm theartifact contains `HEAD` before trusting a verdict (a stale deploy reads as afalse RED or GREEN); a local `next build && next start` needs none. The probemechanism is in `rig-template.md`. ## B. BASELINE (unlocked): development scaffold, do not ship Drive the real navigation with no `instant()` lock and assert that thedestination's `SHELL_MARKER` renders **as the test user**: the account thee2e suite authenticates as (in CI, the CI account; locally, your e2e loginfixture), with its flags, plan, role, and data. This establishes that themarker is real and reachable: not flag-gated, not redirected away, not aguessed selector. The suite runs as the test account, not the author's session;that environment drift (the rig DRIFT list) is a common source ofuntrustworthy REDs. Scaffold and run command: **`test-template.md`**.**Delete this baseline before the PR.** ## C. RED (locked) + the VERIFY-RED gate Wrap the same navigation in `instant()`; assert the shell commits under thelock. A RED here is the gap. **This is the test that ships**(`test-template.md`). Prefer the self-validating variant when the route has deferred content. If theroute cannot build while blocked, or a cookie/session read stays GREEN, use theRED recipes in `reference/red-test-robustness.md`. > **C-gate: do not start optimizing until the RED is verified trustworthy.** A> RED that is red for the wrong reason sends you optimizing a route that was> never broken. The question that settles it: **does `SHELL_MARKER` render without the lock,as the test user?** Answer it by re-running phase B as the test user, not byadding assertions to the shipped test. The two-branch resolution (No → markeror environment bug; Yes → genuine gap, proceed to D), the full taxonomy ofuntrustworthy REDs, the checklist, and worked cases are in**`reference/red-test-robustness.md`**. Read it now. --- ## D. FIX: push each boundary down to the data it guards **The anti-pattern: one coarse boundary.** A single `<Suspense>` high in thetree with a page-level fallback has three costs: - The layout UI stays out of the static shell: only a throwaway copy of it is  prerendered.- The entire subtree is replaced when the boundary resolves, which discards  client state and shifts layout.- The hand-built fallback drifts out of sync as the UI changes, because it  duplicates structure that also exists in the resolved tree. **The fix: hoist the static, push the Suspense down.** Render the layout UIonce, synchronously, in the shell, and wrap each await in a boundary scoped tothe single read it guards. Only that leaf streams; the stable ancestors arereused as-is. **Rule:** if an element renders in both the fallback and the resolved tree,hoist it above the boundary. ### The most common blocker: a top-level `await` in a layout on a fallback route ```app/[locale]/(app)/[tenant]/dashboard/...       │ generateStaticParams ✅   │ no generateStaticParams → fallback route``` When any dynamic segment in the route lacks `generateStaticParams`, the routeis a fallback route, and **all** params defer to request time, including theenumerated ones. A top-level `await` in a layout (`await params`, arequest-time session read, an auth gate) then blocks the whole subtree out ofthe static shell, even when it reads a statically known param. Minimal shape: adynamic-segment route with one segment lacking `generateStaticParams`, plus atop-level `await` in the layout above it. ### The fix: defer the gate, render children Render `children` unconditionally; move the top-level `await` into a`<Suspense fallback={null}>`-wrapped child. Mechanism and before→after:`reference/real-app-patterns.md`, "Deferring an auth gate". **Fix the page below the shell too, not only the layout.** A page-leveltop-level `await` (commonly `await params`) blocks the same way the layout'sdoes, so make the page sync and push its dynamic reads into a`<Suspense>`-wrapped leaf as well. `fallback={null}` is correct only when a gate renders nothing onsuccess; for data, the fallback must be a real loading skeleton (see D1). Every other blocker shape — `cookies()`/`headers()`, uncached fetch or databasereads, `searchParams`, metadata, viewport, non-deterministic values (`Date.now()`,`Math.random()`, `crypto.randomUUID()`) — surfaces its own insight when you hitit: the build prints a `https://nextjs.org/docs/messages/<slug>` link. Thedefault build output is often abbreviated and may carry no usable stack trace;add `--debug-prerender` for the full failing frame and to report every blockerpast the first. Scope the build to the route you're on with`next build --debug-build-paths "app/<route>/**"` rather than rebuilding the app.Open that page and apply its recipe; don't improvise from the inline message. The before→after recipe for each shape is in `reference/patterns.md`, which maps it to the insightthat explains it. A few things those per-error pages don't stress for the instant-navigation goal: - **A boundary in the root layout isn't enough for client navigations.** It  passes a page-load check but leaves sibling client navigations blocking; put  the boundary below the lowest layout the source and destination routes share.- **Keep the LCP element** (usually the main heading) out of any boundary, so it  paints in the shell instead of waiting on a stream.- **A green check isn't always instant.** `export const instant = false` opts  the segment out of validation while the navigation still blocks, and a  `<Suspense>` above the document `<body>` prerenders an empty shell — neither  makes the route instant. ### D1: reuse the route's existing loading UI; do not hand-build skeletons Before writing any skeleton, search the repository for the loading UI thatalready exists for this route, in order: 1. the route's `loading.tsx`;2. an exported `*Skeleton` colocated with the component;3. the fallback already inside the component's own `<Suspense>`. The **divergence point** is the lowest layout shared by the source anddestination routes: a soft navigation re-renders only the segments below it,while an initial load re-runs every layout from the root. (Also called theshared boundary.) A `loading.tsx` above the divergence point fills onlythe initial-load shell; it sits above the soft-nav re-render scope. A`loading.tsx` at the destination segment is itself the in-tree boundary for asoft navigation into that segment and serves both. Reuse whichever boundaryactually covers the navigation you are shipping; below the divergence point,`loading.tsx` and colocated skeletons are interchangeable for that purpose. If a component has no skeleton, extract its loading markup into a colocatedskeleton beside it. Do not author a fresh skeleton that mirrors the pagelayout: it duplicates structure, drifts as the page changes, and pulls thedesign back toward a single coarse boundary. Reusing the component's ownskeleton also keeps the prefetched shell consistent with the loaded UI. See: [Streaming](https://nextjs.org/docs/app/guides/streaming#push-dynamic-access-down)and [loading states](https://nextjs.org/docs/app/guides/instant-navigation#iterate-on-loading-states). Exception: if the deferred component renders `null` for some users (forexample, a flag-gated control), `fallback={null}` is correct, since a skeletonwould flash and then collapse. ### D2: the shell must match the real render at every breakpoint A skeleton frozen to one breakpoint misaligns on the others. Fix it the sameway: one responsive component renders both the live UI and the shell (D1skeleton in its data slots), so the breakpoint switch happens once. Verify byre-asserting the shell marker at two widths(`await page.setViewportSize({ width: 1280, height: 800 })`, then`{ width: 390, height: 844 }`), or by adding a mobile Playwright project, sothis gate is as machine-checkable as the others. Detail:`reference/real-app-patterns.md`. > **D-gate: phase D is complete when the locked test from phase C passes GREEN> under the lock on the production-build rig**, not when the code compiles. That> GREEN is the deterministic stop for the fix loop; proceed to E. If the optimization adds or expands a cache boundary, follow[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating).A passing `instant()` test proves shell readiness, not mutation freshness. **When URL data can't be pushed down** (for example, the whole page depends on`params`, `searchParams`, or the full URL), there may be no meaningful staticshell to grow. Don't force one. Per-link prefetching can make the softnavigation instant, but it is outside this optimizer loop: it requires PartialPrefetching, a `<Link prefetch={true}>`, and cached URL-dependent content. See[Optimizing prefetching](https://nextjs.org/docs/app/guides/optimizing-prefetching)and pattern 10 in `reference/patterns.md` for the requirements, cost trade-offs,manual prefetch caveat, and `instant()` test gotchas. ## E. PARITY: the refactor changed only whether the route is instant The push-down is a mechanical transform, not a redesign. Afterward the routemust render the same tree, data, ordering, empty and error states, redirects,and interactions as before; the only observable difference is that the shellnow commits instantly. Verify: - **Same render output.** The moved `await`s compute and return the same  values; after the stream, the route shows the same content as the base  branch for the test user.- **Side effects still fire.** A deferred `redirect()` or `notFound()` still  happens, at request time rather than during prerender. Confirm an  unauthorized user is still redirected and a missing record still returns 404.- **Both viewports reach the real UI** after the stream (D2).- **Client state survives.** Because the layout UI is hoisted into the stable  shell rather than swapped on resolve, open menus, scroll position, focus,  and input state persist across the stream.- **Pre-existing failures stay separate.** If the route errors after the  change, reproduce it on the base branch. The same failure there is an  environment or data problem, not an optimizer regression. If anything other than whether the route is instant changed, reduce the refactor. ## F. DIFFERENTIAL Revert only the fix → RED; re-apply → GREEN; link both runs(`reference/red-test-robustness.md`). On a deployed rig, confirm each run is live(LIVENESS, phase A) before trusting its color. ## G. REVIEW (PR checklist) A green final state means nothing if the RED was never trustworthy. Thetest-trustworthiness items are the robustness checklist(`reference/red-test-robustness.md`); confirm them, then require thesePR-specific items: - [ ] **Differential shown**: RED without the fix, GREEN with it, runs linked.- [ ] **Parity confirmed (E)**: same content, redirects, and state.- [ ] **Mutations verified when applicable**: after populating any cache whose      data can be updated, a mutation test confirms the next read returns the      expected data.- [ ] **Existing loading UI reused (D1)**: no new page-mirroring skeleton.- [ ] **Shell matches the real render at desktop and mobile widths (D2)**.- [ ] **Baseline removed**: only the locked test from C remains. **Stop condition for the whole workflow:** the locked test from C is GREEN onthe rig, the differential (F) holds, and every item above is checked. Until allthree hold, you are not done. ## Driving the navigation in tests - **Soft navigation** → drive a real `<Link>` click. **Initial load** → use  `page.goto()` inside `instant()` with the `baseURL` option. Do not substitute  `goto` for a soft-nav verdict; the two shells can differ  (`test-template.md`, `reference/real-app-patterns.md`).- With parallel routes, only the slots that change re-render on a soft  navigation; client-rendered navigation UI does not re-render at all. Do not  chase a slot the navigation never touches  (`reference/real-app-patterns.md`). ## Files - `rig-template.md`: phase 0 production build, test context, navigation  contract, and unattended loop discovery.- `test-template.md`: the shipped `instant()` specs for both navigation  types (phase C), and the delete-before-PR baseline scaffold (phase B).- `reference/red-test-robustness.md`: the C-gate and phase F. The taxonomy of  untrustworthy REDs, the checklist, the differential recipe, the vacuous-pass  failure mode, and worked cases.- `reference/real-app-patterns.md`: parallel routes, deferring an auth gate,  initial-load vs soft-navigation shells, the empty-shell failure mode, the  responsive-skeleton mismatch, edge cases. ## After optimization Once the target routes are instant, check whether the app has already adoptedPartial Prefetching (`partialPrefetching: true`, or the relevant destinationstill uses `prefetch = 'partial'` during an incremental rollout). Make that check mechanically: ```bashrg -n "partialPrefetching|prefetch\s*=\s*['\"]partial['\"]" --glob 'next.config.*' --glob 'app/**' --glob 'src/app/**'``` If `partialPrefetching: true` is in config, the app is globally adopted. If only`prefetch = 'partial'` matches, treat those destination segments as adoptedduring an incremental rollout and keep checking any other target routes. - **Already adopted:** for any URL-data route that stopped at the limitation  above, consider a targeted `<Link prefetch={true}>` on the links where having  that URL-specific content ready before the click is worth the per-link server  work. Keep the default link behavior everywhere else so the shared App Shell  remains the low-cost baseline.- **Not adopted yet:** recommend  [`next-partial-prefetching-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-partial-prefetching-adoption).  That skill moves the app onto the better prefetching model: shared App Shell  prefetches by default, fewer duplicated full-prefetch requests for visible  links, a link audit for existing `<Link prefetch={true}>` usage, and optional  per-link prefetching only where URL-specific content is worth the  extra server work. 
Discovery context

Discovered by repository scan. No exact path reference found in the snapshot’s root AGENTS.md.