|
| 1 | +--- |
| 2 | +title: Ensuring instant navigations |
| 3 | +description: Learn how to structure your app to prefetch and prerender more content, providing instant page loads and client navigations. |
| 4 | +nav_title: Instant navigation |
| 5 | +version: draft |
| 6 | +related: |
| 7 | + title: Learn more |
| 8 | + description: Explore the full instant API, caching, and revalidation. |
| 9 | + links: |
| 10 | + - app/api-reference/file-conventions/route-segment-config/instant |
| 11 | + - app/getting-started/caching |
| 12 | + - app/getting-started/revalidating |
| 13 | + - app/guides/prefetching |
| 14 | +--- |
| 15 | + |
| 16 | +With [Cache Components](/docs/app/api-reference/config/next-config-js/cacheComponents) enabled, wrapping uncached data in `<Suspense>` boundaries produces instant navigations — but only if the boundaries are in the right place. A misplaced boundary can silently block client-side navigations, especially where the entry point varies by shared layout. **Always export `unstable_instant` from routes that should navigate instantly** — it validates the caching structure at dev time and build time, catching issues before they reach users. |
| 17 | + |
| 18 | +This guide starts with a product page that navigates instantly, then shows how to catch and fix a page where Suspense boundaries are not in the right place. |
| 19 | + |
| 20 | +## A page that navigates instantly |
| 21 | + |
| 22 | +A product page at `/store/[slug]` that fetches two things: product details (name, price) and live inventory. Product details rarely change, so they are cached with `use cache`. Inventory must be fresh and streams behind its own `<Suspense>` fallback: |
| 23 | + |
| 24 | +```tsx filename="app/store/[slug]/page.tsx" highlight={1,12-19,25} |
| 25 | +export const unstable_instant = { prefetch: 'static' } |
| 26 | + |
| 27 | +import { Suspense } from 'react' |
| 28 | + |
| 29 | +export default async function ProductPage({ |
| 30 | + params, |
| 31 | +}: { |
| 32 | + params: Promise<{ slug: string }> |
| 33 | +}) { |
| 34 | + return ( |
| 35 | + <div> |
| 36 | + <Suspense fallback={<p>Loading product...</p>}> |
| 37 | + {params.then(({ slug }) => ( |
| 38 | + <ProductInfo slug={slug} /> |
| 39 | + ))} |
| 40 | + </Suspense> |
| 41 | + <Suspense fallback={<p>Checking availability...</p>}> |
| 42 | + <Inventory params={params} /> |
| 43 | + </Suspense> |
| 44 | + </div> |
| 45 | + ) |
| 46 | +} |
| 47 | + |
| 48 | +async function ProductInfo({ slug }: { slug: string }) { |
| 49 | + 'use cache' |
| 50 | + const product = await fetchProduct(slug) |
| 51 | + return ( |
| 52 | + <> |
| 53 | + <h1>{product.name}</h1> |
| 54 | + <p>${product.price}</p> |
| 55 | + </> |
| 56 | + ) |
| 57 | +} |
| 58 | + |
| 59 | +async function Inventory({ params }: { params: Promise<{ slug: string }> }) { |
| 60 | + const { slug } = await params |
| 61 | + const inventory = await fetchInventory(slug) |
| 62 | + return <p>{inventory.count} in stock</p> |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +There is no `generateStaticParams`, so `[slug]` is a dynamic segment and `slug` is only known at request time. Awaiting `params` suspends, which is why each component that reads it has its own `<Suspense>` boundary. The `params` Promise is resolved inline with `.then()` so the cached `ProductInfo` receives a plain `slug` string. |
| 67 | + |
| 68 | +The [`unstable_instant`](/docs/app/api-reference/file-conventions/route-segment-config/instant) export on line 1 tells Next.js to validate that this page produces an instant [static shell](/docs/app/glossary#static-shell) at every possible entry point. Validation runs during development and at build time. If a component would block navigation, the error overlay tells you exactly which one and suggests a fix. |
| 69 | + |
| 70 | +### Inspect it with the Next.js DevTools |
| 71 | + |
| 72 | +Enable the Instant Navigation DevTools toggle in your Next.js config: |
| 73 | + |
| 74 | +```ts filename="next.config.ts" highlight={5-7} |
| 75 | +import type { NextConfig } from 'next' |
| 76 | + |
| 77 | +const nextConfig: NextConfig = { |
| 78 | + cacheComponents: true, |
| 79 | + experimental: { |
| 80 | + instantNavigationDevToolsToggle: true, |
| 81 | + }, |
| 82 | +} |
| 83 | + |
| 84 | +export default nextConfig |
| 85 | +``` |
| 86 | + |
| 87 | +Open the Next.js DevTools and select **Instant Navs**. You will see two options: |
| 88 | + |
| 89 | +- **Page load**: click **Reload** to refresh the page and freeze it at the initial static UI generated for this route, before any dynamic data streams in. |
| 90 | +- **Client navigation**: once enabled, clicking any link in your app shows the prefetched UI for that page instead of the full result. |
| 91 | + |
| 92 | +Try a **page load**. "Loading product..." and "Checking availability..." appear as separate fallbacks. On the first visit the cache is cold, so both fallbacks are visible. Navigate to the page again and the product name appears immediately from cache. |
| 93 | + |
| 94 | +Now try a **client navigation** (click a link from `/store/shoes` to `/store/hats`). The product name and price appear immediately (cached). "Checking availability..." shows where inventory will stream in. |
| 95 | + |
| 96 | +> **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). |
| 97 | +
|
| 98 | +<details> |
| 99 | +<summary>Why page loads and client navigations produce different shells</summary> |
| 100 | + |
| 101 | +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. |
| 102 | + |
| 103 | +On a client navigation (link click), Next.js only re-renders below the layout that the source and destination routes share. Components above that shared layout are not re-rendered. This means a Suspense boundary in the root layout covers everything on a page load, but for a client navigation between `/store/shoes` and `/store/hats`, the shared `/store` layout is the entry point. The root Suspense sits above it and has no effect. |
| 104 | + |
| 105 | +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, so the hook resolves synchronously. The same component can appear in the instant shell on a client navigation but behind a fallback on a page load. |
| 106 | + |
| 107 | +</details> |
| 108 | + |
| 109 | +### Prevent regressions with e2e tests |
| 110 | + |
| 111 | +Validation catches structural problems during development and at build time. To prevent regressions as the codebase evolves, the `@next/playwright` package includes an `instant()` helper that asserts on exactly what appears in the instant shell: |
| 112 | + |
| 113 | +```typescript filename="e2e/navigation.test.ts" |
| 114 | +import { test, expect } from '@playwright/test' |
| 115 | +import { instant } from '@next/playwright' |
| 116 | + |
| 117 | +test('product title appears instantly', async ({ page }) => { |
| 118 | + await page.goto('/store/shoes') |
| 119 | + |
| 120 | + await instant(page, async () => { |
| 121 | + await page.click('a[href="/store/hats"]') |
| 122 | + await expect(page.locator('h1')).toContainText('Baseball Cap') |
| 123 | + }) |
| 124 | + |
| 125 | + // After instant() exits, dynamic content streams in |
| 126 | + await expect(page.locator('text=in stock')).toBeVisible() |
| 127 | +}) |
| 128 | +``` |
| 129 | + |
| 130 | +`instant()` holds back dynamic content while the callback runs against the static shell. After it resolves, dynamic content streams in and you can assert on the full page. |
| 131 | + |
| 132 | +There is no need to write an `instant()` test for every navigation. Build-time validation already provides the structural guarantee. Use `instant()` for the user flows that matter most. |
| 133 | + |
| 134 | +## Fixing a page that blocks |
| 135 | + |
| 136 | +Now consider a different route, `/shop/[slug]`, that has the same data requirements but without local Suspense boundaries or caching: |
| 137 | + |
| 138 | +```tsx filename="app/shop/[slug]/page.tsx" |
| 139 | +export default async function ProductPage({ |
| 140 | + params, |
| 141 | +}: { |
| 142 | + params: Promise<{ slug: string }> |
| 143 | +}) { |
| 144 | + const { slug } = await params |
| 145 | + const product = await fetchProduct(slug) |
| 146 | + const inventory = await fetchInventory(slug) |
| 147 | + return ( |
| 148 | + <div> |
| 149 | + <h1>{product.name}</h1> |
| 150 | + <p>${product.price}</p> |
| 151 | + <p>{inventory.count} in stock</p> |
| 152 | + </div> |
| 153 | + ) |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +The root layout wraps `{children}` in `<Suspense>`: |
| 158 | + |
| 159 | +```tsx filename="app/layout.tsx" highlight={9-11} |
| 160 | +export default function RootLayout({ |
| 161 | + children, |
| 162 | +}: { |
| 163 | + children: React.ReactNode |
| 164 | +}) { |
| 165 | + return ( |
| 166 | + <html lang="en"> |
| 167 | + <body> |
| 168 | + <Suspense fallback={<p>Loading...</p>}>{children}</Suspense> |
| 169 | + </body> |
| 170 | + </html> |
| 171 | + ) |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +On an initial page load, the root Suspense catches the async work and streams the page in behind the fallback. Everything appears to work. But on a client navigation from `/shop/shoes` to `/shop/hats`, the shared `/shop` layout is the entry point. The root `<Suspense>` boundary is above that layout, so it is invisible to this navigation. The page fetches uncached data with no local boundary, so the old page stays visible until the server finishes renderingm making the navigation feel unresponsive. |
| 176 | + |
| 177 | +### Step 1: Add instant validation |
| 178 | + |
| 179 | +Add the `unstable_instant` export to surface the problem: |
| 180 | + |
| 181 | +```tsx filename="app/shop/[slug]/page.tsx" highlight={1} |
| 182 | +export const unstable_instant = { prefetch: 'static' } |
| 183 | + |
| 184 | +export default async function ProductPage({ |
| 185 | + params, |
| 186 | +}: { |
| 187 | + params: Promise<{ slug: string }> |
| 188 | +}) { |
| 189 | + const { slug } = await params |
| 190 | + const product = await fetchProduct(slug) |
| 191 | + const inventory = await fetchInventory(slug) |
| 192 | + return ( |
| 193 | + <div> |
| 194 | + <h1>{product.name}</h1> |
| 195 | + <p>${product.price}</p> |
| 196 | + <p>{inventory.count} in stock</p> |
| 197 | + </div> |
| 198 | + ) |
| 199 | +} |
| 200 | +``` |
| 201 | + |
| 202 | +Next.js now simulates navigations at every shared layout boundary in the route. Awaiting `params` and both data fetches are flagged as violations because they suspend or access uncached data outside a Suspense boundary. Each error identifies the specific component and suggests a fix. |
| 203 | + |
| 204 | +### Step 2: Fix the errors |
| 205 | + |
| 206 | +Look at the data. There is no `generateStaticParams`, so `slug` is only known at request time. Awaiting `params` suspends, so every component that reads it needs its own `<Suspense>` boundary. |
| 207 | + |
| 208 | +Decide what to do with each fetch: |
| 209 | + |
| 210 | +- **Product details** (name, price) rarely change. Cache them as a function of `slug` with `use cache`. |
| 211 | +- **Inventory** must be fresh from upstream. Leave it uncached and let it stream behind a `<Suspense>` fallback. |
| 212 | + |
| 213 | +The result is the same structure from the first section: |
| 214 | + |
| 215 | +```tsx filename="app/shop/[slug]/page.tsx" highlight={1,12-19,25} |
| 216 | +export const unstable_instant = { prefetch: 'static' } |
| 217 | + |
| 218 | +import { Suspense } from 'react' |
| 219 | + |
| 220 | +export default async function ProductPage({ |
| 221 | + params, |
| 222 | +}: { |
| 223 | + params: Promise<{ slug: string }> |
| 224 | +}) { |
| 225 | + return ( |
| 226 | + <div> |
| 227 | + <Suspense fallback={<p>Loading product...</p>}> |
| 228 | + {params.then(({ slug }) => ( |
| 229 | + <ProductInfo slug={slug} /> |
| 230 | + ))} |
| 231 | + </Suspense> |
| 232 | + <Suspense fallback={<p>Checking availability...</p>}> |
| 233 | + <Inventory params={params} /> |
| 234 | + </Suspense> |
| 235 | + </div> |
| 236 | + ) |
| 237 | +} |
| 238 | + |
| 239 | +async function ProductInfo({ slug }: { slug: string }) { |
| 240 | + 'use cache' |
| 241 | + const product = await fetchProduct(slug) |
| 242 | + return ( |
| 243 | + <> |
| 244 | + <h1>{product.name}</h1> |
| 245 | + <p>${product.price}</p> |
| 246 | + </> |
| 247 | + ) |
| 248 | +} |
| 249 | + |
| 250 | +async function Inventory({ params }: { params: Promise<{ slug: string }> }) { |
| 251 | + const { slug } = await params |
| 252 | + const inventory = await fetchInventory(slug) |
| 253 | + return <p>{inventory.count} in stock</p> |
| 254 | +} |
| 255 | +``` |
| 256 | + |
| 257 | +Validation passes. Open the DevTools and try a client navigation. The product name and price appear immediately, and "Checking availability..." shows where inventory will stream in. |
| 258 | + |
| 259 | +<details> |
| 260 | +<summary>How validation checks every entry point</summary> |
| 261 | + |
| 262 | +When you add `unstable_instant` to a route, Next.js does not only check the initial page load. It simulates navigations at every possible shared layout boundary in the route hierarchy. |
| 263 | + |
| 264 | +For a route like `/shop/[slug]`, validation checks: |
| 265 | + |
| 266 | +- Entry from outside (page load): the full tree renders, root layout Suspense catches everything |
| 267 | +- Entry from a sibling under `/shop` (client navigation from `/shop/shoes` to `/shop/hats`): only the page segment re-renders, the `/shop` layout is the entry point |
| 268 | + |
| 269 | +Each entry point is validated independently. A Suspense boundary that covers one path might be invisible to another. This is why a page can pass the initial load check but fail for sibling navigations, and why catching these issues by hand is difficult as the number of routes grows. |
| 270 | + |
| 271 | +</details> |
| 272 | + |
| 273 | +## Opting out with `instant = false` |
| 274 | + |
| 275 | +Not every layout can be instant. A dashboard layout that reads cookies and fetches user-specific data might be too dynamic for the first entry. You can set `instant = false` on that layout to exempt it from validation: |
| 276 | + |
| 277 | +```tsx filename="app/dashboard/layout.tsx" |
| 278 | +export const unstable_instant = false |
| 279 | +``` |
| 280 | + |
| 281 | +This tells validation: do not require that entry into `/dashboard` is instant, but still allows you to validate sibling navigations within it by using `instant` on those inner segments. Navigating from `/dashboard/a` to `/dashboard/b` can still be checked by adding `instant` to the page segments under `/dashboard`. |
| 282 | + |
| 283 | +## Next steps |
| 284 | + |
| 285 | +- [`instant` API reference](/docs/app/api-reference/file-conventions/route-segment-config/instant) for all configuration options, including runtime prefetching and incremental adoption with `instant = false` |
| 286 | +- [Caching](/docs/app/getting-started/caching) for background on `use cache`, Suspense, and Partial Prerendering |
| 287 | +- [Revalidating](/docs/app/getting-started/revalidating) for how to expire cached data with `cacheLife` and `updateTag` |
0 commit comments