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
← Back to SKILL.md

Real-app patterns

The rest of this skill models a single linear layout → page tree. Production App Router routes add parallel routes, shared layout UI, and auth gates, which is where most of the real static-shell work happens. These patterns bridge that gap. Read the skill's SKILL.md first.

Parallel routes: each slot is its own boundary

Instant validation treats every parallel-route slot below the shared layout as an independent navigation boundary. Consequences:

  • Each @slot needs its own <Suspense> around its dynamic reads; a boundary in one slot does not cover another.
  • An uncovered dynamic read in any slot blocks the whole navigation. A perfect @content does not help if @sidebar awaits a session at the top.
  • A slot that renders null (e.g. default.tsx) is shell-safe: it is static and performs no reads. Slots that do not re-render for this navigation cost nothing.
[tenant]/layout.tsx         (shared: already mounted on a soft navigation; not re-rendered)
  ├ @content  → settings/layout → billing/page     ← guard each slot's dynamic reads…
  ├ @sidebar  → side nav                            ← …here too (independent boundary)
  └ @header   → default.tsx → null                  ← free

Client-rendered slot routing is not part of the soft-navigation re-render

A common pattern: a stable shared layout renders @header/@sidebar through a client component that swaps slot content based on usePathname(). On a soft navigation, Next.js only re-renders the server segments that changed below the shared layout; a client-component subtree is not part of that re-render. So that navigation UI neither blocks the navigation nor needs a server <Suspense> for it; only the server segments that actually change (e.g. @content) matter. It does participate in an initial load (see the caveat below).

"Instant" is not "useful shell": the empty-shell failure mode

Validation checks that a dynamic read is guarded by a boundary, not that the fallback is non-empty. A <Suspense> with no fallback (or fallback={null}) passes validation and commits instantly, but renders a blank shell. If a layout and its page both await getSession() (your auth library's request-time read) at the top under one empty-fallback boundary, the whole frame collapses to nothing while the user waits. "Validates as instant" and "good loading experience" are different goals.

Give every boundary a real loading skeleton, and place it low so the most real content stays in the shell. A fallback={null} directly above <body> is a deliberate empty-shell opt-out; an empty fallback lower in the tree is almost always a bug.

The responsive-skeleton mismatch: the shell must match every breakpoint

A loading skeleton that misaligns with the loaded UI is its own bug, and it usually appears on mobile. A hand-built skeleton encodes one layout; the real component is responsive and changes shape at breakpoints, so a desktop-shaped skeleton no longer lines up once the viewport is small.

A concrete shape: a list-detail view renders a list or tree in a side panel on desktop, but collapses that panel into a single dropdown or drawer on mobile (with its own loading state). A row skeleton built for the desktop panel has nothing to align with on mobile.

The fix is the same push-down as everywhere else: share the real responsive layout between the live render and the shell render. One responsive component renders both (its data slots show the reused *Skeleton in the shell and real data after the stream), so the breakpoint switch happens once, for both renders, and there is no second desktop-only skeleton to drift.

(Same hoist rule, responsive layout included.) Verify the shell at both desktop and mobile widths against the real render at the same width.

Deferring an auth gate / top-level await in a layout

A top-level await in a layout blocks everything below it (the most common blocker; see Runtime data during prerendering). Auth gates are the most common real instance:

// ❌ Before: the await + redirect at the top blocks the whole settings frame
export default async function SettingsLayout({ children }) {
  const session = await getSession() // your auth library's request-time read; suspends during prerender → frame can't build
  if (!session?.user) redirect(getLoginUrl())
  return <Shell>{children}</Shell>
}
// ✅ After: render children unconditionally; move the gate into a Suspense child
import { Suspense } from 'react'

export default function SettingsLayout({ children }) {
  return (
    <Shell>
      <Suspense fallback={null}>
        <AuthGate />
      </Suspense>
      {children}
    </Shell>
  )
}

async function AuthGate() {
  const session = await getSession() // the session read suspends during prerender…
  if (!session?.user) redirect(getLoginUrl()) // …so redirect() never runs at build time
  return null
}

The shell prerenders as if authorized (the session read suspends before redirect() is reached, so the redirect only happens at request time), and {children} is now in the shell instead of behind the gate. (fallback={null} is correct here: AuthGate renders nothing on success.)

Initial-load shell vs soft-navigation shell

The ../test-template.md specs drive a <Link> click for soft navigations and page.goto() for initial loads. The two shells can differ for the same route:

The initial-load shell can show less than the soft-navigation shell when a layout above the shared boundary awaits un-enumerated params/searchParams. An initial load re-runs every layout from the root; if a parent layout does await props.params and that segment has no generateStaticParams, the param suspends on the initial load and its whole subtree drops out of the shell. A soft navigation does not re-render that parent and already has the params. Symptom: an element present after a <Link> click is missing after goto.

To assert the soft-navigation shell, drive a real <Link> click (through menus if necessary). Use page.goto() inside instant() to assert the initial-load shell, or when no parent above the shared boundary awaits un-enumerated params, in which case the two coincide.

Edge cases

  • A React.cache (or custom memoization) wrapper around cookies()/headers() still suspends. Memoizing the call does not make it shell-safe: the underlying request read still returns a pending promise during prerender. Only the use cache directive, keyed on static or param inputs, puts data in the shell.
  • Playwright cannot see a display: contents or fragment fallback. Such a fallback reads as hidden, so instant() assertions cannot toBeVisible() it. Give fallbacks a real wrapper element with a data-testid.
Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 23.
references — `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 points
SKILL.mdView in source ↗
Source excerpt starting at line 68.
can differ; guard the one you ship, both when both matter(`reference/real-app-patterns.md`).
SKILL.mdView in source ↗
Source excerpt starting at line 78.
`fallback={null}` shell (the empty-shell failure mode,`reference/real-app-patterns.md`).
SKILL.mdView in source ↗
Source excerpt starting at line 125.
- [ ]      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
SKILL.mdView in source ↗
Source excerpt starting at line 288.
`<Suspense fallback={null}>`-wrapped child. Mechanism and before→after:`reference/real-app-patterns.md`, "Deferring an auth gate".
SKILL.mdView in source ↗
Source excerpt starting at line 362.
this gate is as machine-checkable as the others. Detail:`reference/real-app-patterns.md`.
SKILL.mdView in source ↗
Source excerpt starting at line 435.
  `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
SKILL.mdView in source ↗
Source excerpt starting at line 439.
  chase a slot the navigation never touches  (`reference/real-app-patterns.md`).
SKILL.mdView in source ↗
Source excerpt starting at line 450.
  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