Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
887c978
skill: add Partial Prefetching optimizer
aurorascharff Sep 7, 2026
b78201c
docs: fix optimizer skill language lint
aurorascharff Sep 7, 2026
0c3d0fe
docs: link optimizer skill to cache stage references
aurorascharff Sep 8, 2026
1532950
test(evals): make optimizer verification runnable
aurorascharff Sep 8, 2026
ffb8b0d
docs: clarify partial prefetching optimization
aurorascharff Sep 8, 2026
63b8faf
docs: refine optimizing prefetching guide
aurorascharff Sep 8, 2026
03636e4
skill: align prefetch optimizer with guides
aurorascharff Sep 8, 2026
303ce28
docs: limit prefetching guide changes
aurorascharff Sep 8, 2026
372dff6
docs: refine Partial Prefetching optimizer guidance
aurorascharff Sep 8, 2026
3143198
skill: keep prefetch tests focused
aurorascharff Sep 8, 2026
e2a3c1f
docs: clarify Partial Prefetching optimizer contract
aurorascharff Sep 9, 2026
ab2e528
docs: clarify optimizer autonomy
aurorascharff Sep 9, 2026
d17f1ce
docs: describe optimizer navigation scope
aurorascharff Sep 9, 2026
afc6c94
docs: remove redundant connection guidance
aurorascharff Sep 9, 2026
963023e
docs: clarify reusable navigation-only work
aurorascharff Sep 9, 2026
71bb19e
docs: route optimizer to bundled stage reference
aurorascharff Sep 9, 2026
3d90ec5
docs: link canonical navigation reference
aurorascharff Sep 9, 2026
626f2bf
Merge remote-tracking branch 'origin/canary' into codex/partial-prefe…
aurorascharff Sep 9, 2026
42f392f
Merge remote-tracking branch 'origin/canary' into codex/partial-prefe…
aurorascharff Sep 9, 2026
219d7c4
docs: clarify conditional link prefetching
aurorascharff Sep 9, 2026
c968a9d
Merge remote-tracking branch 'origin/codex/partial-prefetching-optimi…
aurorascharff Sep 9, 2026
a6c639b
test: exclude optimizer eval harness from app build
aurorascharff Sep 9, 2026
71c4358
skill: bundle Partial Prefetching optimizer test rig
aurorascharff Sep 9, 2026
9e32843
docs(skills): align instant navigation test rigs
aurorascharff Sep 9, 2026
efa7835
docs: show Partial Prefetching preservation test
aurorascharff Sep 9, 2026
f98213d
docs: verify cache freshness after writes
aurorascharff Sep 10, 2026
bf2fbf8
docs: refine cache revalidation guidance
aurorascharff Sep 10, 2026
9739ff4
test: evaluate cache freshness after mutations
aurorascharff Sep 10, 2026
b963a31
test: distinguish time-based and on-demand freshness
aurorascharff Sep 10, 2026
42d79dd
docs: link cache mutations to revalidation guide
aurorascharff Sep 10, 2026
466149e
skill: align optimizer user handoff
aurorascharff Sep 10, 2026
7da52ed
docs: clarify cache revalidation
aurorascharff Sep 10, 2026
c8ab0c1
docs: clarify auto prefetch behavior
aurorascharff Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/01-app/01-getting-started/08-caching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The [`use cache`](/docs/app/api-reference/directives/use-cache) directive caches
- **Data-level**: Cache a function that fetches or computes data (e.g., `getProducts()`, `getUser(id)`)
- **UI-level**: Cache an entire component or page (e.g., `async function BlogPosts()`)

