Why Next.js Instant Navigations matter
Next.js Instant Navigations let users feel like your site responds immediately: the App Shell commits the moment they click, while personalized or slow pieces stream in behind Suspense boundaries. For high‑traffic, high‑value routes (search, product lists, dashboards), converting a single route to an instant experience is one deploy away and yields measurable UX and Core Web Vitals wins.
In this playbook I walk a practical, per‑route process to convert a slow route into an “instant” navigation. You’ll get a minimal config, code examples, the Playwright guard you should ship, and the three silent mistakes that will quietly de‑opt your work.
Primary keyword: Next.js Instant Navigations
The per‑route checklist (overview)
1) Flip the flag: enable Cache Components (and Partial Prefetching if you want shared App Shell prefetches).
2) Annotate the shell with "use cache" and push runtime reads into Suspense children.
3) Use partial per‑link prefetching for URL-dependent data and "use cache: private" + prefetch = 'allow-runtime' for cookie/session-driven UI.
4) Protect with tests: validate the UX with the instant() Playwright helper.
Do this route‑by‑route. Ship one route in isolation: small blast radius, clear metrics, and patterns you can reuse.
Step 1 — Flip the flag (config)
Enable Cache Components in your next.config. If you plan to adopt the App Shell / shared prefetch model, enable partialPrefetching too. Minimal config:
// next.config.js
module.exports = {
experimental: {
cacheComponents: true,
partialPrefetching: true // optional: adopt per-route or global
}
}
Enter fullscreen mode Exit fullscreen mode
When cacheComponents is on, Next.js will try to prerender a static shell for each route. The goal is to make that shell as meaningful as possible so it can commit immediately on navigation.
Step 2 — Annotate the shell and push dynamic reads down
Add "use cache" to functions that perform expensive, cacheable fetches. Place truly runtime-only reads (cookies(), headers(), searchParams, connection) inside Suspense boundaries so the shell remains deterministic.
Example: search page with cached results and a session widget.
// app/search/layout.tsx (the shell)
export default function SearchLayout({ children }: { children: React.ReactNode }) {
// shell UI that should render instantly
return (
<html>
<body>
<header>…search header…</header>
<main>{children}</main>
</body>
</html>
)
}
// app/search/results.tsx (dynamic, Suspense-wrapped)
import React, { Suspense } from 'react'
const Results = ({ q }: { q: string }) => {
const hits = await getResults(q) // cached function
return <ResultsList hits={hits} />
}
export default function SearchPage({ searchParams }) {
return (
<Suspense fallback={<ResultsSkeleton />}>
<Results q={searchParams.q || ''} />
</Suspense>
)
}
// lib/search.ts
export async function getResults(q: string) {
'use cache'
const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(q)}`)
return res.json()
}
Enter fullscreen mode Exit fullscreen mode
Notes:
- The layout renders immediately as the App Shell.
- Results sit behind Suspense so they stream in.
-
getResultsuses"use cache"so cached responses can be included in the shell when possible.
Step 3 — Partial prefetching and private cache for session data
Two prefetch mechanisms matter:
- App Shell prefetch (Partial Prefetching): one reusable shell per route.
- Per‑link runtime prefetch (
prefetch={true}on orprefetch = 'allow-runtime'segment) that resolves URL data (params,searchParams) and optionally private cache entries.
If a widget reads cookies() and should appear instantly per session, use "use cache: private" and allow runtime prefetching on the segment:
// lib/session.ts
export async function getPersonalizedWidgets() {
'use cache: private'
cacheLife({ stale: 60 }) // keep runtime-prefetchable lifetime
const sessionId = (await cookies()).get('session-id')?.value || 'guest'
return fetch(`/api/widgets?session=${sessionId}`).then(r => r.json())
}
// app/search/layout.tsx (or page) to opt into runtime prefetch
export const prefetch = 'allow-runtime'
Enter fullscreen mode Exit fullscreen mode
Important scope rule: the "use cache: private" directive must enclose the actual cookies() call (or move the cookie read into the helper). If you put the directive on a helper but read cookies at a higher frame, the runtime read stays outside the private cache and the segment remains dynamic.
Tradeoffs: runtime prefetching is a per‑visible‑link server invocation. Use it where the UX benefit outweighs the cost (search results, product detail links, high‑value conversions).
Step 4 — Protect with tests: instant() Playwright helper
Ship an instant() e2e that asserts the shell commits immediately and the dynamic bits stream in afterwards. The @next/playwright helper freezes dynamic content during the assertion window so regressions fail CI.
Example test:
import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'
test('search is instant on client navigation', async ({ page }) => {
await page.goto('/')
await instant(page, async () => {
await page.click('a[href="/search?q=shake"]')
await page.waitForURL('/search?q=shake')
// Shell elements must be visible instantly
await expect(page.locator('header')).toBeVisible()
// result-dependent content should not be present yet
await expect(page.getByText('Results for')).toHaveCount(0)
})
// After streaming completes, expect results
await expect(page.getByText('Results for "shake"')).toBeVisible()
})
Enter fullscreen mode Exit fullscreen mode
Pro tip: when testing page.goto() inside instant(), pass baseURL to the helper so it can determine the origin.
Three mistakes that silently de‑opt you
1) Reading cookies() at the top level of the shell
- Why it hurts: request APIs like
cookies()mark the tree dynamic and prevent the shell from being cached. Fix: move the read under a Suspense boundary or use"use cache: private"around the read (and pair with runtime prefetch if you want it available before click).
2) Leaving Server Actions or blocking async work in the shell
- Why it hurts: server actions or top‑level awaits force the server to resolve before commit. Fix: push server actions deeper (they can remain where they belong), and move blocking I/O into Suspense children with
"use cache"where appropriate.
3) Forgetting partial prefetch or using the wrong prefetch strategy for session routes
- Why it hurts: the shared App Shell must be configured with Partial Prefetching or opt per-route with
prefetch = 'partial'. For cookie-driven data,"use cache: private"withoutprefetch = 'allow-runtime'won’t benefit link‑visibility prefetches. Fix: apply the matching prefetch segment config and ensure cache lifetimes meet the minimum (stale >= 30s for private runtime prefetching).
Each of these problems often compiles and runs fine — they just prevent the shell from committing instantly, which makes them silent regressions unless you validate with instant() tests.
Measuring success and rollout strategy
- Baseline: capture TTFB and Largest Contentful Paint (LCP) for the route. In my /search example, TTFB dropped from ~1.2s to ~400ms on cache hits and LCP improved noticeably after converting the shell.
- Rollout: ship one route, measure in production, iterate. Reuse patterns and codemods (prefetch partial adoption) across routes.
Final checklist before you deploy
- [ ] next.config: cacheComponents: true (+ partialPrefetching if adopting globally)
- [ ] Shell renders without cookies()/headers()/blocking awaits
- [ ] Dynamic reads inside Suspense with meaningful fallbacks
- [ ] Cacheable helpers annotated with ‘use cache’ (or ‘use cache: private’ where appropriate)
- [ ] Links audited: add prefetch={true} or keep default; opt high‑value links into runtime prefetch
- [ ] instant() Playwright test added and green in CI
Closing
Next.js Instant Navigations are not a single switch — they’re a small set of structural changes that let your App Shell commit immediately and stream the rest. Do one route, measure the win, and repeat. If you want, start with your slowest, most‑visited route (search, category pages, product lists) and protect it with an instant() test. The UX feels immediate — and your metrics usually follow.