SKILL.md
SKILL.mdBrowse 6 files
6,010 tokens
25,280 bytes
Token encoding: o200k_base
Snapshot bfcf687
1---2name: next-cache-components-optimizer3description: >4 Drive a Next.js route to instant navigation by setting up an agentic loop,5 under Cache Components / PPR, on initial load (hard navigation) and6 client-side navigation (soft navigation). Encode the goal as a failing7 @next/playwright instant() e2e and work it to green, one verified route at a8 time; the shipped test then guards against regression. Use when asked to make9 a route's navigation instant (its static shell commits immediately), fix a10 route whose static shell isn't prerendered/served/prefetched, grow a route's11 static shell or fix its slow first paint, diagnose which Suspense boundary12 keeps a route out of its static shell, or write the instant() e2e guard for13 one. Requires Next.js 16.3+ with cacheComponents; directs an upgrade if older.14---15 16# next-cache-components-optimizer17 18Set up an agentic optimization loop that drives a Next.js route from "not19instant" to "instant" and keeps it there. The loop is test-driven: encode the20goal as a failing `@next/playwright` `instant()` test, work it to green, and21ship the test as the regression guard. Run it once per target route. Work the22phases P → G in order; each ends in a gate. Fix recipes live in two lazily-read23references — `reference/patterns.md` (before→after for each blocker type) and24`reference/real-app-patterns.md` (parallel routes, auth gates, the empty-shell25and responsive-skeleton failure modes). Read one only when its phase points26there.27 28## What is invariant, and what is yours29 30One thing here is fixed. The rest is yours. Read this before treating any31command, platform, or env var below as a requirement.32 33- **Invariant: the verification loop.** Maximizing the shell is worthless34 unless you can prove it. The proof is an automated check: under a lock that35 gates dynamic data, the static shell still commits. RED shows the gap, GREEN36 shows it closed, the test ships as the regression guard. It must run on a37 production-like build and must not be able to pass vacuously. Stand the loop38 up once; every later optimization is then verifiable by construction. The39 loop is the deliverable, not any one route.40- **The mechanism: `@next/playwright` `instant()`.** This skill uses41 [`instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests)42 as a ruler, not a stopwatch (phase A). It comes from43 `@next/playwright` (installed alongside `@playwright/test`, on the same44 release line as `next`), so it isn't tied to any host. Keep it. Timing a45 navigation by hand is too flaky to trust, and is the failure mode this skill46 exists to prevent.47- **Yours: the rig.** How you build, deploy, authenticate, configure48 Playwright, and loop belongs to your stack, not to this skill. A local49 `next build && next start`, a CI/staging container, and a per-push preview50 deploy are equally valid rigs; the verdict comes from the build, never the51 platform. Phase 0 maps the invariant onto your repo. Read every platform52 name, env-var spelling, and command below as an example to translate, not a53 requirement.54 55## Two navigations, two loading states56 57A route reaches the user two ways, and both must be instant:58 59- **Initial load (hard navigation)** commits the route's prerendered static60 shell; deferred parts stream in behind their loading skeletons (Suspense61 fallbacks, `loading.tsx`).62- **Client-side navigation (soft navigation)** commits the destination's63 prefetched App Shell — the `<Link>` default under Partial Prefetching —64 re-rendering only the segments that change.65 66The fix patterns are identical for both; the test differs only in how the67navigation is driven ("Driving the navigation in tests" below). The two shells68can differ; guard the one you ship, both when both matter69(`reference/real-app-patterns.md`).70 71## Goal72 73Maximizing the static shell is the optimization objective: the most meaningful74prerendered content commits immediately, and only genuinely per-request data75streams in afterward. The shipped test deterministically encodes **present ∧76instant**; **non-blank** is the additional bar the workflow enforces by77judgment (D1/D2/E), because an `instant()` pass alone is satisfied by a blank78`fallback={null}` shell (the empty-shell failure mode,79`reference/real-app-patterns.md`).80 81`instant()` is a ruler, not a stopwatch: assert that the shell appears under82the lock; do not time it. A trustworthy verdict requires a production build83(phase A).84 85The GREEN under the lock is the deterministic verdict; each gate keeps it86trustworthy.87 88## Reporting to the user89 90This loop is meant to run unattended, so it doesn't stop to ask between steps.91Work the navigation the user named, finish it, and stop. What matters is how you92word and present the results, not how often you interrupt. The mechanics below —93the rig, RED, GREEN, the gates — are your scaffolding; the user never needs to94hear those words.95 96- **Speak their language.** Describe the gap and the result in terms of what the97 user sees: "navigating to the dashboard waited on the charts query before98 anything painted; now the layout and skeletons paint instantly and the charts99 stream in" — not RED/GREEN, the lock, or the phase letters.100- **Show, don't tell.** When you report a route, drive the browser (or attach101 before/after screenshots) so the user watches the shell commit immediately and102 the data stream in, rather than reading a claim. Identical before and after103 means the fix did nothing — roll it back.104- **Present a run as a list of results the user can click through** — one line105 per navigation: the route, what commits instantly, and what streams in — not a106 transcript of the loop.107- **Only surface a question for a genuine fork:** a fix that would change108 behavior, a security-sensitive read, or a route that's dynamic by design (a109 per-link-prefetch candidate, not a shell to grow). A clean instant fix is not110 a fork — keep going. With no one to ask (an unattended run), don't block: take111 the safe default and note the assumption — for a cache-freshness choice,112 defer the read behind `<Suspense>` (always fresh, still instant) rather than113 guess a `cacheLife`.114 115## The workflow116 117```118- [ ] P PREREQS Next.js 16.3+ with cacheComponents: true; upgrade first → below119- [ ] 0 SETUP once per repo: discover + write instant-nav.rig.md → rig-template.md120- [ ] A RIG production build with the testing API exposed → below121- [ ] B BASELINE unlocked: the marker renders for the test user → test-template.md122- [ ] C RED locked instant(): the shell does not commit → test-template.md123- [ ] C-gate VERIFY-RED: stop until the RED is trustworthy → reference/red-test-robustness.md124- [ ] D FIX push each Suspense boundary down to the data it guards → reference/patterns.md125- [ ] D1 reuse the route's existing loading UI; do not hand-build skeletons126- [ ] D2 the shell matches the real render at every breakpoint → reference/real-app-patterns.md127- [ ] E PARITY the refactor changed only whether the route is instant128- [ ] F DIFFERENTIAL revert only the fix → RED; re-apply → GREEN → reference/red-test-robustness.md129- [ ] G REVIEW PR checklist (below)130```131 132Phases B and C build the test; only the locked test from C ships.133 134---135 136## P. PREREQUISITES: current Next.js with Cache Components137 138The workflow depends on framework capabilities that ship with current Next.js:139 140- **Next.js 16.3+ with `cacheComponents: true`** in `next.config.ts`. Without141 Cache Components there is no static shell to optimize.142- **`@next/playwright`** on the same release line as the project's `next`; it143 provides `instant()`. Verify with `npm ls next @next/playwright` (or the144 project's package manager) and align them if they differ. The matching145 testing API is in the `next` runtime, gated by the146 `experimental.exposeTestingApiInProductionBuild` config flag (phase A).147 148If the project does not meet these, upgrade first (`npx @next/codemod upgrade`149automates most of it), then enable Cache Components in `next.config.ts`:150 151```ts152export default { cacheComponents: true }153```154 155Enabling the flag surfaces the blocking routes to resolve first; the156[`next-cache-components-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-adoption)157skill drives that adoption. Reach for this optimizer once the app builds under158Cache Components.159 160This gate is deliberate: the skill targets current Next.js, and none of the161verdicts below are meaningful on older versions.162 163## 0. SETUP: discover this project's rig, once per repo164 165The principles in this skill are fixed; the infrastructure they run on is166yours. On first use in a repository, discover how the project builds, deploys,167authenticates, and tests (inspect the repository first, and ask the user only168what it cannot answer), then write the answers to a committed169`instant-nav.rig.md`. Every later run reads that file instead of170rediscovering. The required build, test context, navigation contracts,171iteration loop, and file template are in **`rig-template.md`**.172 173If the repo has no Playwright e2e harness yet, standing up a minimal one174(`@next/playwright`, a config with `baseURL`, one authenticated path) is part175of this step; the loop does not assume a pre-existing suite.176 177## A. RIG: a production build with the testing API exposed178 179Stand up the rig described by `instant-nav.rig.md`. Two invariants hold on180every platform:181 1821. **Never measure on `next dev`.** It does not prefetch, and its lock is183 unreliable for blocking routes, so a dev `instant()` result is not a valid184 RED or GREEN.1852. **The measured build must expose the testing API.** Otherwise `instant()`186 silently no-ops and the test passes vacuously (see187 `reference/red-test-robustness.md`). The lock-engagement proof is the phase-C188 RED itself: the unfixed target route is the known-blocking route, and its189 RED under the lock shows the lock engages on this build (C-gate); the190 self-validating variant in `test-template.md` is the in-band guarantee. Wire191 `experimental.exposeTestingApiInProductionBuild` to a condition that is192 true for every build you measure and never true in production:193 194 ```ts195 experimental: {196 // Use the condition your platform provides, and record it in the rig file:197 // local: an explicit opt-in, as below198 // generic CI: process.env.DEPLOY_ENV === 'staging'199 // Vercel: process.env.VERCEL_ENV === 'preview'200 exposeTestingApiInProductionBuild:201 process.env.EXPOSE_TESTING_API === '1',202 }203 ```204 205The rig is any production-like build that exposes the testing API: a local206`next build && next start`, a CI/staging container, and a preview deploy are207all equally valid; the verdict comes from the build, not the platform. See208`rig-template.md` for the setup requirements.209 210For any deployed or remote build, poll the rig's LIVENESS probe to confirm the211artifact contains `HEAD` before trusting a verdict (a stale deploy reads as a212false RED or GREEN); a local `next build && next start` needs none. The probe213mechanism is in `rig-template.md`.214 215## B. BASELINE (unlocked): development scaffold, do not ship216 217Drive the real navigation with no `instant()` lock and assert that the218destination's `SHELL_MARKER` renders **as the test user**: the account the219e2e suite authenticates as (in CI, the CI account; locally, your e2e login220fixture), with its flags, plan, role, and data. This establishes that the221marker is real and reachable: not flag-gated, not redirected away, not a222guessed selector. The suite runs as the test account, not the author's session;223that environment drift (the rig DRIFT list) is a common source of224untrustworthy REDs. Scaffold and run command: **`test-template.md`**.225**Delete this baseline before the PR.**226 227## C. RED (locked) + the VERIFY-RED gate228 229Wrap the same navigation in `instant()`; assert the shell commits under the230lock. A RED here is the gap. **This is the test that ships**231(`test-template.md`).232 233Prefer the self-validating variant when the route has deferred content. If the234route cannot build while blocked, or a cookie/session read stays GREEN, use the235RED recipes in `reference/red-test-robustness.md`.236 237> **C-gate: do not start optimizing until the RED is verified trustworthy.** A238> RED that is red for the wrong reason sends you optimizing a route that was239> never broken.240 241The question that settles it: **does `SHELL_MARKER` render without the lock,242as the test user?** Answer it by re-running phase B as the test user, not by243adding assertions to the shipped test. The two-branch resolution (No → marker244or environment bug; Yes → genuine gap, proceed to D), the full taxonomy of245untrustworthy REDs, the checklist, and worked cases are in246**`reference/red-test-robustness.md`**. Read it now.247 248---249 250## D. FIX: push each boundary down to the data it guards251 252**The anti-pattern: one coarse boundary.** A single `<Suspense>` high in the253tree with a page-level fallback has three costs:254 255- The layout UI stays out of the static shell: only a throwaway copy of it is256 prerendered.257- The entire subtree is replaced when the boundary resolves, which discards258 client state and shifts layout.259- The hand-built fallback drifts out of sync as the UI changes, because it260 duplicates structure that also exists in the resolved tree.261 262**The fix: hoist the static, push the Suspense down.** Render the layout UI263once, synchronously, in the shell, and wrap each await in a boundary scoped to264the single read it guards. Only that leaf streams; the stable ancestors are265reused as-is.266 267**Rule:** if an element renders in both the fallback and the resolved tree,268hoist it above the boundary.269 270### The most common blocker: a top-level `await` in a layout on a fallback route271 272```273app/[locale]/(app)/[tenant]/dashboard/...274 │ generateStaticParams ✅ │ no generateStaticParams → fallback route275```276 277When any dynamic segment in the route lacks `generateStaticParams`, the route278is a fallback route, and **all** params defer to request time, including the279enumerated ones. A top-level `await` in a layout (`await params`, a280request-time session read, an auth gate) then blocks the whole subtree out of281the static shell, even when it reads a statically known param. Minimal shape: a282dynamic-segment route with one segment lacking `generateStaticParams`, plus a283top-level `await` in the layout above it.284 285### The fix: defer the gate, render children286 287Render `children` unconditionally; move the top-level `await` into a288`<Suspense fallback={null}>`-wrapped child. Mechanism and before→after:289`reference/real-app-patterns.md`, "Deferring an auth gate".290 291**Fix the page below the shell too, not only the layout.** A page-level292top-level `await` (commonly `await params`) blocks the same way the layout's293does, so make the page sync and push its dynamic reads into a294`<Suspense>`-wrapped leaf as well. `fallback={null}` is correct only when a gate renders nothing on295success; for data, the fallback must be a real loading skeleton (see D1).296 297Every other blocker shape — `cookies()`/`headers()`, uncached fetch or database298reads, `searchParams`, metadata, viewport, non-deterministic values (`Date.now()`,299`Math.random()`, `crypto.randomUUID()`) — surfaces its own insight when you hit300it: the build prints a `https://nextjs.org/docs/messages/<slug>` link. The301default build output is often abbreviated and may carry no usable stack trace;302add `--debug-prerender` for the full failing frame and to report every blocker303past the first. Scope the build to the route you're on with304`next build --debug-build-paths "app/<route>/**"` rather than rebuilding the app.305Open that page and apply its recipe; don't improvise from the inline message.306 307The before→after recipe for each shape is in `reference/patterns.md`, which maps it to the insight308that explains it.309 310A few things those per-error pages don't stress for the instant-navigation goal:311 312- **A boundary in the root layout isn't enough for client navigations.** It313 passes a page-load check but leaves sibling client navigations blocking; put314 the boundary below the lowest layout the source and destination routes share.315- **Keep the LCP element** (usually the main heading) out of any boundary, so it316 paints in the shell instead of waiting on a stream.317- **A green check isn't always instant.** `export const instant = false` opts318 the segment out of validation while the navigation still blocks, and a319 `<Suspense>` above the document `<body>` prerenders an empty shell — neither320 makes the route instant.321 322### D1: reuse the route's existing loading UI; do not hand-build skeletons323 324Before writing any skeleton, search the repository for the loading UI that325already exists for this route, in order:326 3271. the route's `loading.tsx`;3282. an exported `*Skeleton` colocated with the component;3293. the fallback already inside the component's own `<Suspense>`.330 331The **divergence point** is the lowest layout shared by the source and332destination routes: a soft navigation re-renders only the segments below it,333while an initial load re-runs every layout from the root. (Also called the334shared boundary.) A `loading.tsx` above the divergence point fills only335the initial-load shell; it sits above the soft-nav re-render scope. A336`loading.tsx` at the destination segment is itself the in-tree boundary for a337soft navigation into that segment and serves both. Reuse whichever boundary338actually covers the navigation you are shipping; below the divergence point,339`loading.tsx` and colocated skeletons are interchangeable for that purpose.340 341If a component has no skeleton, extract its loading markup into a colocated342skeleton beside it. Do not author a fresh skeleton that mirrors the page343layout: it duplicates structure, drifts as the page changes, and pulls the344design back toward a single coarse boundary. Reusing the component's own345skeleton also keeps the prefetched shell consistent with the loaded UI.346 347See: [Streaming](https://nextjs.org/docs/app/guides/streaming#push-dynamic-access-down)348and [loading states](https://nextjs.org/docs/app/guides/instant-navigation#iterate-on-loading-states).349 350Exception: if the deferred component renders `null` for some users (for351example, a flag-gated control), `fallback={null}` is correct, since a skeleton352would flash and then collapse.353 354### D2: the shell must match the real render at every breakpoint355 356A skeleton frozen to one breakpoint misaligns on the others. Fix it the same357way: one responsive component renders both the live UI and the shell (D1358skeleton in its data slots), so the breakpoint switch happens once. Verify by359re-asserting the shell marker at two widths360(`await page.setViewportSize({ width: 1280, height: 800 })`, then361`{ width: 390, height: 844 }`), or by adding a mobile Playwright project, so362this gate is as machine-checkable as the others. Detail:363`reference/real-app-patterns.md`.364 365> **D-gate: phase D is complete when the locked test from phase C passes GREEN366> under the lock on the production-build rig**, not when the code compiles. That367> GREEN is the deterministic stop for the fix loop; proceed to E.368 369If the optimization adds or expands a cache boundary, follow370[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating).371A passing `instant()` test proves shell readiness, not mutation freshness.372 373**When URL data can't be pushed down** (for example, the whole page depends on374`params`, `searchParams`, or the full URL), there may be no meaningful static375shell to grow. Don't force one. Per-link prefetching can make the soft376navigation instant, but it is outside this optimizer loop: it requires Partial377Prefetching, a `<Link prefetch={true}>`, and cached URL-dependent content. See378[Optimizing prefetching](https://nextjs.org/docs/app/guides/optimizing-prefetching)379and pattern 10 in `reference/patterns.md` for the requirements, cost trade-offs,380manual prefetch caveat, and `instant()` test gotchas.381 382## E. PARITY: the refactor changed only whether the route is instant383 384The push-down is a mechanical transform, not a redesign. Afterward the route385must render the same tree, data, ordering, empty and error states, redirects,386and interactions as before; the only observable difference is that the shell387now commits instantly. Verify:388 389- **Same render output.** The moved `await`s compute and return the same390 values; after the stream, the route shows the same content as the base391 branch for the test user.392- **Side effects still fire.** A deferred `redirect()` or `notFound()` still393 happens, at request time rather than during prerender. Confirm an394 unauthorized user is still redirected and a missing record still returns 404.395- **Both viewports reach the real UI** after the stream (D2).396- **Client state survives.** Because the layout UI is hoisted into the stable397 shell rather than swapped on resolve, open menus, scroll position, focus,398 and input state persist across the stream.399- **Pre-existing failures stay separate.** If the route errors after the400 change, reproduce it on the base branch. The same failure there is an401 environment or data problem, not an optimizer regression.402 403If anything other than whether the route is instant changed, reduce the refactor.404 405## F. DIFFERENTIAL406 407Revert only the fix → RED; re-apply → GREEN; link both runs408(`reference/red-test-robustness.md`). On a deployed rig, confirm each run is live409(LIVENESS, phase A) before trusting its color.410 411## G. REVIEW (PR checklist)412 413A green final state means nothing if the RED was never trustworthy. The414test-trustworthiness items are the robustness checklist415(`reference/red-test-robustness.md`); confirm them, then require these416PR-specific items:417 418- [ ] **Differential shown**: RED without the fix, GREEN with it, runs linked.419- [ ] **Parity confirmed (E)**: same content, redirects, and state.420- [ ] **Mutations verified when applicable**: after populating any cache whose421 data can be updated, a mutation test confirms the next read returns the422 expected data.423- [ ] **Existing loading UI reused (D1)**: no new page-mirroring skeleton.424- [ ] **Shell matches the real render at desktop and mobile widths (D2)**.425- [ ] **Baseline removed**: only the locked test from C remains.426 427**Stop condition for the whole workflow:** the locked test from C is GREEN on428the rig, the differential (F) holds, and every item above is checked. Until all429three hold, you are not done.430 431## Driving the navigation in tests432 433- **Soft navigation** → drive a real `<Link>` click. **Initial load** → use434 `page.goto()` inside `instant()` with the `baseURL` option. Do not substitute435 `goto` for a soft-nav verdict; the two shells can differ436 (`test-template.md`, `reference/real-app-patterns.md`).437- With parallel routes, only the slots that change re-render on a soft438 navigation; client-rendered navigation UI does not re-render at all. Do not439 chase a slot the navigation never touches440 (`reference/real-app-patterns.md`).441 442## Files443 444- `rig-template.md`: phase 0 production build, test context, navigation445 contract, and unattended loop discovery.446- `test-template.md`: the shipped `instant()` specs for both navigation447 types (phase C), and the delete-before-PR baseline scaffold (phase B).448- `reference/red-test-robustness.md`: the C-gate and phase F. The taxonomy of449 untrustworthy REDs, the checklist, the differential recipe, the vacuous-pass450 failure mode, and worked cases.451- `reference/real-app-patterns.md`: parallel routes, deferring an auth gate,452 initial-load vs soft-navigation shells, the empty-shell failure mode, the453 responsive-skeleton mismatch, edge cases.454 455## After optimization456 457Once the target routes are instant, check whether the app has already adopted458Partial Prefetching (`partialPrefetching: true`, or the relevant destination459still uses `prefetch = 'partial'` during an incremental rollout).460 461Make that check mechanically:462 463```bash464rg -n "partialPrefetching|prefetch\s*=\s*['\"]partial['\"]" --glob 'next.config.*' --glob 'app/**' --glob 'src/app/**'465```466 467If `partialPrefetching: true` is in config, the app is globally adopted. If only468`prefetch = 'partial'` matches, treat those destination segments as adopted469during an incremental rollout and keep checking any other target routes.470 471- **Already adopted:** for any URL-data route that stopped at the limitation472 above, consider a targeted `<Link prefetch={true}>` on the links where having473 that URL-specific content ready before the click is worth the per-link server474 work. Keep the default link behavior everywhere else so the shared App Shell475 remains the low-cost baseline.476- **Not adopted yet:** recommend477 [`next-partial-prefetching-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-partial-prefetching-adoption).478 That skill moves the app onto the better prefetching model: shared App Shell479 prefetches by default, fewer duplicated full-prefetch requests for visible480 links, a link audit for existing `<Link prefetch={true}>` usage, and optional481 per-link prefetching only where URL-specific content is worth the482 extra server work.483 Discovery context
Discovered by repository scan. No exact path reference found in the snapshot’s root AGENTS.md.