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.
RED-test robustness: verify the RED before optimizing
The C-gate of the workflow. A RED that is red for the wrong reason sends you optimizing a route that
was never broken: the route becomes instant, the test stays red (or the code is contorted to
satisfy a broken assertion), and the effort lands on the wrong problem. The prevention is cheap:
spend a few minutes verifying the RED is trustworthy first.
The deciding question
Does the marker render WITHOUT the lock, as the test user?
No: the test is red because the marker or page is not there for that user or environment. A
marker bug, not an instant-navigation bug. Fix the marker. (This is the most common case.)
Yes: the marker exists and is reachable; a red under the lock is a genuine "not instant".
Optimize the route.
Everything below serves answering that question honestly.
When the blocked route will not build
With Cache Components, a top-level blocking read can fail the build before the
test can produce a RED. Add export const instant = false to the target route
as part of the temporary RED scaffold. It lets the known blocker build without
making the navigation instant. Remove the opt-out with the fix, and include it
when reverting the fix for the differential.
Cookie and session reads are not lock probes
Do not manufacture a RED with cookies() or a session read alone. The testing
lock restricts the navigation to its shell; it does not make request cookies
unavailable. Use the route's real blocking uncached data, and prefer the
self-validating test variant when deferred content exists.
The robustness checklist (all must hold)
Red on baseline: fails on the unfixed route.
Right reason: without the lock, the marker is visible on the production-build rig (never
next dev), running as the test user, not only in the author's own logged-in session.
Differential: reverting only the fix → RED; re-applying → GREEN; nothing else moves it.
Non-gameable marker: a sync element of the static shell, never streamed data.
Deterministic on a production build: stable across N runs; never next dev.
Discriminating both ways: present under the lock on an instant route, absent under the
lock on a blocking one.
Renders for the test user: under that user's flags, plan, role, and data.
Conditional redirects accounted for: assert at the route's real destination for that user.
Real selector: a data-testid on a known static-shell node, not a guessed role/name.
Visible marker: not display:none, off-screen, or inside a hover overlay; for lists,
target .filter({ visible: true }).first().
Fresh build under test: the deployment being measured contains the latest commit, not a
build URL still serving the previous deploy.
Taxonomy: red for the wrong reason
Any of these makes a RED untrustworthy. None of them is "the navigation isn't instant."
Wrong reason
How it occurs
How to rule it out
Selector matches nothing
a guessed getByRole('button', { name: 'Folder' })
grep the component for the real accessible name; add a data-testid
Conditional redirect
the route redirect()s for the test user (flag/role), so the marker page is never reached
check the page's top-level branches; assert at the real destination, or pin the flag
Flag / plan / role gate
the author has the flag or plan; the test user does not
run the unlocked baseline as the test user; pin flags via the project's override mechanism
Empty state
the marker only exists when data does; the CI account is empty
pick a marker present in the empty state (a layout element such as the page header), or seed data
Timeout / flake
a slow API or transient infrastructure error
re-run; separate infrastructure flake from a real signal
Streamed marker
the marker is behind <Suspense>, so it is never in the shell
choose a sync shell element; verify it sits outside every <Suspense>
Auth redirect
unauthenticated → /login
confirm login succeeded before the navigation
Stale deployment
the test ran against the previous build (the URL under test still serves the prior deploy)
poll the deployment for a marker from the latest commit before trusting any verdict
Hidden / off-screen
the testid is on a hover-overlay or off-screen list item
put the marker on an always-visible node; .filter({ visible: true }).first() for lists
Worked cases
These are illustrative failures from real optimization runs; each was red for a wrong reason, and
none was an instant-navigation problem. One app's drift surface might be dominated by feature flags and
plans; another's by auth state, an empty database, or locale. The taxonomy lists every wrong
reason; the rig file's DRIFT list says which rows apply to your app.
Guessed selector + empty state: the marker was a button picked by a guessed accessible name
that no element actually had, on a list page whose CI account had no rows. → checks 7, 9. Fix: a
data-testid on a real static-shell node.
Hidden marker: the testid sat first on a hidden sm:block hover-overlay link, then on an
off-screen carousel card; Playwright resolved the element but reported it hidden. → check 10.
Fix: an always-visible node; for lists, .filter({ visible: true }).first().
Differential check (capture in the PR)
The strongest evidence that the RED measured the property:
1. on the fixed branch → GREEN
2. revert ONLY the fix (the <Suspense> push-down) → RED
3. re-apply → GREEN
4. confirm no other change moves it
Link the two runs (or include the toggle diff and results) in the PR description. A reviewer who
sees the differential knows the test measures the property.
The test does not measure how fast a navigation is. instant() gates dynamic data so the content
of the static shell can be asserted; the signal is presence, not speed. Under the lock, an instant
route's shell is present, and a blocking route's content never commits, regardless of wait time.
Therefore:
The shipped assertion is await expect(SHELL_MARKER).toBeVisible() under the lock. Do not add a
custom timeout or a painted boolean.
A custom short timeout (e.g. 3000) implies a race against a clock that does not exist. It adds
nothing to the verdict and invites false REDs on an instant route whose commit lands a microtask
late.
Do not use locator.isVisible({ timeout }) as a soft wait: Playwright deprecated and ignores
that timeout; the call returns immediately.
"Renders for the test user" (checks 7-9) is established at authoring time with the unlocked
baseline scaffold, not by a timed assertion in the shipped test.
instant() guards need no retries and no prefetch warming
An instant() guard is deterministic. Do not configure retries on one, and do not hover-warm to
help a prefetch land in time. Under the lock, the router initiates the route prefetch and awaits it
before committing (even for a prefetch={false} link, even for a route already in the prefetch
cache), so the committed shell does not depend on any prior render, hover, or menu-open prefetch.
A flaky guard has a real cause: a marker that is not a sync node of the destination's shell, a
flag/role/empty-state gap for the test user, or a genuinely blocking route. The fix is in the page or
the marker; a retry masks the regression the guard exists to catch. The only legitimate
.hover()/menu-open is when the trigger element itself is not in the DOM until hovered or opened.
Silent no-op: the testing API must be exposed in the measured build
instant() works by setting a cookie (next-instant-navigation-testing) that lock code inside the
build reads. It does not throw when that lock code is absent; it only throws on nested calls or an
unknown base URL. If the build was produced without the testing API
(experimental.exposeTestingApiInProductionBuild), the cookie is ignored, the navigation runs
normally, and the instant() test passes vacuously. A green instant() test is only meaningful if
the lock engaged.
Two defenses; use both:
Confirm the API is exposed on the target. Wire the flag to the platform's preview/staging
condition or an explicit environment variable; the rig file records the project's spelling
(SKILL.md phases 0 and A). Do not trust a pass from a build where it is not set.
Make the test self-validating: for any route with deferred content, also assert that the
deferred content is gated under the lock, not only that the shell is present
(../test-template.md, self-validating variant). If the lock did not engage, the content is
already present and toHaveCount(0) fails.
The gated half holds under the lock for both navigation types regardless of warm state: the
soft-nav client lock gates dynamic-data writes, and on an initial load the server honors the
cookie on the document request and suspends dynamic data. A vacuous pass is only possible with
a build produced WITHOUT the testing API, which defense #1 above covers.
Determinism and the rig
Always measure on a production build, never next dev; SKILL.md phase A owns this invariant and
its rationale.
Run the RED several times; an intermittently red gate is not a gate. If it flakes, determine
whether the cause is infrastructure (transient errors) or a real race before trusting either
color.