docs/01-app/02-guides/instant-navigation.mdx
This guide walks through understanding instant navigations, writing a route that navigates instantly, visualizing what's in the initial UI, and locking the behavior in with end-to-end tests.
A navigation is instant when the browser can start rendering the new page the moment the user clicks, with static, cached, and fallback content showing up right away, while the server streams the remaining content into its fallbacks.
Good to know: This definition assumes caches are warm. Cold caches still require the server to compute the cached result once, so the first navigation to a route may still wait.
A direct visit and a client navigation to the same route can produce different initial UI. Direct visits get the static shell as HTML, typically from a CDN. Client navigations only re-render below the layout the current and destination routes share, so the fallback UI defined by a <Suspense> boundary above that point can't be used during the transition.
Whether the new page appears instantly depends on the <Suspense> boundaries and caching present below the shared layout.
On a page load, the entire page renders from the document root. Every component runs on the server, and anything that suspends is caught by the nearest <Suspense> boundary in the full tree.
On a client navigation between /store/shoes and /store/hats, only the components below the /store layout re-render. A <Suspense> boundary in the root layout covers everything on a page load, but on this navigation, it sits above the re-render scope and does not trigger.
This is also why client-side hooks behave differently. useSearchParams() suspends during server rendering because search params are not available at build time. But on a client navigation, the router already has the params from the URL and the hook resolves synchronously. The same component can render immediately on a client navigation but sit behind a fallback on a page load.
Runtime prefetching extends the static shell with a link's URL data (searchParams and params) by invoking the route at prefetch time. Ensuring navigations are instant is the foundation: a route that doesn't navigate instantly without runtime prefetching won't navigate instantly with it either. See Runtime prefetching for the patterns.
With Cache Components, caching directives ("use cache" and its variants) assign a lifetime to an async function's result, which is what lets Next.js include it in the static shell.
Good to know:
"use cache: private"is a variant for caching functions that read runtime APIs likecookies()andheaders(). The result is cached in the browser only, not on the server. It can't be part of the static shell. See"use cache: private"in the runtime prefetching guide for how it pairs with prefetching.
<Suspense> declares fallback UI for parts of the tree that read uncached data or runtime APIs like cookies() and headers(); the content streams into the fallback when it resolves.
Good to know: A fallback may access
cookies(),headers(), or the full URL. At build time, the fallback itself suspends, and a<Suspense>boundary further up the tree is needed. With runtime prefetching, the information is available and such a fallback becomes part of the instant UI. Cached values like timestamps or data fetches can sit directly inside the fallback.
Next.js can also generate an App Shell per route: a fallback that renders instantly during client navigations when nothing else is ready. Runtime prefetching builds on it, resolving a link's URL data on top.
<Link> prefetchesUnder Partial Prefetching, each visible <Link> prefetches the destination's App Shell by default. The shell is shared across every link to the same route, so rendering a <Link> is effectively free.
To prefetch the page content alongside the shell for a specific link, set prefetch={true}:
<Link href="/checkout" prefetch>
Checkout
</Link>
With Partial Prefetching enabled, prefetch={true} also opts the link into runtime prefetching, which resolves the per-link URL data (params, searchParams, the full URL) ahead of the click.
By default (validationLevel: 'warning'), Cache Components apps validate every Page and Default segment in development. Validation surfaces what would keep navigations into a segment from being instant — which navigations would block, where a <Suspense> boundary is missing, and which data is reaching the user uncached.
To opt out of automatic validation and only validate segments that explicitly export instant, set validationLevel to 'manual-warning':
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
experimental: {
instantInsights: {
validationLevel: 'manual-warning',
},
},
}
export default nextConfig
For each validated route, Next.js checks both the initial page load and client navigations at different points in the route hierarchy.
For a route like /shop/[slug], validation checks:
<Suspense> catches everything./shop/shoes to /shop/hats): the /shop layout is already mounted and only the page below it re-renders. A <Suspense> boundary in the root layout does not cover this navigation.Each case is validated independently. A <Suspense> boundary that covers one navigation path might not cover another. This is why a page can pass the page load check but fail for client navigations, and why catching these issues by hand is difficult as the number of routes grows.
The @next/playwright package provides an instant() helper that scopes your assertions to the UI that's immediately available on navigation, so regressions surface in CI. See Prevent regressions with e2e tests for the pattern.
The Navigation Inspector in the Next.js DevTools freezes the page at its initial loading state, showing the static shell on direct visits and the prefetched destination on client navigations. Use the inspector to see how much meaningful content lands in the shell.
Pair it with the React DevTools Suspense panel to see exactly which boundary covers which part of the page. See Visualize loading states with the Next.js DevTools for the workflow.
See Maximizing the static shell for patterns to reduce fallback coverage and pull more content into the shell.
To see the primitives in action, consider a small store app. Each product has its own page at /store/[slug], reachable from the homepage and from other product pages. The goal is that navigating to and between products is instant.
The product page fetches two pieces of data: product details (name, price) and live inventory.
generateStaticParams, meaning slug is only known at request timeparams to get the slug, which suspends. Each has its own <Suspense> boundary<Suspense> boundaryimport { Suspense } from 'react'
import { db } from '@/lib/db'
export default function ProductPage(props: PageProps<'/store/[slug]'>) {
return (
<div>
<Suspense fallback={<p>Loading product...</p>}>
<ProductInfo params={props.params} />
</Suspense>
<Suspense fallback={<p>Checking availability...</p>}>
<Inventory params={props.params} />
</Suspense>
</div>
)
}
type Params = PageProps<'/store/[slug]'>['params']
async function ProductInfo({ params }: { params: Params }) {
const { slug } = await params
const product = await getProduct(slug)
return (
<>
<h1>{product.name}</h1>
<p>${product.price}</p>
</>
)
}
async function getProduct(slug: string) {
'use cache'
return db.products.findBySlug(slug)
}
async function Inventory({ params }: { params: Params }) {
const { slug } = await params
const item = await db.inventory.findBySlug(slug)
return <p>{item.count} in stock</p>
}
Cache Components validates this route automatically in development. If something would block a navigation, the dev overlay surfaces a blocking-route insight that names the offending component and points at these fixes:
<FixCardGrid> <FixCard group="cache" title="Cache the component or data" href="/docs/messages/blocking-prerender-dynamic#cache-the-component-or-data" snippets={[ { text: 'async function Posts() {' }, { text: ' "use cache"', highlight: true }, { text: ' return <List items={…} />' }, ]} /> <FixCard group="stream" title="Wrap in or move into Suspense" href="/docs/messages/blocking-prerender-dynamic#wrap-in-or-move-into-suspense" snippets={[ { text: '<Suspense fallback={…}>', highlight: true }, { text: ' <DataChild />' }, { text: '</Suspense>', highlight: true }, ]} /> </FixCardGrid>Good to know: Each fix card links to a detailed walkthrough with patterns, code samples, and trade-offs. Click a card to dive in, or use Copy prompt to hand the fix to your agent. See AI workflow for the loop.
Validation runs on every page load using the real request from your browser, so dynamic params like [slug] are checked against actual values as you navigate.
A soft navigation into a page with "use client" at the top behaves like a single-page app transition, with no server render at navigation time, which makes it instant. The dev overlay doesn't include this in its fix cards because it has bigger implications than the recommended approaches, which keep the page in the server-component model. Reach for "use client" when the page is fully interactive and must be a client component.
Good to know:
"use client"doesn't skip validation for the static shell. Hooks likeuseSearchParams()still need a<Suspense>boundary.
As you develop a route, the Next.js DevTools let you see what your users see on page loads and client navigations before dynamic data streams in. Use it to verify that your loading states look right, confirm the content you expect appears immediately, and iterate on where to place <Suspense> boundaries.
The React DevTools Suspense panel complements this: it lists the <Suspense> boundaries in the tree and lets you toggle each one between its fallback and resolved state, so you can see exactly which boundary covers which part of the page.
The Navigation Inspector is available when Cache Components is enabled:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Open the Next.js DevTools and select Navigation Inspector, then toggle Pause on navigations. The panel shows Awaiting navigation... With the toggle on, the next refresh or link click freezes the page so you can inspect the shell.
Refresh the product page. The Inspector freezes and shows Loading shell labeled Page load with the target URL. In the app, two fallbacks appear: "Loading product..." and "Checking availability...". On the first visit the cache is cold and both are visible.
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', }} > <Image alt="Navigation Inspector panel showing 'Loading shell' labeled 'Page load' with the target URL" srcLight="/docs/light/inspector-load.png" srcDark="/docs/dark/inspector-load.png" width="472" height="389" /> <span style={{ fontSize: '0.875rem', opacity: 0.7 }}> After a page refresh. </span> </div>Click Resume to complete the navigation. Refresh again, and the product name appears immediately from cache.
Now click a link from /store/shoes to /store/hats. The Inspector shows Loading shell labeled Client nav with both source and target URLs. In the app, the product name and price appear immediately (cached). "Checking availability..." shows where inventory will stream in.
Toggle Pause on navigations off when you're done inspecting loading states. Each navigation pauses as long as the toggle is on.
Good to know: Page loads and client navigations can produce different shells. Client-side hooks like
useSearchParamssuspend on page loads (search params are not known at build time) but resolve synchronously on client navigations (the router already has the params).
Validation catches structural problems during development, but as the codebase grows, the structural checks can only tell you that a shell exists. They can't tell you whether the right content is in it. E2E tests close that gap: they assert on what the user actually sees when the navigation completes, catching regressions before they ship.
The @next/playwright package includes an instant() helper for this. Install it alongside @playwright/test:
pnpm add -D @next/playwright @playwright/test
npm install -D @next/playwright @playwright/test
yarn add -D @next/playwright @playwright/test
bun add -D @next/playwright @playwright/test
A route can be reached two ways, and a <Suspense> boundary can cover one without covering the other:
page.goto() to test the static UI from the document response.<Link> to test the destination's prefetched UI. Runtime prefetching can add request-specific content to this UI.import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'
test.describe('Product page (/store/[slug])', () => {
test('is instant on an initial page load', async ({ page, baseURL }) => {
await instant(
page,
async () => {
await page.goto('/store/hats')
await expect(page.locator('h1')).toContainText('Baseball Cap')
await expect(page.getByText('In stock')).toHaveCount(0)
},
{ baseURL }
)
await expect(page.getByText('In stock')).toBeVisible()
})
test('is instant on a client navigation', async ({ page }) => {
await page.goto('/store/shoes')
await instant(page, async () => {
await page.click('a[href="/store/hats"]')
await page.waitForURL((url) => url.pathname === '/store/hats')
await expect(page.locator('h1')).toContainText('Baseball Cap')
await expect(page.getByText('In stock')).toHaveCount(0)
})
await expect(page.getByText('In stock')).toBeVisible()
})
})
Pass Playwright's baseURL to instant() when page.goto() is the first navigation. The helper needs the origin before requesting the document.
Inside the callback, an initial page load shows the static UI and a client navigation shows the destination's prefetched UI. Other dynamic content remains blocked until the callback finishes.
Good to know: The start of the
instant()scope is the same as turning on Pause on navigations in the Navigation Inspector, and the end of the scope releases the pause the way Resume does.
For client navigations, wait for the destination URL before asserting on its UI. Otherwise, a shared selector can match the source page before the destination commits. If the prefetched destination cannot commit, the URL wait times out and the test fails.
Run these against next dev, where the testing API is enabled automatically. To run them in CI against a production build, set exposeTestingApiInProductionBuild so next start exposes the same API:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
experimental: { exposeTestingApiInProductionBuild: true },
}
export default nextConfig
Focus these tests on the user flows that matter most.
Consider a different route, /products/[slug], that fetches product data from a public API and shows a featured list alongside:
export default async function ProductPage(
props: PageProps<'/products/[slug]'>
) {
const featured = await getFeatured()
const { slug } = await props.params
const res = await fetch(`https://next-recipe-api.vercel.dev/products/${slug}`)
const product = await res.json()
return (
<div>
<FeaturedSection items={featured} />
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</div>
)
}
async function getFeatured() {
const res = await fetch('https://next-recipe-api.vercel.dev/products?limit=3')
return res.json()
}
Two fetch() calls block at the top level: an uncached featured-list fetch and a per-slug product fetch (which also awaits params). Both will surface as Instant validation errors, one at a time.
Validation surfaces the per-slug product fetch first.
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', }} > <Image alt="Dev overlay insight for a blocking-route, showing the uncached data access on app/products/[slug]/page.tsx with Stream, Cache, and Block fix cards" srcLight="/docs/light/instant-insight.png" srcDark="/docs/dark/instant-insight.png" width="960" height="760" /> </div>Extract the slug-dependent work into a sub-component and wrap it with <Suspense>:
import { Suspense } from 'react'
async function ProductInfo({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const res = await fetch(`https://next-recipe-api.vercel.dev/products/${slug}`)
const product = await res.json()
return (
<>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</>
)
}
export default async function ProductPage(
props: PageProps<'/products/[slug]'>
) {
const featured = await getFeatured()
return (
<div>
<FeaturedSection items={featured} />
<Suspense fallback={<p>Loading product...</p>}>
<ProductInfo params={props.params} />
</Suspense>
</div>
)
}
Now await props.params and the product fetch suspend together. The product-fetch error clears, and validation moves on to the next blocker.
Validation now fires on getFeatured().
Add a "use cache" directive to the fetching function:
async function getFeatured() {
'use cache'
const res = await fetch('https://next-recipe-api.vercel.dev/products?limit=3')
return res.json()
}
The result is cached at the fetch level. The featured list ships with the App Shell.
Good to know: In serverless deployments, in-memory caching with
"use cache"will not persist across instances. Consider using"use cache: remote"for persistent caching.
Validation passes. Open the DevTools and try a client navigation. The featured section appears immediately, and "Loading product..." shows where the product details will stream in.
Validation passing means the navigation is instant. It does not mean the loading states are good. A <Suspense> boundary placed high in the tree (say, wrapping the whole page) might satisfy validation, but it replaces most of the page with a single fallback on every navigation.
The best loading states keep as much real, cached content visible as possible and only show fallbacks where data is actually in flight. A product page that keeps the header, image, and description visible with only the price and availability behind a fallback feels faster than a full-page skeleton, even at the same total load time.
Use the DevTools to see what your users see, or see the AI workflow for automating the loop with an agent.
An AI coding agent can run this workflow to make a navigation instant:
instant() test and verify that it fails before changing the route.use cache or <Suspense>). The agent applies the fix and re-runs validation.The agent doesn't need to understand the full caching model, only to follow the insights and errors until they're gone. It does need your app-specific intent, though. Tell it what should appear immediately, what can stream in, and what must stay fresh. When a caching decision is unclear, keep the data fresh behind <Suspense> rather than guessing a cache lifetime.
For iterating on loading states, a prompt like "maximize my content, and reduce the amount that needs to be behind a spinner" works well for pushing boundaries down.
Agents working on a Cache Components route typically reach for three levers:
'use cache' with cacheLife to assign a freshness profile.searchParams or params), opt it into runtime prefetching so the framework resolves that data at link-prefetch time. Session data from cookies() or headers() already lands in the App Shell without it.Each refactor should pair with a before/after capture to verify the change actually landed. Identical-looking captures mean the refactor didn't take effect.
For agents to see what their changes actually render, pair this with agent-browser. With React DevTools enabled it reports the component tree and which <Suspense> boundaries are still pending, so an agent can make a change, snapshot the shell, check what landed, and adjust. See Runtime visibility in the AI agents guide for the setup.
The next-cache-components-optimizer Skill packages this loop. It confirms the target UI renders normally, writes an instant() test that fails before the fix, then works it to green against a production-like build and ships it as a regression guard. Use it for the initial load (hard navigation), client-side navigation (soft navigation), or both.
Not every layout or page can or should be instant. When the structural fix isn't worth the work, or when a route isn't a priority for instant navigation, refine validation at one of two scopes.
The dev overlay surfaces this as the Block fix alongside every insight:
<FixCardGrid> <FixCard group="block" title="Allow blocking route" href="/docs/messages/blocking-prerender-dynamic#allow-blocking-route" snippets={[ { text: '// page.tsx or layout.tsx' }, { text: 'export const instant = false', highlight: true }, ]} /> </FixCardGrid>Set instant = false on the page or layout file. This opts the segment out of validation feedback. The segment may still navigate instantly if its structure supports it; the framework just won't surface insights for it. Navigations between sibling segments below are still validated.
export const instant = false
With false on /dashboard/layout.tsx, validation no longer flags navigations into /dashboard from outside; navigations between /dashboard/a and /dashboard/b are still checked.
For opted-out segments, the navigation blocks on the server. If the content depends on cookies or headers but has a known cache lifetime, caching it with use cache: private lets the App Shell carry it ahead of the click instead of opting out, as long as its stale time is at least 5 minutes.
<Link> defaults and the migration path off unstable_eagerinstant API reference for the full configurationsearchParams or params) and you want it in the shelluse cache, Suspense, and Partial PrerenderingcacheLife and updateTag