A cache directive gives a result a lifetime, information Next.js uses to apply rendering optimizations. See [Prerendering](#prerendering) for how cached results become part of the static shell and may be included in a [prefetch](#prefetching).
A cache directive gives a result a lifetime, which Next.js uses for time-based revalidation and rendering optimizations. For on-demand revalidation after a mutation, see [Revalidating](/docs/app/getting-started/revalidating). See [Prerendering](#prerendering) for how cached results become part of the static shell and may be included in a [prefetch](#prefetching).

> **Good to know:** We recommend pairing every cache directive with a [`cacheLife`](/docs/app/api-reference/functions/cacheLife). Without one, the implicit `default` profile applies.

Expand Down
25 changes: 24 additions & 1 deletion docs/01-app/02-guides/adopting-partial-prefetching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,30 @@ A legacy full prefetch can include more UI than the navigation needs. In most ca

### Verify prefetched UI with tests

To verify the migration programmatically, an [`instant()`](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) test can capture the UI that should stay available from an existing full prefetch. Run this baseline against a production build with `partialPrefetching` disabled because automatic prefetching doesn't run in `next dev`. Then rerun the same assertions after adopting the destination. A failure identifies UI the App Shell no longer carries, so you can cache the data or subtree that restores it. The passing test provides regression coverage.
To verify the migration programmatically, an [`instant()`](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) test can capture the UI that should stay available from an existing full prefetch. Test the exact source link because different links to the same destination can use different prefetch settings:

```ts filename="e2e/product-navigation.test.ts" highlight={7-16}
import { expect, test } from '@playwright/test'
import { instant } from '@next/playwright'

test('preserves the prefetched product summary', async ({ page }) => {
await page.goto('/products')

await instant(page, async () => {
const link = page.getByRole('link', { name: 'Baseball Cap' })
await expect(link).toBeVisible()
await link.click()
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()
})
})
```

Run this baseline against a production build with `partialPrefetching` disabled because automatic prefetching doesn't run in `next dev`. Then rerun the same test after adopting the destination. If it fails, the selected UI is no longer prefetched. Cache the reusable data or subtree that should stay available, then keep the passing test as regression coverage.

### Static or cached content

Expand Down
20 changes: 20 additions & 0 deletions docs/01-app/02-guides/ai-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,23 @@ Then give the agent this prompt:
```prompt
Adopt Partial Prefetching in this project using the next-partial-prefetching-adoption Skill.
```

### `next-partial-prefetching-optimizer`

The [`next-partial-prefetching-optimizer`](https://www.skills.sh/vercel/next.js/next-partial-prefetching-optimizer) Skill takes a target navigation or set of navigations and works toward the UI you want ready before each click:

1. Turns the requested behavior for each navigation into a contract for what is prefetched, what waits for navigation, and when prefetching begins.
2. Captures each contract in an [`instant()`](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) test.
3. Applies the [Optimizing prefetching](/docs/app/guides/optimizing-prefetching) patterns and keeps the passing tests as regression coverage.

It needs Cache Components and Partial Prefetching already adopted.

```bash filename="Terminal"
npx skills add vercel/next.js --skill next-partial-prefetching-optimizer
```

Then give the agent a prompt like:

```prompt
Make the product title instant when navigating from /products to /products/[id]. Use the next-partial-prefetching-optimizer Skill.
```
46 changes: 45 additions & 1 deletion docs/01-app/02-guides/optimizing-prefetching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,15 @@ export default function Home() {
}
```

The destination renders a static heading and a `<Results>` list whose contents depend on the query. Each query is cached, computed once and reused.
`'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'
Expand Down Expand Up @@ -215,6 +223,40 @@ Without it, keeping a subtree out of a prefetch means leaving it uncached, which

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:
Expand All @@ -223,6 +265,8 @@ Use `prefetch={true}` on routes where:
- 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.
Expand Down
10 changes: 9 additions & 1 deletion docs/01-app/03-api-reference/04-functions/navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ version: experimental
related:
links:
- app/api-reference/functions/prefetch
- app/api-reference/functions/connection
- app/guides/adopting-partial-prefetching
- app/api-reference/functions/io
- app/api-reference/config/next-config-js/partialPrefetching
Expand Down Expand Up @@ -118,6 +119,14 @@ The slug and the cookie are both read below `unstable_navigation()` and passed a

Put the directive on a function called below it, as `getComments` does here. Awaiting `unstable_navigation()` inside a cached scope, such as [`use cache`](/docs/app/api-reference/directives/use-cache) or [`use cache: private`](/docs/app/api-reference/directives/use-cache-private), is not allowed.

## How `unstable_navigation()` differs from `connection()`

The [`connection()`](/docs/app/api-reference/functions/connection) function waits for a real request. It excludes the code that follows from prerendering and prefetching, making that work request-dependent.

`unstable_navigation()` only excludes the code that follows from route prefetches. It resolves when the user navigates without making that work request-dependent. A function called after `await unstable_navigation()` can therefore use [`"use cache"`](/docs/app/api-reference/directives/use-cache) and keep its cache lifetime without generating its result during prefetching.

Use `connection()` when the work must run for a real request. Use `unstable_navigation()` when the work is reusable but should wait until the user navigates.

## When to use it

Reach for it when the content below `unstable_navigation()` is expensive to produce, in compute, in upstream requests, or in latency, and you do not want every prefetch of the route to generate it.
Expand Down Expand Up @@ -158,7 +167,6 @@ async function Stories() {
## Good to know

- The function has no effect on the initial load of a page.
- Unlike [`connection()`](/docs/app/api-reference/functions/connection), it does not mark the subtree as request-dependent. Content below `await unstable_navigation()` stays fully cacheable and can be wrapped in [`use cache`](/docs/app/api-reference/directives/use-cache). It also keeps its cache lifetime.
- Reading `cookies()` or `headers()` below `unstable_navigation()` does not make the route's prefetches render per request, so they can still be served from static output. Those accesses are ignored, because they would not have resolved in a prefetch either.
- A call outside a `<Suspense>` boundary surfaces a validation insight in development. It is reported even on a page that is currently prerendered in full, because the same page starts deferring content as soon as it switches to a runtime shell.
- Calling it inside `use cache`, `use cache: private`, [`unstable_cache()`](/docs/app/api-reference/functions/unstable_cache), [`after()`](/docs/app/api-reference/functions/after), [`generateStaticParams`](/docs/app/api-reference/functions/generate-static-params), or a Client Component throws.
Expand Down
10 changes: 9 additions & 1 deletion docs/01-app/03-api-reference/04-functions/prefetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ version: experimental
related:
links:
- app/api-reference/functions/navigation
- app/api-reference/functions/connection
- app/guides/adopting-partial-prefetching
- app/api-reference/functions/io
- app/api-reference/config/next-config-js/partialPrefetching
Expand Down Expand Up @@ -122,6 +123,14 @@ The greeting reads the session and stays in the App Shell. The recommendations r

Put the directive on a function called below it, as `getRecommendations` does here. Awaiting `unstable_prefetch()` inside a cached scope, such as [`use cache`](/docs/app/api-reference/directives/use-cache) or [`use cache: private`](/docs/app/api-reference/directives/use-cache-private), is not allowed.

## How `unstable_prefetch()` differs from `connection()`

The [`connection()`](/docs/app/api-reference/functions/connection) function waits for a real request. It excludes the code that follows from prerendering and prefetching, making that work request-dependent.

`unstable_prefetch()` only excludes the code that follows from the App Shell. It can resolve during a per-link prefetch without making that work request-dependent. A function called after `await unstable_prefetch()` can therefore use [`"use cache"`](/docs/app/api-reference/directives/use-cache) and keep its cache lifetime without generating its result for every link into the route.

Use `connection()` when the work must run for a real request. Use `unstable_prefetch()` when the work is reusable but should only run for a selected per-link prefetch or a navigation.

## When to use it

Reach for it when the content below `unstable_prefetch()` is expensive to produce, in compute, in upstream requests, or in latency, and you do not want it generated in the App Shell that every `<Link>` into the route prefetches.
Expand All @@ -139,7 +148,6 @@ When every read of [`cookies()`](/docs/app/api-reference/functions/cookies) and
## Good to know

- The function has no effect on the initial load of a page.
- Unlike [`connection()`](/docs/app/api-reference/functions/connection), it does not mark the subtree as request-dependent. Content below `await unstable_prefetch()` stays fully cacheable and can be wrapped in [`use cache`](/docs/app/api-reference/directives/use-cache).
- A call outside a `<Suspense>` boundary surfaces a validation insight in development. It is reported even on a page that is currently prerendered in full, because the same page starts deferring content as soon as it switches to a runtime shell.
- Calling it inside `use cache`, `use cache: private`, [`unstable_cache()`](/docs/app/api-reference/functions/unstable_cache), [`after()`](/docs/app/api-reference/functions/after), [`generateStaticParams`](/docs/app/api-reference/functions/generate-static-params), or a Client Component throws.

Expand Down
4 changes: 4 additions & 0 deletions evals/eval.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"skills": ["next-cache-components-adoption"],
"timeout": 1800
},
"agent-051-optimize-partial-prefetching": {
"skills": ["next-partial-prefetching-optimizer"],
"timeout": 1800
},
"agent-054-cache-components-empty-static-params": {
"skills": ["next-cache-components-adoption"],
"timeout": 1800
Expand Down
92 changes: 92 additions & 0 deletions evals/evals/agent-051-optimize-partial-prefetching/EVAL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Optimize one Partial Prefetching navigation
*
* The fixture starts after Cache Components and Partial Prefetching adoption.
* The agent must turn one accepted, URL-specific product goal into an
* instant() RED-to-GREEN loop without expanding the cost to every card.
*/

import { expect, test } from 'vitest'
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
import { environment, transcript } from '@vercel/agent-eval/eval'

const IGNORE_DIRS = new Set([
'.git',
'.next',
'node_modules',
'dist',
'build',
'coverage',
])

function readSourceFiles(dir: string): string[] {
if (!existsSync(dir)) return []

return readdirSync(dir).flatMap((entry) => {
if (IGNORE_DIRS.has(entry)) return []
const path = join(dir, entry)
if (statSync(path).isDirectory()) return readSourceFiles(path)
if (entry === 'EVAL.ts' || !/\.(ts|tsx|js|jsx)$/.test(entry)) return []
return readFileSync(path, 'utf-8')
})
}

const source = readSourceFiles(process.cwd()).join('\n')

test('keeps Cache Components and Partial Prefetching enabled', () => {
const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8')

expect(config).toMatch(/cacheComponents\s*:\s*true/)
expect(config).toMatch(/partialPrefetching\s*:\s*true/)
})

test('adds a click-driven instant navigation regression test', () => {
expect(source).toMatch(/from\s+['"]@next\/playwright['"]/)
expect(source).toMatch(/\binstant\s*\(/)
expect(source).toMatch(/\.click\s*\(/)
})

test('adds reusable cached work', () => {
expect(source).toMatch(/['"]use cache['"]/)
})

test('uses the navigation stage', () => {
expect(source).toMatch(/\b(?:unstable_)?navigation\s*\(/)
})

test('optimizes only the selected featured navigation', async () => {
await expect(environment).toSatisfyCriterion(
`Only the featured Aurora keynote Link from /sessions opts into per-link prefetching with prefetch={true} or the equivalent bare prefetch prop. The other session-card Links retain the default or auto prefetch behavior. The solution does not add prefetch={false} or enable per-link prefetching for every card.`
)
})

test('assigns the destination UI to the intended stages', async () => {
await expect(environment).toSatisfyCriterion(
`The final /sessions/aurora-keynote implementation makes the public title "Aurora Keynote", speaker "Mina Park", and summary reusable through a focused use-cache function. An explicit cacheLife is optional because the fixture has no existing time-based freshness contract; this does not remove the separate on-demand invalidation requirement for edits. The result varies by session slug, and the cache does not contain connection(), cookies(), headers(), or live audience questions. Related sessions are also reusable cached data, but an await navigation() boundary runs before that cached work is called. Live audience questions remain uncached request-time content and continue streaming after navigation.`
)
})

test('invalidates cached session data after an edit', async () => {
await expect(environment).toSatisfyCriterion(
`The cached session read has a cache tag that identifies the edited session, and saveSessionSummary invalidates the same tag after updateSessionSummary succeeds. The invalidation provides read-your-own-writes behavior for the Server Action; cacheLife alone is not treated as sufficient.`
)
})

test('ships the exact positive instant contract', async () => {
await expect(environment).toSatisfyCriterion(
`The project retains a production-mode @next/playwright instant() test that starts at /sessions and clicks the featured Aurora keynote Link. While instant() holds dynamic writes, the test asserts the Aurora Keynote title, Mina Park speaker, and session summary are visible, while related sessions and live audience questions are absent. After the lock releases, the deferred regions are allowed to render. The testing API is exposed only for the measured production test build.`
)
})

test('completed a verified RED-to-GREEN optimizer loop', async () => {
await expect(transcript).toSatisfyCriterion(
`The agent verified the exact featured-Link navigation in a production build. Before changing the implementation, it proved the destination content rendered normally and that the locked instant() contract failed while the route shell remained available. It then applied the targeted optimization and reran the same locked test successfully. Merely writing the test, running only a development server, or describing commands for the user does not satisfy this criterion.`
)
})

test('verified session summary freshness after a write', async () => {
await expect(transcript).toSatisfyCriterion(
`The agent ran a behavioral freshness check that first populated the cache for a session, then updated its summary through the existing mutation, and finally read the session again and observed the updated summary. Source inspection, merely adding invalidation code, or testing the mutation before the cache was populated does not satisfy this criterion.`
)
})
Loading
Loading