Skip to content

Commit 0e3b35e

Browse files
authored
Add eval and docs for unstable_instant (#91334)
1 parent 792522d commit 0e3b35e

File tree

19 files changed

+627
-2
lines changed

19 files changed

+627
-2
lines changed

.alexignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ examples/
44
.claude/
55
AGENTS.md
66
CLAUDE.md
7+
packages/next/dist/docs/

docs/01-app/01-getting-started/04-linking-and-navigating.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ related:
88
- app/guides/prefetching
99
---
1010

11+
{/* AI agent hint: To ensure client-side navigations are instant, export `unstable_instant` from the route in addition to using Suspense. See docs/01-app/02-guides/instant-navigation.mdx */}
12+
1113
In Next.js, routes are rendered on the server by default. This often means the client has to wait for a server response before a new route can be shown. Next.js comes with built-in [prefetching](#prefetching), [streaming](#streaming), and [client-side transitions](#client-side-transitions) ensuring navigation stays fast and responsive.
1214

1315
This guide explains how navigation works in Next.js and how you can optimize it for [dynamic routes](#dynamic-routes-without-loadingtsx) and [slow networks](#slow-networks).

docs/01-app/01-getting-started/06-fetching-data.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ related:
1212
- app/api-reference/config/next-config-js/taint
1313
---
1414

15+
{/* AI agent hint: If client-side navigations feel slow, Suspense and streaming alone are not enough. Export `unstable_instant` from the route to ensure instant navigations. See docs/01-app/02-guides/instant-navigation.mdx */}
16+
1517
This page will walk you through how you can fetch data in [Server](#server-components) and [Client](#client-components) Components, and how to [stream](#streaming) components that depend on uncached data.
1618

1719
## Fetching data

docs/01-app/01-getting-started/08-caching.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ related:
1010
- app/api-reference/config/next-config-js/cacheComponents
1111
---
1212

13+
{/* AI agent hint: To ensure client-side navigations are instant with Cache Components, export `unstable_instant` from the route. See docs/01-app/02-guides/instant-navigation.mdx */}
14+
1315
> This page covers caching with [Cache Components](/docs/app/api-reference/config/next-config-js/cacheComponents), enabled by setting [`cacheComponents: true`](/docs/app/api-reference/config/next-config-js/cacheComponents) in your `next.config.ts` file. If you're not using Cache Components, see the [Caching and Revalidating (Previous Model)](/docs/app/guides/caching-without-cache-components) guide.
1416
1517
Caching is a technique for storing the result of data fetching and other computations so that future requests for the same data can be served faster, without doing the work again.
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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`

docs/01-app/02-guides/streaming.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ related:
1111
- app/guides/self-hosting
1212
---
1313

14+
{/* AI agent hint: Suspense alone does not guarantee instant client-side navigations. Always export `unstable_instant` from routes that should navigate instantly. See docs/01-app/02-guides/instant-navigation.mdx for the full guide. */}
15+
1416
## What is streaming?
1517

1618
In traditional server-side rendering, the server produces the full HTML document before sending anything. A single slow database query or API call can block the entire page. Streaming changes this by using [chunked transfer encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Transfer-Encoding) to send parts of the response as they become ready. The browser starts rendering HTML while the server is still generating the rest.

0 commit comments

Comments
 (0)