NNext.jsSource ↗
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.

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.

What "instant" means

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 request-specific content like the user's name from a cookie, 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.

The tools

Build the static shell

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 like cookies() and headers(). 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.

Under 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>

To prefetch with the user's session (cookies, headers, the full URL), opt the destination segment into runtime prefetching with export const prefetch = 'allow-runtime'.

Validate instant navigation

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:

  • Page load: the full tree renders from the root. The root layout <Suspense> catches everything.
  • Client navigation (e.g. from /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.

Test it in CI

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.

Inspect loading states

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.

A page that navigates instantly

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.

  • There is no generateStaticParams, meaning slug is only known at request time
  • Both components await params to get the slug, which suspends. Each has its own <Suspense> boundary
  • Product info rarely changes and is queried from the db using a cached function
  • Inventory must be fresh on each request. The db query is inside a <Suspense> boundary
import { 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:

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.

Client Component Pages

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 like useSearchParams() still need a <Suspense> boundary.

Visualize loading states with the Next.js DevTools

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.

{/* TODO: screenshot — React DevTools Suspense panel listing boundaries with toggle controls */}

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.

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 useSearchParams suspend on page loads (search params are not known at build time) but resolve synchronously on client navigations (the router already has the params).

Prevent regressions with e2e tests

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

Then use it in a test:

import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'

test('product title appears instantly', async ({ page }) => {
  await page.goto('/store/shoes')

  await instant(page, async () => {
    await page.click('a[href="/store/hats"]')
    await expect(page.locator('h1')).toContainText('Baseball Cap')
  })

  // After instant() exits, dynamic content streams in
  await expect(page.locator('text=in stock')).toBeVisible()
})

Inside the instant() callback, only the static shell is visible. After the callback finishes, dynamic content streams in and you can assert on the full page.

Focus these tests on the user flows that matter most. Run them against your dev server during development, and in CI against your build output so regressions fail the pipeline.

Fixing a navigation that blocks

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.

Step 1: Move the slug-dependent work into Suspense

Validation surfaces the per-slug product fetch first.

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.

Iterate on loading states

{/* TODO: diagram — before/after illustration showing a single high-up Suspense boundary being refined down to smaller boundaries closer to the data */}

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.

AI workflow

The observe-fix-iterate loop is well suited to AI coding agents:

  • Observe: read validation insights in the dev overlay and dev server console.
  • Fix: validation errors name a specific component and suggest a fix (use cache or <Suspense>). The agent applies the fix and re-runs validation, whether that's in dev or a build.
  • Iterate: run an instant() test to check what appears in the shell. Because the output is deterministic, the agent can assert on it without flaky retries.

The agent doesn't need to understand the full caching model. It follows the insights and errors until they're gone.

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. You can hint at what data needs to be fresh on load and what can be cached, and the agent will move the <Suspense> and use cache placement accordingly.

Agents working on a Cache Components route typically reach for three levers:

  • Push down: extract I/O into a Suspense-wrapped child so the parent stays static and static siblings lift into the shell.
  • Cache: pair 'use cache' with cacheLife to assign a freshness profile.
  • Runtime prefetching (nav-only): when I/O depends on cookies(), headers(), or searchParams, opt the route into runtime prefetching so the framework prerenders it at link-prefetch time.

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. It exposes the Next.js DevTools (including PPR shells) as shell commands agents can read and drive, so the loop becomes: make a change, snapshot the shell, check what's in it, adjust.

{/* TODO: distribution of the skill/plugin */} The next-cache-components-optimizer skill packages this loop: shared preflight, the three levers, plan-mode gating, before/after capture verify, and a no-shell bailout when the route is fully blocking. It picks between two sub-loops based on the request: page-render (grow the static shell of a single page) or in-app navigation (capture the destination's suspended boundaries after a click).

Opting out

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:

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, runtime prefetching can prerender it ahead of click instead of opting out.

Next steps


Source: docs/01-app/02-guides/instant-navigation.mdx

Generated by smolify with deterministic-repository-import-v1 · 1 source file