Files
vercel__next.js/docs/01-app/02-guides/optimizing-prefetching.mdx
Aurora Scharff 393de1ebe0 skill: add Partial Prefetching optimizer (#96471)
## Summary

- add a Partial Prefetching optimizer Skill for selected source Links
and destinations
- keep prefetch stages and Link policy in the public [Optimizing
prefetching
guide](https://nextjs.org/docs/app/guides/optimizing-prefetching), and
rely on the same static-shell documentation as the Cache Components
optimizer for cache placement, Suspense, loading UI, and authorization
- keep the Skill focused on the product decision, production `instant()`
loop, differential verification, and mutation freshness when an
optimization adds a cache around mutable data
- reference the canonical
[`unstable_prefetch()`](https://nextjs.org/docs/app/api-reference/functions/prefetch)
and
[`unstable_navigation()`](https://nextjs.org/docs/app/api-reference/functions/navigation)
docs instead of explaining the APIs again in the Skill
- link cache-writing optimizations to the canonical
[Revalidating](https://nextjs.org/docs/app/getting-started/caching-and-revalidating#revalidating)
documentation and verify a populated-cache mutation in the Skill and
eval
- bundle a self-contained production rig aligned with the related
adoption and optimizer Skills, tuned to record each source-link and
prefetch-stage contract
- add an eval that compares baseline, the bundled guide, and the local
Skill against the same prefetched-UI and cache-freshness goal
- document the optimizer in the AI agents guide

This follows the same guide/Skill split as #97737. Partial Prefetching
adoption preserves existing behavior; this optimizer is the later
decision about what is worth preparing before a click and what should
wait for navigation.

Related: #97712, #97737, #97759, #98122, #98175

## Eval

One run per treatment with Claude Opus 4.8 and Claude Sonnet 4.5, using
a fixed Claude Haiku 4.5 judge. The bundled-guide and Skill treatments
use a local Next.js tarball containing the new guide and API references.

| Model | Treatment | Criteria | Score change | Overall | Duration |
Tokens processed | Total run cost | Cost change |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Opus 4.8 | Baseline | 10/10 (100%) | — | Pass | 18m 42s | 3.39M |
$3.46 | — |
| Opus 4.8 | Bundled guide | 9/10 (90%) | -10 pp | Fail | 13m 37s |
3.02M | $2.84 | -18.0% |
| Opus 4.8 | Local Skill | 10/10 (100%) | +10 pp | Pass | 17m 22s |
2.80M | $2.54 | -10.6% |
| Sonnet 4.5 | Baseline | 2/10 (20%) | — | Fail | 15m 55s | 1.97M |
$1.06 | — |
| Sonnet 4.5 | Bundled guide | 5/10 (50%) | +30 pp | Fail | 15m 49s |
2.17M | $1.11 | +4.5% |
| Sonnet 4.5 | Local Skill | 8/10 (80%) | +30 pp | Fail | 15m 31s |
1.41M | $0.77 | -30.4% |

Token totals count each unique model message once and include uncached
input, cache creation, cache reads, and output. Cache reads account for
most of the total in every treatment. Total run cost applies the current
AI Gateway base rates to each token category: Opus 4.8 costs $5/M input,
$6.25/M cache writes, $0.50/M cache reads, and $25/M output; Sonnet 4.5
costs $3/M input, $3.75/M cache writes, $0.30/M cache reads, and $15/M
output. The six agent runs cost $11.78 in total.

Compared with baseline, the Opus Skill run maintained a 100% criteria
pass rate while reducing cost by 26.7%. The Sonnet Skill run improved
the criteria pass rate from 20% to 80%, a 60 percentage-point increase,
while reducing cost by 27.3%.

The stronger model could solve the fixture without the Skill, although
it independently searched the bundled Next.js documentation and took
slightly longer than the Skill treatment. The Opus guide-only run
implemented the correct behavior but missed the required
RED-before-GREEN proof.

The Sonnet treatments show a clearer progression. Baseline did not
implement the required caching, navigation stage, `instant()` contract,
or cache invalidation. The bundled guide added the cached summary and
production test, but missed the navigation stage, deferred-state
assertions, tag invalidation, and RED-before-GREEN proof. The Skill
treatment completed the `instant()` RED-to-GREEN loop and final
production behavior, but left `connection()` in the related-sessions
read and used path revalidation instead of tagging the cached read. It
therefore passed 8 of 10 criteria but failed the overall eval.

All six measured runs completed through grading. Chromium and its system
dependencies are provisioned before the agent starts, so missing browser
libraries did not determine these results.

## Verification

- `pnpm --filter=next build`
- `pnpm prettier --check` for the changed guide, prompt, Skill, and eval
fixture
- `pnpm exec tsc -p
evals/evals/agent-051-optimize-partial-prefetching/tsconfig.json
--noEmit`
- `node --check run-evals.js`
- `git diff --check`
- packed a local Next.js tarball and confirmed it contains the optimizer
guide plus the `navigation()` and `prefetch()` references
- verified the `navigation()` contract in the minimal reproduction and
NextBeats on Next.js 16.4.0-canary.22
- merged the latest `canary`, preserving both generic fixture setup and
Playwright provisioning

<!-- NEXT_JS_LLM -->
2026-09-10 22:27:36 +02:00

297 lines
17 KiB
Plaintext

---
title: Optimizing prefetching
description: Resolve per-link URL data with the prefetch prop, include session data in the App Shell, or exclude expensive content from a prefetch.
nav_title: Optimizing prefetching
related:
title: Learn more
description: Validate your structure and review the caching primitives.
links:
- app/api-reference/config/next-config-js/partialPrefetching
- app/api-reference/functions/prefetch
- app/api-reference/functions/navigation
- app/api-reference/file-conventions/route-segment-config/prefetch
- app/api-reference/file-conventions/route-segment-config/instant
- app/api-reference/directives/use-cache-private
- app/getting-started/caching
- app/guides/instant-navigation
- app/guides/prefetching
---
Prefetching downloads a route's JavaScript, CSS, and RSC payload before the user navigates to it, so the router can render the next route without waiting for a round trip. The [Prefetching guide](/docs/app/guides/prefetching) covers what the App Router prefetches by default.
With [Cache Components](/docs/app/getting-started/caching) and [Partial Prefetching](/docs/app/api-reference/config/next-config-js/partialPrefetching), a [`<Link>`](/docs/app/api-reference/components/link) prefetches one reusable [**App Shell**](/docs/app/glossary#app-shell) per route by default. The App Shell includes the route's static output. For routes that read [`cookies()`](/docs/app/api-reference/functions/cookies) or [`headers()`](/docs/app/api-reference/functions/headers), it also includes session-specific UI. Links to the same route reuse that App Shell.
The shared App Shell does not include URL data that varies by destination, such as [`searchParams`](/docs/app/api-reference/file-conventions/page#searchparams-optional) and [`params`](/docs/app/api-reference/file-conventions/page#params-optional). Set `prefetch={true}` on a link to resolve cached content that depends on its [URL data](/docs/app/glossary#url-data) before navigation instead of streaming that content after navigation.
This guide assumes [Cache Components](/docs/app/getting-started/caching) with [`partialPrefetching`](/docs/app/api-reference/config/next-config-js/partialPrefetching) enabled:
```ts filename="next.config.ts" highlight={4,5}
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
```
It also assumes your route is already structured for instant navigation. If it isn't, start with the [Instant navigation guide](/docs/app/guides/instant-navigation) to validate its caching structure first.
## Resolve URL data at prefetch time
Set `<Link prefetch={true}>` to resolve URL data for that link before navigation. The destination must use [Partial Prefetching](/docs/app/api-reference/config/next-config-js/partialPrefetching), enabled globally with `partialPrefetching` or per segment with [`prefetch = 'partial'`](/docs/app/api-reference/file-conventions/route-segment-config/prefetch#partial).
A user on `/` sees links to `/search?q=react` and `/search?q=next`, each opting in with `prefetch={true}`:
```tsx filename="app/page.tsx"
import Link from 'next/link'
export default function Home() {
return (
<nav>
<Link href="/search?q=react" prefetch={true}>
React
</Link>
<Link href="/search?q=next" prefetch={true}>
Next.js
</Link>
</nav>
)
}
```
`'auto'` is equivalent to omitting `prefetch` or passing `undefined`. In a conditional expression, return `'auto'` for links that should keep the default App Shell prefetch:
```tsx filename="app/page.tsx"
<Link href={href} prefetch={isFeatured ? true : 'auto'} />
```
Use `prefetch={false}` only when you want to disable prefetching for a link.
The `/search` page renders a static heading and a `<Results>` list based on the query. The `search()` function caches results by query so later requests can reuse them.
```tsx filename="app/search/page.tsx"
import { Suspense } from 'react'
export default function SearchPage({ searchParams }: PageProps<'/search'>) {
return (
<>
<h1>Search</h1>
<Suspense fallback={<ResultsSkeleton />}>
<Results searchParams={searchParams} />
</Suspense>
</>
)
}
async function Results({
searchParams,
}: {
searchParams: PageProps<'/search'>['searchParams']
}) {
const { q } = await searchParams
return <ResultList items={await search(q)} />
}
async function search(q: string) {
'use cache'
return db.search(q)
}
```
Without `prefetch={true}`, the App Shell renders `<h1>` and shows the `<Results>` fallback. The query resolves after the click and streams the results in.
With `prefetch={true}` on the link, the router prefetches a prerender that resolves `<Results>` before the click. The `q` value comes from the link's URL, known at prefetch time, and the cached `search(q)` provides the result. On the click, the results render immediately, with no fallback.
The prerender advances through anything static or cached, then stops at uncached reads and falls back to the surrounding `<Suspense>` boundary. That boundary is already in place from [structuring the route for instant navigation](/docs/app/guides/instant-navigation).
Generating the per-link prefetch costs **a server invocation per prefetchable link**, so it is opt-in per link. On pages where all the content is statically renderable, Next.js serves the prefetch from the static cache instead. A page that accesses non-static data is generated per prefetch.
> **Good to know:** A cold cache (first visit, or after expiration) means the server still has to compute the cached result. Users may see a loading spinner on that first navigation. Subsequent navigations are instant as long as the cache is warm.
Like `searchParams`, `params` needs a `<Suspense>` boundary, even when the values are predefined by [`generateStaticParams`](/docs/app/api-reference/functions/generate-static-params). A statically known param still belongs to one URL. A per-link prefetch with `prefetch={true}` resolves the values `generateStaticParams` does not cover.
## Include session data in the shell
`prefetch={true}` resolves URL data. Session data is handled separately. A route that reads `cookies()` or `headers()`, including through `"use cache: private"`, gets an App Shell that includes its session data, cached per session on the client and ready on navigation without a per-link prefetch.
A lookup based on session data needs a cache lifetime, the same way `search(q)` did for the URL. Take a dashboard nav that reads a cookie, then looks up content based on it:
```tsx filename="app/dashboard/layout.tsx"
import { Suspense } from 'react'
export default function DashboardLayout({
children,
}: LayoutProps<'/dashboard'>) {
return (
<div>
<Suspense fallback={<nav>Loading...</nav>}>
<UserNav />
</Suspense>
<main>{children}</main>
</div>
)
}
```
The cookie itself is session data the App Shell already knows. But `"use cache"` can't read `cookies()` inside the cached function, so two patterns bridge it:
- **Extract and pass** when the lookup result is shared across many sessions.
- **`"use cache: private"`** when it is tied to one.
### Extract and pass
Read the cookie outside the cached function and pass the value in as an argument. The `cookies()` call stays outside the cache scope, the argument crosses the boundary, and the cached function has a deterministic signature. The cache entry is keyed on that argument, and sessions that share the value share the entry.
```tsx filename="app/dashboard/user-nav.tsx"
import { cookies } from 'next/headers'
async function UserNav() {
const team = (await cookies()).get('team')?.value
const topics = await getTopics(team)
return (
<nav>
{topics.map((topic) => (
<a key={topic.id} href={topic.href}>
{topic.label}
</a>
))}
</nav>
)
}
async function getTopics(team: string | undefined) {
'use cache'
return db.topics.forTeam(team)
}
```
On a direct visit, `<UserNav>` shows its fallback until the lookup resolves. On navigation, the App Shell has already resolved it, because the team cookie is session data the shell can read. Because sessions on the same team share the cache entry, traffic to the underlying data scales with team count, not session count.
Anything without a caching directive still streams in after navigation. A shell holds only what can be prepared ahead of the navigation, not the whole page. It advances only as far as the caching structure allows.
### `"use cache: private"`
When the lookup is tied to a single session, use [`"use cache: private"`](/docs/app/api-reference/directives/use-cache-private). It assigns a cache lifetime to a function that reads cookies, headers, or other runtime data directly. Results are cached in the browser only, scoped to that session.
```tsx filename="app/dashboard/user-nav.tsx"
import { cookies } from 'next/headers'
async function UserNav() {
const user = await getUser()
return <nav>{user.name}</nav>
}
async function getUser() {
'use cache: private'
const session = (await cookies()).get('session')?.value
return db.users.findBySession(session)
}
```
Here `cookies()` lives inside the cached function, which only works under `"use cache: private"`. This is also the pattern when you can't extract the runtime data from the outside: auth helpers that check `Date.now()` against a token's expiry, or session helpers that read cookies deep inside their own code, can't be wrapped at the call site.
Everything inside the scope shares the same lifetime. Colocate `"use cache: private"` as close to the runtime data access as possible.
## Exclude content from a prefetch
Optimizing a prefetch also means excluding content from it. Some subtrees are expensive enough to produce that you would rather not pay for them on every prefetch of the route.
Awaiting [`unstable_navigation()`](/docs/app/api-reference/functions/navigation) above the work leaves it out of every prefetch, so it is produced on the navigation. The nearest [`<Suspense>`](https://react.dev/reference/react/Suspense) fallback is what the user sees while it streams in. Take the dashboard again, where a ranking keyed on the session is too expensive to produce for every link into the route:
```tsx filename="app/dashboard/recommendations.tsx" highlight={6}
import { cookies } from 'next/headers'
import { unstable_navigation } from 'next/cache'
async function Recommendations() {
const session = (await cookies()).get('session')?.value
await unstable_navigation()
const items = await rank(session)
return <List items={items} />
}
async function rank(session?: string) {
'use cache: private'
return db.recommendations.rank({ session })
}
```
The session is read above `unstable_navigation()`, so it resolves in the shell like any other session data. Only the ranking is left out, and it keeps its cache lifetime.
Without it, keeping a subtree out of a prefetch means leaving it uncached, which gives up that lifetime on the navigation too. Awaiting `unstable_navigation()` inside a cached scope is not allowed, so put the caching directive on a function called below it, as `rank` does here.
For a subtree that would otherwise sit in the App Shell, but that a `<Link prefetch={true}>` should still resolve before the click, use [`unstable_prefetch()`](/docs/app/api-reference/functions/prefetch) instead.
Unlike [`connection()`](/docs/app/api-reference/functions/connection), `unstable_navigation()` does not make the subtree request-dependent. This lets reusable content keep its cache lifetime while waiting for navigation. See [How `unstable_navigation()` differs from `connection()`](/docs/app/api-reference/functions/navigation#how-unstable_navigation-differs-from-connection) for the full comparison.
## Test prefetched and deferred content
The [Instant navigation guide](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) explains how to install and configure `@next/playwright`. For a client navigation, use `instant()` to inspect the destination's prefetched UI.
When [adopting Partial Prefetching](/docs/app/guides/adopting-partial-prefetching#verify-prefetched-ui-with-tests), run this test before enabling the feature and rerun the same assertions afterward. This preserves the UI selected from the legacy full prefetch while its implementation moves to the App Shell or a per-link prefetch.
Inside the callback, click the link, wait for the destination URL, and assert which content is available. After the callback releases the navigation, assert that deferred content eventually renders:
```ts filename="e2e/product-navigation.test.ts" highlight={8-15,17}
import { expect, test } from '@playwright/test'
import { instant } from '@next/playwright'
test('prefetches the product summary but not reviews', async ({ page }) => {
await page.goto('/products')
await instant(page, async () => {
await page.click('a[href="/products/baseball-cap"]')
await page.waitForURL((url) => url.pathname === '/products/baseball-cap')
await expect(
page.getByRole('heading', { name: 'Baseball Cap' })
).toBeVisible()
await expect(page.getByTestId('product-summary')).toBeVisible()
await expect(page.getByTestId('reviews')).toHaveCount(0)
})
await expect(page.getByTestId('reviews')).toBeVisible()
})
```
While the `instant()` callback is running, dynamic content is paused. In this example, the product summary is part of the prefetched UI, while reviews wait for navigation. The final assertion confirms that the reviews render after the pause is released.
## Trade-offs
Use `prefetch={true}` on routes where:
- Part of the component tree depends on URL data: the full URL, `searchParams`, or `params` not resolved by [`generateStaticParams`](/docs/app/api-reference/functions/generate-static-params)
- That part of the tree has a known cache lifetime (it can be expressed with `"use cache"` or `"use cache: private"`)
- The traffic justifies the per-link server invocation
Prefetching cached data does not change its freshness behavior. If a mutation can update the data, [tag and invalidate the cached result](/docs/app/getting-started/revalidating).
Skip it when the prefetch can't produce a better UI than the App Shell. Each visible `<Link prefetch={true}>` can wake a server, and that cost only pays off if more of the page is ready before the click:
- The route has little or no URL-data dependency. The App Shell already makes the navigation instant.
- The dependent content has to be fresh on every request. The prerender stops at the same `<Suspense>` fallback, so the user sees the same UI either way.
- The route is rarely navigated to. You pay per visible link, regardless of click-through.
A per-link prefetch is best-effort. It only helps the navigations where it completes before the click. On a slow connection, on a feed of many links, or on a direct visit, it may not be ready when the user navigates, and the navigation falls back to the App Shell.
When many links to a route are visible at once, such as a grid of cards, each `<Link prefetch={true}>` prefetches that link's content as it enters the viewport, so the grid makes one such server request per card. Prefetch on intent instead. A [hover-triggered prefetch](/docs/app/guides/prefetching#hover-triggered-prefetch) fetches only the links the user is likely to click. The default `<Link>` (without `prefetch={true}`) prefetches only the App Shell, so it doesn't carry this cost.
| | App Shell | Per-link prefetch with `prefetch={true}` |
| ------- | ------------------------------------------- | ---------------------------------------- |
| Scope | One per route | One per visible `<Link prefetch={true}>` |
| Content | Route's rendered output minus per-link data | Same, plus per-link URL data resolved |
| Cost | Bounded by route count | Bounded by visible-link count |
| Role | Default prefetch | More rendered before click |
[Excluding content](#exclude-content-from-a-prefetch) is one more control on top of these. The trade is generating content for a prefetch that may never be used, against momentarily showing a `<Suspense>` fallback while that content streams in.
## Next steps
- [Adopting Partial Prefetching](/docs/app/guides/adopting-partial-prefetching) for how `<Link>` behaves under the new model and how to migrate existing apps.
- [`prefetch` API reference](/docs/app/api-reference/file-conventions/route-segment-config/prefetch) for all prefetch modes.
- [`use cache: private` reference](/docs/app/api-reference/directives/use-cache-private) for per-user caching specifics.
- [`unstable_prefetch()`](/docs/app/api-reference/functions/prefetch) and [`unstable_navigation()`](/docs/app/api-reference/functions/navigation) for the full behavior of each prefetch stage.
- [Instant navigation guide](/docs/app/guides/instant-navigation) for validating the route's caching structure.
- [Caching](/docs/app/getting-started/caching) for background on `use cache`, Suspense, and Partial Prerendering.