Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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/prefet
.claude/skills/asymmetric-al-next-cache-components-optimizer/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 223% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 205% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 214% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 118% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 216% | 0% |
Set up an agentic optimization loop that drives a Next.js route from "not instant" to "instant" and keeps it there. The loop is test-driven: encode the goal as a failing @next/playwright instant() test, work it to green, and ship the test as the regression guard. Run it once per target route. Work the phases P → G in order; each ends in a gate. Fix recipes live in two lazily-read references — reference/patterns.md (before→after for each blocker type) and reference/real-app-patterns.md (parallel routes, auth gates, the empty-shell and responsive-skeleton failure modes). Read one only when its phase points there.
One thing here is fixed. The rest is yours. Read this before treating any command, platform, or env var below as a requirement.
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.
@next/playwright instant(). This skill locks withinstant(): 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.
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.
A route reaches the user two ways, and both must be instant:
shell; deferred parts stream in behind their loading skeletons (Suspense fallbacks, loading.tsx).
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 the navigation is driven ("Driving the navigation in tests" below). The two shells can differ; guard the one you ship, both when both matter (reference/real-app-patterns.md).
Maximizing the static shell is the optimization objective: the most meaningful prerendered content commits immediately, and only genuinely per-request data streams in afterward. The shipped test deterministically encodes present ∧ instant; non-blank is the additional bar the workflow enforces by judgment (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 under the 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 it trustworthy.
This loop is meant to run unattended — ideally across many navigations in one pass — so it doesn't stop to ask after each route. What matters is how you word 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 to hear those words.
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.
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.
what's now instant, what streams — not a transcript of the loop.
behavior, a security-sensitive read, or a route that's dynamic by design (a runtime-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.
- [ ] 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.
The workflow depends on framework capabilities that ship with current Next.js:
cacheComponents: true in next.config.ts. WithoutCache Components there is no static shell to optimize.
@next/playwright on the same release line as the project's next; itprovides 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 skill drives that adoption. Reach for this optimizer once the app builds under Cache Components.
This gate is deliberate: the skill targets current Next.js, and none of the verdicts below are meaningful on older versions.
The principles in this skill are fixed; the infrastructure they run on is yours. On first use in a repository, discover how the project builds, deploys, authenticates, and tests (inspect the repository first, and ask the user only what it cannot answer), then write the answers to a committed instant-nav.rig.md. Every later run reads that file instead of rediscovering. The six questions (BUILD / EXPOSE / RUN / TEST USER / DRIFT / LOOP), the file template, and filled examples (local-only, generic CI + container, preview deploy) 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 part of this step; the loop does not assume a pre-existing suite.
Stand up the rig described by instant-nav.rig.md. Two invariants hold on every platform:
next dev. It does not prefetch, and its lock isunreliable for blocking routes, so a dev instant() result is not a valid RED or GREEN.
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 are all equally valid; the verdict comes from the build, not the platform. See rig-template.md for filled examples.
For any deployed or remote build, poll the rig's LIVENESS probe to confirm the artifact contains HEAD before trusting a verdict (a stale deploy reads as a false RED or GREEN); a local next build && next start needs none. The probe mechanism is in rig-template.md (question 6).
Drive the real navigation with no instant() lock and assert that the destination's SHELL_MARKER renders as the test user: the account the e2e suite authenticates as (in CI, the CI account; locally, your e2e login fixture), with its flags, plan, role, and data. This establishes that the marker is real and reachable: not flag-gated, not redirected away, not a guessed selector. The suite runs as the test account, not the author's session; that environment drift (the rig DRIFT list) is a common source of untrustworthy REDs. Scaffold and run command: test-template.md. Delete this baseline before the PR.
Wrap the same navigation in instant(); assert the shell commits under the lock. A RED here is the gap. This is the test that ships (test-template.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 by adding assertions to the shipped test. The two-branch resolution (No → marker or environment bug; Yes → genuine gap, proceed to D), the full taxonomy of untrustworthy REDs, the checklist, and worked cases are in reference/red-test-robustness.md. Read it now.
The anti-pattern: one coarse boundary. A single <Suspense> high in the tree with a page-level fallback has three costs:
prerendered.
client state and shifts layout.
duplicates structure that also exists in the resolved tree.
The fix: hoist the static, push the Suspense down. Render the layout UI once, synchronously, in the shell, and wrap each await in a boundary scoped to the single read it guards. Only that leaf streams; the stable ancestors are reused as-is.
Rule: if an element renders in both the fallback and the resolved tree, hoist it above the boundary.
await in a layout on a fallback routeapp/[locale]/(app)/[tenant]/dashboard/...
│ generateStaticParams ✅ │ no generateStaticParams → fallback routeWhen any dynamic segment in the route lacks generateStaticParams, the route is a fallback route, and all params defer to request time, including the enumerated ones. A top-level await in a layout (await params, a request-time session read, an auth gate) then blocks the whole subtree out of the static shell, even when it reads a statically known param. Minimal shape: a dynamic-segment route with one segment lacking generateStaticParams, plus a top-level await in the layout above it.
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-level top-level await (commonly await params) blocks the same way the layout's does, 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 on success; for data, the fallback must be a real loading skeleton (see D1).
Every other blocker shape — cookies()/headers(), uncached fetch or database reads, searchParams, metadata, viewport, non-deterministic values (Date.now(), Math.random(), crypto.randomUUID()) — surfaces its own insight when you hit it: the build prints a https://nextjs.org/docs/messages/<slug> link. The default build output is often abbreviated and may carry no usable stack trace; add --debug-prerender for the full failing frame and to report every blocker past 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 insight that explains it.
A few things those per-error pages don't stress for the instant-navigation goal:
passes a page-load check but leaves sibling client navigations blocking; put the boundary below the lowest layout the source and destination routes share.
paints in the shell instead of waiting on a stream.
export const instant = false optsthe 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.
Before writing any skeleton, search the repository for the loading UI that already exists for this route, in order:
loading.tsx;*Skeleton colocated with the component;<Suspense>.The divergence point is the lowest layout shared by the source and destination routes: a soft navigation re-renders only the segments below it, while an initial load re-runs every layout from the root. (Also called the shared boundary.) A loading.tsx above the divergence point fills only the 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 a soft navigation into that segment and serves both. Reuse whichever boundary actually 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 colocated skeleton beside it. Do not author a fresh skeleton that mirrors the page layout: it duplicates structure, drifts as the page changes, and pulls the design back toward a single coarse boundary. Reusing the component's own skeleton also keeps the prefetched shell consistent with the loaded UI.
Exception: if the deferred component renders null for some users (for example, a flag-gated control), fallback={null} is correct, since a skeleton would flash and then collapse.
A skeleton frozen to one breakpoint misaligns on the others. Fix it the same way: one responsive component renders both the live UI and the shell (D1 skeleton in its data slots), so the breakpoint switch happens once. Verify by re-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, so this 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.
When the read can't be pushed down (an ID minted per request, an all-dynamic page, a per-request auth/scope read the whole subtree needs), there is no shell to grow. Don't force one: opt the route into runtime prefetching so the prefetch runs the dynamic render ahead of the click and the soft nav commits the real content. See Runtime Prefetching for the mechanism (prefetch = 'allow-runtime' on the route plus a full <Link prefetch={true}>) and the dynamic-data-during-prefetching insight for adoption. The instant()-specific gotchas the docs don't cover:
runtime spawn (subtreeHasSpeculativePrefetch); only prefetch={true} / kind: 'full' reaches it. If you set prefetch = 'allow-runtime' and it's still RED, the link is doing an auto prefetch.
allow-runtime on the content segment butnothing on a sibling @header/@sidebar leaf leaves the route's runtime entry incomplete, so the lock falls back to the shell. Flip every leaf together.
prefetched — the prefetch receives the redirect, not the tree. Point the link and the prefetch at the final URL.
data; issuing it on hover for every link is wasteful. Scope kind: 'full' to the runtime-prefetch targets only.
client component, so its text isn't in the prefetch response. Assert a data-testid that renders when the client subtree commits.
The push-down is a mechanical transform, not a redesign. Afterward the route must render the same tree, data, ordering, empty and error states, redirects, and interactions as before; the only observable difference is that the shell now commits instantly. Verify:
awaits compute and return the samevalues; after the stream, the route shows the same content as the base branch for the test user.
redirect() or notFound() stillhappens, at request time rather than during prerender. Confirm an unauthorized user is still redirected and a missing record still returns 404.
shell rather than swapped on resolve, open menus, scroll position, focus, and input state persist across the stream.
If anything other than whether the route is instant changed, reduce the refactor.
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.
A green final state means nothing if the RED was never trustworthy. The test-trustworthiness items are the robustness checklist (reference/red-test-robustness.md); confirm them, then require these PR-specific items:
Stop condition for the whole workflow: the locked test from C is GREEN on the rig, the differential (F) holds, and every item above is checked. Until all three hold, you are not done.
<Link> click. Initial load → usepage.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).
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).
rig-template.md: phase 0, the six-question rig discovery, theinstant-nav.rig.md template, and filled examples (local-only, generic CI, preview deploy).
test-template.md: the shipped instant() specs for both navigationtypes (phase C), and the delete-before-PR baseline scaffold (phase B).
reference/red-test-robustness.md: the C-gate and phase F. The taxonomy ofuntrustworthy 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.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 20,066 | 6,503 | -68% | 1 | 1 | 0% | 3,954 | 6,242 | +58% | 0 | 0 | — |
case-02 | fail→fail | 25,586 | 3,582 | -86% | 1 | 1 | 0% | 5,205 | 6,310 | +21% | 0 | 0 | — |
case-03 | fail→fail | 16,793 | 6,185 | -63% | 1 | 1 | 0% | 3,266 | 6,285 | +92% | 0 | 0 | — |
case-04 | pass→pass | 8,233 | 8,510 | +3% | 1 | 1 | 0% | 1,673 | 7,521 | +350% | 0 | 0 | — |
case-05 | fail→fail | 20,388 | 13,831 | -32% | 1 | 1 | 0% | 3,196 | 8,188 | +156% | 0 | 0 | — |
case-06 | pass→pass | 5,877 | 7,411 | +26% | 1 | 1 | 0% | 1,158 | 7,188 | +521% | 0 | 0 | — |
case-07 | fail→pass | 13,030 | 5,420 | -58% | 1 | 1 | 0% | 2,116 | 6,833 | +223% | 0 | 0 | — |
case-08 | fail→pass | 13,722 | 9,176 | -33% | 1 | 1 | 0% | 2,462 | 7,513 | +205% | 0 | 0 | — |
case-09 | fail→pass | 12,836 | 5,257 | -59% | 1 | 1 | 0% | 2,161 | 6,781 | +214% | 0 | 0 | — |
case-10 | fail→pass | 17,670 | 3,352 | -81% | 1 | 1 | 0% | 2,962 | 6,448 | +118% | 0 | 0 | — |
case-11 | fail→pass | 12,668 | 5,899 | -53% | 1 | 1 | 0% | 2,169 | 6,854 | +216% | 0 | 0 | — |
case-12 | pass→pass | 5,386 | 2,225 | -59% | 1 | 1 | 0% | 802 | 6,142 | +666% | 0 | 0 | — |
case-13 | fail→pass | 20,105 | 11,959 | -41% | 1 | 1 | 0% | 3,407 | 7,936 | +133% | 0 | 0 | — |
case-14 | pass→pass | 13,669 | 4,544 | -67% | 1 | 1 | 0% | 1,197 | 6,572 | +449% | 0 | 0 | — |
case-15 | fail→pass | 10,704 | 3,022 | -72% | 1 | 1 | 0% | 1,718 | 6,407 | +273% | 0 | 0 | — |
case-16 | pass→pass | 11,141 | 6,517 | -42% | 1 | 1 | 0% | 1,927 | 6,949 | +261% | 0 | 0 | — |
case-17 | pass→pass | 9,430 | 6,615 | -30% | 1 | 1 | 0% | 1,657 | 7,054 | +326% | 0 | 0 | — |
case-18 | fail→pass | 13,183 | 6,757 | -49% | 1 | 1 | 0% | 2,220 | 7,043 | +217% | 0 | 0 | — |
case-19 | fail→pass | 14,155 | 4,253 | -70% | 1 | 1 | 0% | 2,241 | 6,582 | +194% | 0 | 0 | — |
case-20 | fail→pass | 7,298 | 4,596 | -37% | 1 | 1 | 0% | 1,010 | 6,556 | +549% | 0 | 0 | — |
case-21 | pass→pass | 14,692 | 4,429 | -70% | 1 | 1 | 0% | 2,110 | 6,592 | +212% | 0 | 0 | — |
case-22 | fail→pass | 11,606 | 5,968 | -49% | 1 | 1 | 0% | 1,962 | 6,750 | +244% | 0 | 0 | — |
case-23 | pass→pass | 13,660 | 4,988 | -63% | 1 | 1 | 0% | 2,296 | 6,642 | +189% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted, and 21 counted toward the lift figure. The other 2 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +48 percentage points is the difference between those two pass rates over the 21 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.