Instant navigations and partial prefetching
See what a Next.js link prefetches with partial prefetching, load more or less with prefetch={true}, navigation() and prefetch(), and keep routes static.
- 01Read what a link prefetchesFind the App Shell, the per-link prefetch and the navigation request in a recording, and say what each one holds.
- 02Move content between stagesUse
<Link prefetch={true}>,navigation()andprefetch()to choose what's ready at the click and what waits. - 03Guarantee and check instant routesRequire static output with
ensureStatic, know which hidden pages the Back button keeps, and use Instant Insights and the Navigation Inspector.
A click that shows the page at once
Linking and navigating showed that a Link prefetches the page it points to, and Partial Prerendering showed a page made of a static shell with holes that stream in. Put the two together and a click can be instant. The docs define it this way: the browser starts drawing the new page the moment the visitor clicks, with its static, cached and fallback parts on the screen at once, while the server streams the rest into the fallbacks.
Here is the tea shop with one page per tea, at /teas/[tea]. It's a real Next.js 16.4.3 project, built with next build, run with next start, and opened in real Chrome. Chrome opened the home page, waited until the router's prefetch of the tea page had answered, made the network slow (each request now waits 1.5 seconds, like a weak phone signal), and clicked Mint tea. The Steps tab opens at the click:
app/teas/[tea]/page.tsx1import { Suspense } from "react";2import { getStock, getTea } from "@/lib/supplier";3 4type Params = PageProps<"/teas/[tea]">["params"];5 6export default function TeaPage({ params }: PageProps<"/teas/[tea]">) {7 return (8 <main>9 <p>Packed by hand, sent the same day.</p>10 <Suspense fallback={<h1>Finding the tea…</h1>}>11 <TeaDetails params={params} />12 </Suspense>13 <Suspense fallback={<p>Checking stock…</p>}>14 <Stock params={params} />15 </Suspense>16 </main>17 );18}19 20async function TeaDetails({ params }: { params: Params }) {21 const { tea } = await params;22 const details = await getTea(tea);23 return <h1>{details.name}: ${details.price}</h1>;24}25 26async function Stock({ params }: { params: Params }) {27 const { tea } = await params;28 const stock = await getStock(tea);29 return <p>In stock: {stock.boxes} boxes</p>;30}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Packed by hand, sent the same day.
Checking stock…
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), files and other sites included
GET/teas/mintno answer yetfetch- rsc
- 1
- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%5B%22tea%22%2C%22mint%22%2C%22d%22%2Cnull%5D%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2C%22refetch%22%2C16546%5D%7D%2Cnull%2Cnull%2C16482%5D%7D%2Cnull%2Cnull%2C16482%5D%7D%2Cnull%2Cnull%2C16466%5D
- next-url
- /
In app/teas/[tea]/page.tsx, line 9 is the same for every tea. Lines 10 to 12 wrap TeaDetails, which reads the tea from params and calls the cached getTea, in a Suspense with the fallback Finding the tea…. Lines 13 to 15 do the same for Stock, whose getStock in lib/supplier.ts isn't cached, with Checking stock….
At step 4, the click, the address is already /teas/mint and the page shows Packed by hand, sent the same day., Finding the tea… and Checking stock…. Its one request is still on its way: it has no status yet. That screen came from what the router prefetched before the click. Steps 5 and 6 wait for the price and the stock, which arrive in the answer to that request. Click a step to see the page after it.
prefetch={true} also loads what depends on its own URL and is cached. The click brings the rest, like the stock.A tea room sets every table the same way each morning, so a guest can sit down at once. For a regular who booked, the staff also pour the usual tea. The rest, like a fresh cake, is made after the guest orders. Nobody waits at the door either way.
- In real life: Every table set the same way, with cups and a menu, before anyone sits
- In Next.js: The App Shell: one per route, ready before the click
- In real life: A table with a reserved card and the regular's usual tea already poured
- In Next.js: A per-link prefetch, from
<Link prefetch={true}> - In real life: The food, cooked after the order
- In Next.js: What the navigation renders after the click
With partial prefetching, a link loads the route's App Shell before the click, so the next page shows at once and only its holes wait for the server. <Link prefetch={true} loads more for one link, navigation() and prefetch() hold work back, and ensureStatic makes sure a route's output stays static.
What a prefetch fetches
Go back to steps 1 and 2 of the recording above. This example lists every request the page made, scripts included, so look for the ones of type fetch with the header rsc: 1. Those are the router's, and each one with next-router-prefetch: 1 is a prefetch. There are two kinds:
- The route tree, for each link. Requests with
next-router-segment-prefetch: /_tree, one for/teas/green, one for/teas/mint, one for/teas/lemon(and one for/, the Home link). Each tells the router which folders and layouts that address uses. - The page's part, once for the route. One request with
next-router-segment-prefetch: /teas/$d$tea/__PAGE__.$d$teais the[tea]folder left unfilled: this is the page for any tea, not for mint. It's the route's App Shell, and all three links share it.
The recorder sent that shell request on its own, twice, with the headers Chrome sent: once from the Mint tea link's address and once from Green tea's. In the Network tab, pick the shell, for the Mint tea link, then the one for Green tea. The two answers are the same, byte for byte. Each is an RSC payload (the description of a rendered page that React uses on a client-side navigation) holding Packed by hand, sent the same day. and the two fallbacks, and no tea name, price or stock. Its header x-nextjs-cache: HIT, a header of Next.js's own, says it came from what the build made.
partialPrefetching: true, the three links share one App Shell for /teas/[tea], fetched once. Right: with it off, the recording made one page prefetch for each link.The _rsc values in the addresses, the next-router-… headers and their numbers (1, 2 and 3), the router state in next-router-state-tree, and segment names like /teas/$d$tea/__PAGE__ are what Next.js 16.4.3's router sends. They help you read a recording; they aren't an API to build on, and another version can send others. What stays true is the model: one App Shell per route, more for a link that asks for it, and the rest after the click.
That's partial prefetching: a link prefetches the part of the route that doesn't depend on its URL, and links to the same route share it. Next.js 16.3 added it, as the partialPrefetching option in next.config.ts; it needs Cache Components. The next.config.ts that create-next-app 16.4.3 writes turns both on. Leaving the option out means off, with a warning. Before, a link prefetched for itself. Compare:
app/teas/[tea]/page.tsx1import { Suspense } from "react";2import { getStock, getTea } from "@/lib/supplier";3 4type Params = PageProps<"/teas/[tea]">["params"];5 6export default function TeaPage({ params }: PageProps<"/teas/[tea]">) {7 return (8 <main>9 <p>Packed by hand, sent the same day.</p>10 <Suspense fallback={<h1>Finding the tea…</h1>}>11 <TeaDetails params={params} />12 </Suspense>13 <Suspense fallback={<p>Checking stock…</p>}>14 <Stock params={params} />15 </Suspense>16 </main>17 );18}19 20async function TeaDetails({ params }: { params: Params }) {21 const { tea } = await params;22 const details = await getTea(tea);23 return <h1>{details.name}: ${details.price}</h1>;24}25 26async function Stock({ params }: { params: Params }) {27 const { tea } = await params;28 const stock = await getStock(tea);29 return <p>In stock: {stock.boxes} boxes</p>;30}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
- Lemon tea
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (11), files and other sites included
GET/200a whole pageGET/_next/static/chunks/1x6obpmlu1_c2.js200application/javascriptGET/_next/static/chunks/06bs-nf19b2js.js200application/javascriptGET/_next/static/chunks/2ki52vdnbo2ft.js200application/javascriptGET/_next/static/chunks/3l_qmp-61z4yc.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/3oh00mkbkq8u6.js200application/javascriptGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/greenno answer yetfetch- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/mintno answer yetfetch- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas/lemonno answer yetfetch- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
Before the click, the router asked for the route tree of each link (/_tree), and for the tea page's own part once: next-router-segment-prefetch: /teas/$d$tea/__PAGE__. $d$tea is the [tea] folder left unfilled. That one App Shell serves all three links.
With partial prefetching off, the page's part was fetched once for each of the three links. On a page with twenty links to twenty teas, that's twenty prefetches of the page's part instead of one. The Next.js team plans to turn partial prefetching on everywhere in the next major release, and remove the option.
The shell is made for each visitor when the route reads cookies
The tea page reads nothing about the visitor, so its App Shell is the static shell from the build, the same for everyone. A route that reads cookies() or headers() gets an App Shell rendered for each session instead. The router asks for it with next-router-prefetch: 3, and keeps it for that visitor. Private and remote caches recorded one: a click on Your orders that needed no request. The account page below works the same way.
Prefetching more for one link
The shared shell can't hold the tea's name: it depends on params, which is different for each link. The docs call params and searchParams URL data. A link can ask for more with prefetch={true}:
import Link from "next/link";
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<ul>
<li><Link href="/teas/green">Green tea</Link></li>
<li><Link href="/teas/mint" prefetch={true}>Mint tea</Link></li>
<li><Link href="/teas/lemon">Lemon tea</Link></li>
</ul>
</main>
);
}
Line 9 is the only change. That link now also gets a per-link prefetch: a request with next-router-prefetch: 2, rendered on the server with the link's own URL. It goes as far as static and cached content goes, and stops at anything uncached, where it keeps the fallback. Pick a version and look at the screen at step 4:
app/page.tsx1import Link from "next/link";2 3export default function Home() {4 return (5 <main>6 <h1>Tea shop</h1>7 <ul>8 <li><Link href="/teas/green">Green tea</Link></li>9 <li><Link href="/teas/mint">Mint tea</Link></li>10 <li><Link href="/teas/lemon">Lemon tea</Link></li>11 </ul>12 </main>13 );14}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Packed by hand, sent the same day.
Checking stock…
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), files and other sites included
GET/teas/mintno answer yetfetch- rsc
- 1
- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%5B%22tea%22%2C%22mint%22%2C%22d%22%2Cnull%5D%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2C%22refetch%22%2C16546%5D%7D%2Cnull%2Cnull%2C16482%5D%7D%2Cnull%2Cnull%2C16482%5D%7D%2Cnull%2Cnull%2C16466%5D
- next-url
- /
At the click, the address is already /teas/mint and the page shows the App Shell: Packed by hand…, Finding the tea… and Checking stock…. The navigation's request is still on its way, and the details and stock stream in when it answers.
With prefetch={true}, the price was on the screen at the click, because getTea is cached and the prefetch knew the tea from the URL. The stock still waited: it isn't cached. The third version adds navigation(), which holds the details back until the click, and the heading is back to Finding the tea….
A per-link prefetch costs a server render for each link that comes on the screen, unless the page is fully static, so the docs make it opt-in, link by link. The page it points to must use partial prefetching, from the option or from export const prefetch = "partial" in that page or layout. And prefetch={false} still turns prefetching off for a link.
navigation() and prefetch()
Next.js now loads a route's server output in up to three stages. The docs call them navigation stages. Each row is one stage, what loads it, and what may be in it:
| Stage | What loads it | What may be in it |
|---|---|---|
| The App Shell | A plain <Link> coming on the screen, once for each route. | What doesn't depend on the URL: static parts, fallbacks, cached results with a stale of 5 minutes or more, and session data when the route reads cookies. |
| A per-link prefetch | A <Link prefetch={true}> coming on the screen, once for each link. | The App Shell plus what depends on the link's URL and is static or cached, like a tea's details from params. |
| The navigation | The click. | Everything else: uncached data, and whatever navigation() held back. |
Sometimes content could go in an early stage but shouldn't: it's expensive, and most visitors never click. Next.js 16.4 added two functions in next/cache that move the code below them to a later stage. Both need Cache Components, both go inside a Suspense, and neither may be awaited inside a cached function. Each row is one function, with connection() for comparison:
| Call | What it does to the code below it | Where that code runs | Can that code stay cached? |
|---|---|---|---|
await prefetch() | Leaves the App Shell. | A per-link prefetch, or the click. | Yes. |
await navigation() | Leaves the App Shell and every per-link prefetch. | The click. | Yes. |
await connection() | Leaves every prerender and prefetch. | Each real request. | No: the work becomes request-time. |
navigation() is the third version in the playground above: await navigation() at the top of TeaDetails kept the details out of the per-link prefetch, so the tea page could show the rest at once and fetch the details only for a visitor who clicks. getTea keeps its cache.
prefetch() works one stage earlier. The shop's account page greets the signed-in visitor and shows teas picked for her by a private cache. Both read the session cookie, so the App Shell is made for her session, and both are in it. Asha signs in, waits, and clicks Your account:
app/account/page.tsxNext.jsapp/account/page.tsx1import { Suspense } from "react";2import { cookies } from "next/headers";3import { cacheLife } from "next/cache";4 5export default function AccountPage() {6 return (7 <main>8 <h1>Your account</h1>9 <Suspense fallback={<p>Loading…</p>}>10 <Greeting />11 </Suspense>12 <Suspense fallback={<p>Choosing teas for you…</p>}>13 <Picks />14 </Suspense>15 </main>16 );17}18 19async function Greeting() {20 const name = (await cookies()).get("session")?.value ?? "guest";21 return <p>Welcome back, {name}.</p>;22}23 24async function Picks() {25 const picks = await getPicks();26 return <p>Picked for you: {picks.join(", ")}</p>;27}28 29async function getPicks(): Promise<string[]> {30 "use cache: private";31 cacheLife("hours");32 const name = (await cookies()).get("session")?.value;33 const response = await fetch(`${process.env.SUPPLIER_API_URL}/picks?for=${name}`);34 return response.json();35}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Welcome back, Asha.
Picked for you: Mint tea, Lemon tea
- Focus
- the link “Your account”
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
The greeting reads the session cookie on line 20, and the picks come from a private cache. The App Shell was rendered for Asha's session (next-router-prefetch: 3) with both, so the click made no request.
In the first version, the click made no request: the shell rendered for Asha already held Welcome back, Asha. and her picks. In the second, await prefetch() on line 25 held the picks back. In the Network tab, the shell for Asha now holds Choosing teas for you… where the picks were, and the click had to ask the server. The docs say a link with prefetch={true} to the account page would fetch them before the click.
Both functions only change what's rendered at request time. A page the build prerenders still has the content in its static output, and a link to it can still get it.
Guaranteeing static output with ensureStatic
A static route costs no server work: a CDN can send it. But one new component that reads a cookie can quietly make a route render for each request. Next.js 16.4 added ensureStatic, an export of a page or layout that makes a stage's output static, or the build fails. Each row is one value, from the docs:
| Value | Output that must be static | Checked how |
|---|---|---|
"auto" (or nothing) | Nothing more than usual. | No extra check. |
"shell" | The App Shell. | None at build time. With partial prefetching off, a warning and no effect. |
"prefetch" | The App Shell and every per-link prefetch. | None at build time. |
"navigation" | The whole server-rendered route. | next dev and next build stop on anything request-time, even inside Suspense. |
false | Nothing, and no other segment may ask for more. | The build stops if a parent or child sets a static level. |
The strictest is "navigation": the whole route, page loads and navigations alike. The tea page tried it:
import { Suspense } from "react";
import { getStock, getTea } from "@/lib/supplier";
type Params = PageProps<"/teas/[tea]">["params"];
export const ensureStatic = "navigation";
export default function TeaPage({ params }: PageProps<"/teas/[tea]">) {
return (
<main>
<p>Packed by hand, sent the same day.</p>
<Suspense fallback={<h1>Finding the tea…</h1>}>
<TeaDetails params={params} />
</Suspense>
<Suspense fallback={<p>Checking stock…</p>}>
<Stock params={params} />
</Suspense>
</main>
);
}
async function TeaDetails({ params }: { params: Params }) {
const { tea } = await params;
const details = await getTea(tea);
return <h1>{details.name}: ${details.price}</h1>;
}
async function Stock({ params }: { params: Params }) {
const { tea } = await params;
const stock = await getStock(tea);
return <p>In stock: {stock.boxes} boxes</p>;
}
Line 6 is the only change. Pick each version and read the Terminal:
app/teas/[tea]/page.tsx1import { Suspense } from "react";2import { getStock, getTea } from "@/lib/supplier";3 4type Params = PageProps<"/teas/[tea]">["params"];5 6export const ensureStatic = "navigation";7 8export default function TeaPage({ params }: PageProps<"/teas/[tea]">) {9 return (10 <main>11 <p>Packed by hand, sent the same day.</p>12 <Suspense fallback={<h1>Finding the tea…</h1>}>13 <TeaDetails params={params} />14 </Suspense>15 <Suspense fallback={<p>Checking stock…</p>}>16 <Stock params={params} />17 </Suspense>18 </main>19 );20}21 22async function TeaDetails({ params }: { params: Params }) {23 const { tea } = await params;24 const details = await getTea(tea);25 return <h1>{details.name}: ${details.price}</h1>;26}27 28async function Stock({ params }: { params: Params }) {29 const { tea } = await params;30 const stock = await getStock(tea);31 return <p>In stock: {stock.boxes} boxes</p>;32}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error: Page "/teas/[tea]": `ensureStatic = "navigation"` requires an exported `generateStaticParams()` function.Learn more: https://nextjs.org/docs/messages/generate-static-params#with-ensurestatic at ignore-listed frames > Build error occurredError: Failed to collect page data for /teas/[tea] at ignore-listed frames { type: 'Error'}Line 6 asks for the whole route to be static. The build stops at once: a dynamic route that must be static needs an exported generateStaticParams().
The first build stopped before rendering anything: a dynamic route that must be static needs generateStaticParams (generateStaticParams teaches it) with at least one full set of params. The second got further and stopped at the uncached stock. Unlike a normal build, a Suspense around it doesn't help: a hole that renders for each request is exactly what "navigation" forbids. The third caches the stock with cacheLife("minutes"), and each listed tea is ○. A lifetime with expire under 5 minutes, or stale under 30 seconds, would still fail, the docs say.
"shell" and "prefetch" are gentler: they make the router ask for static output for those stages, and request-time content inside Suspense waits for a later stage. Set on a layout, a level covers every page under it, and a page may only ask for something stricter. ensureStatic isn't instant, the export Instant Insights read: instant checks that a click shows something at once, and a static route can still fail that if a Client Component loads its data in the browser.
Pages kept for the Back button
Instant works backwards too. Layouts and pages showed that with Cache Components on, Next.js doesn't throw away a page you leave: it hides it with React's Activity, which keeps its state, and shows it again when you come back. Not every page, though. This version has four teas linked from each tea page, and a counter that lives in the browser:
app/teas/[tea]/page.tsx1import Link from "next/link";2import BrewCounter from "@/app/brew-counter";3 4const teas = ["mint", "green", "lemon", "chai"];5 6export function generateStaticParams() {7 return teas.map((tea) => ({ tea }));8}9 10export default async function TeaPage({ params }: PageProps<"/teas/[tea]">) {11 const { tea } = await params;12 return (13 <main>14 <h1>{tea} tea</h1>15 <BrewCounter />16 <p>17 {teas.map((other) => (18 <Link key={other} href={`/teas/${other}`}>19 {other}{" "}20 </Link>21 ))}22 </p>23 </main>24 );25}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Cups brewed: 0 Brew a cup
mint green lemon chai
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
In app/teas/[tea]/page.tsx, line 4 lists the teas and line 15 shows BrewCounter, from app/brew-counter.tsx: a Client Component whose count lives in its state. The steps: brew a cup on mint, go to green, to lemon, brew a cup there, go to chai, then press Back three times. On lemon, the count is still 1. On green, 0, as it was. On mint, 0: the cup brewed there was lost.
[tea] level, the router keeps three pages: chai on the screen, and lemon and green hidden, with their state. Mint was the fourth, so it was dropped, and its count of 1 was lost.For each level of folders, here the [tea] level, the router keeps the page on the screen plus at most two hidden ones, the most recently shown. In bfcache-state-manager.ts, MAX_BF_CACHE_ENTRIES is 3 with Cache Components on, counting the visible page, and 1 without it. When chai came on the screen, mint was the fourth, so it was dropped. The Back steps made no requests: the router still had mint's page data, only its React state was gone.
Instant Insights and the Navigation Inspector
Automatic viewport prefetching is turned off in next dev. Instant Insights and the Navigation Inspector, which Next.js 16.3 added, are development-only tools that look ahead from next dev; the docs describe them.
Instant Insights
With Cache Components on, next dev checks every page for anything that would make a navigation into it wait. It checks a page load and each client-side navigation, because a Suspense in a layout that stays on the screen can't catch a navigation below it. A problem shows in the dev overlay as an Instant Insight that names the component, with three fixes: stream it with Suspense, cache it with "use cache", or allow the route to block. The last one is the instant export:
// Reads the session before anything shows, so a navigation
// into /account from outside may wait for the server.
export const instant = false;
export default function AccountLayout({ children }: LayoutProps<"/account">) {
return <section>{children}</section>;
}
export const instant = true;
export default function OrdersPage() {
return <h1>Your orders</h1>;
}
Line 3 of the layout, instant = false, lets navigations from outside into /account block on the server. Line 1 of the page, instant = true, keeps checking navigations between the pages under it. These checks are warnings in next dev; the build doesn't run them. To check only the pages that export instant, set experimental.instantInsights.validationLevel to "manual-warning".
The Navigation Inspector
Open the Next.js DevTools (the small button in the corner of the page in next dev), pick Navigation Inspector, and turn on Pause on navigations. The next reload or click freezes the page at what shows first: the static shell on a reload (labeled Page load), the prefetched page on a click (Client nav). Resume lets it finish. It's the screen at step 4 of the recording, on demand, for any link.
To keep a route instant as the code changes, the @next/playwright package has an instant() helper for end-to-end tests: inside it, only what was ready at once is on the screen.
import { expect, test } from "@playwright/test";
import { instant } from "@next/playwright";
test("the tea page's shell shows at the click", async ({ page }) => {
await page.goto("/");
await instant(page, async () => {
await page.click('a[href="/teas/mint"]');
await page.waitForURL((url) => url.pathname === "/teas/mint");
await expect(page.getByText("Packed by hand, sent the same day.")).toBeVisible();
await expect(page.getByText("Checking stock…")).toBeVisible();
});
await expect(page.getByText("In stock: 12 boxes")).toBeVisible();
});
Lines 6 to 11 run inside instant(). Line 7 clicks the Mint tea link, line 8 waits for the new address, and lines 9 and 10 check the shell and a fallback. Line 12, after the helper, checks that the stock arrived. The helper works against next dev; for a production build, the docs say to build it with experimental.exposeTestingApiInProductionBuild on, for tests only. End-to-end testing, later in the course, sets up Playwright.
Planning the shop's navigations
For each piece of a page, ask when it can be ready: for every link to the route, for one link, or only after the click? Sort the shop's content into the stage where it arrives:
- The tea page's line
Packed by hand, sent the same day. - The fallback
Checking stock… Welcome back, Asha., read from her session cookie- A tea's name and price, cached, read from
params, behind a<Link prefetch={true}> - Search results for
?q=mint, cached by query, behind a<Link prefetch={true}> - An expensive cached ranking, below
await prefetch(), behind a<Link prefetch={true}> - Live stock, read with an uncached
fetch - A long message thread, cached, below
await navigation()
Sort each piece of the tea shop's pages into the navigation stage that first brings it to the browser.
Common mix-ups
- "A link prefetches the whole page." No. With partial prefetching, it prefetches the route's App Shell, shared by every link to that route. Content that depends on the URL, or isn't cached, comes later.
- "An instant navigation needs no request." No. The shell shows at once, and a request still fetches the holes. Only a page whose shell holds everything, like a fully static one, needs none.
- "
prefetch={true}prefetches everything." No. It stops at uncached work, and at anything belownavigation(). The stock still waited for the click. - "The App Shell is always the same for everyone." No. For a route that reads cookies or headers, it's rendered for each session.
- "Instant Insights check my production build." No. They're warnings in
next dev.ensureStatic = "navigation"is whatnext buildenforces. - "Next.js keeps every page I visited, with its state." No. At each folder level, it keeps the page on the screen and at most two hidden ones.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
Partial prefetching is on. When this home page loads, how many prefetches of the tea page's own part does the router send, not counting the route trees?
import Link from "next/link";
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<ul>
<li><Link href="/teas/green">Green tea</Link></li>
<li><Link href="/teas/mint">Mint tea</Link></li>
<li><Link href="/teas/lemon">Lemon tea</Link></li>
</ul>
</main>
);
}
1. The router asked for /teas/$d$tea/__PAGE__ once, and all three links share it. It also asked for each link's route tree (/_tree), which is a different, smaller request.
The home page links here with a plain Link. On a slow network, what does the heading show the moment the visitor clicks Mint tea?
import { Suspense } from "react";
import { getStock, getTea } from "@/lib/supplier";
type Params = PageProps<"/teas/[tea]">["params"];
export default function TeaPage({ params }: PageProps<"/teas/[tea]">) {
return (
<main>
<p>Packed by hand, sent the same day.</p>
<Suspense fallback={<h1>Finding the tea…</h1>}>
<TeaDetails params={params} />
</Suspense>
<Suspense fallback={<p>Checking stock…</p>}>
<Stock params={params} />
</Suspense>
</main>
);
}
async function TeaDetails({ params }: { params: Params }) {
const { tea } = await params;
const details = await getTea(tea);
return <h1>{details.name}: ${details.price}</h1>;
}
async function Stock({ params }: { params: Params }) {
const { tea } = await params;
const stock = await getStock(tea);
return <p>In stock: {stock.boxes} boxes</p>;
}
Finding the tea…. The name comes from params, which the shared shell can't hold, so the shell has the fallback on line 10. The recording shows it at the click.
Change the home page so Mint tea: $3 is already on the screen when the visitor clicks Mint tea. What do you add?
import Link from "next/link";
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<ul>
<li><Link href="/teas/green">Green tea</Link></li>
<li><Link href="/teas/mint">Mint tea</Link></li>
<li><Link href="/teas/lemon">Lemon tea</Link></li>
</ul>
</main>
);
}
import Link from "next/link";
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<ul>
<li><Link href="/teas/green">Green tea</Link></li>
<li><Link href="/teas/mint" prefetch={true}>Mint tea</Link></li>
<li><Link href="/teas/lemon">Lemon tea</Link></li>
</ul>
</main>
);
}
prefetch={true} on line 9. The link then gets a per-link prefetch that resolves params and the cached getTea("mint"), so Mint tea: $3 is on the screen at the click. The stock still waits.
This page stops next build before anything renders. What does the build say is missing?
import { Suspense } from "react";
import { getStock, getTea } from "@/lib/supplier";
type Params = PageProps<"/teas/[tea]">["params"];
export const ensureStatic = "navigation";
export default function TeaPage({ params }: PageProps<"/teas/[tea]">) {
return (
<main>
<p>Packed by hand, sent the same day.</p>
<Suspense fallback={<h1>Finding the tea…</h1>}>
<TeaDetails params={params} />
</Suspense>
<Suspense fallback={<p>Checking stock…</p>}>
<Stock params={params} />
</Suspense>
</main>
);
}
async function TeaDetails({ params }: { params: Params }) {
const { tea } = await params;
const details = await getTea(tea);
return <h1>{details.name}: ${details.price}</h1>;
}
async function Stock({ params }: { params: Params }) {
const { tea } = await params;
const stock = await getStock(tea);
return <p>In stock: {stock.boxes} boxes</p>;
}
import { Suspense } from "react";
import { getStock, getTea } from "@/lib/supplier";
type Params = PageProps<"/teas/[tea]">["params"];
export const ensureStatic = "navigation";
export function generateStaticParams() {
return [{ tea: "green" }, { tea: "mint" }, { tea: "lemon" }];
}
export default function TeaPage({ params }: PageProps<"/teas/[tea]">) {
return (
<main>
<p>Packed by hand, sent the same day.</p>
<Suspense fallback={<h1>Finding the tea…</h1>}>
<TeaDetails params={params} />
</Suspense>
<Suspense fallback={<p>Checking stock…</p>}>
<Stock params={params} />
</Suspense>
</main>
);
}
async function TeaDetails({ params }: { params: Params }) {
const { tea } = await params;
const details = await getTea(tea);
return <h1>{details.name}: ${details.price}</h1>;
}
async function Stock({ params }: { params: Params }) {
const { tea } = await params;
const stock = await getStock(tea);
return <p>In stock: {stock.boxes} boxes</p>;
}
generateStaticParams. The build said: ensureStatic = "navigation" requires an exported generateStaticParams() function. A route that must be static needs its params listed for the build.
With generateStaticParams added, the tea page with ensureStatic = "navigation" still fails: Next.js encountered uncached or runtime data on a route that must be fully static. What goes at the top of getStock?
const api = process.env.SUPPLIER_API_URL;
export type Tea = { id: string; name: string; price: number };
export async function getTea(id: string): Promise<Tea> {
"use cache";
const response = await fetch(`${api}/teas/${id}`);
return response.json();
}
export async function getStock(id: string): Promise<{ boxes: number }> {
const response = await fetch(`${api}/stock/${id}`);
return response.json();
}
import { cacheLife } from "next/cache";
const api = process.env.SUPPLIER_API_URL;
export type Tea = { id: string; name: string; price: number };
export async function getTea(id: string): Promise<Tea> {
"use cache";
const response = await fetch(`${api}/teas/${id}`);
return response.json();
}
export async function getStock(id: string): Promise<{ boxes: number }> {
"use cache";
cacheLife("minutes");
const response = await fetch(`${api}/stock/${id}`);
return response.json();
}
"use cache", with cacheLife("minutes") on the next line. Then the stock is cached for minutes, the build can prerender each tea with it, and it prints ○ /teas/mint.
A visitor brews a cup on mint, visits green and lemon, brews a cup on lemon, visits chai, then presses Back three times. Which tea's counter shows 0 although a cup was brewed there?
import Link from "next/link";
import BrewCounter from "@/app/brew-counter";
const teas = ["mint", "green", "lemon", "chai"];
export function generateStaticParams() {
return teas.map((tea) => ({ tea }));
}
export default async function TeaPage({ params }: PageProps<"/teas/[tea]">) {
const { tea } = await params;
return (
<main>
<h1>{tea} tea</h1>
<BrewCounter />
<p>
{teas.map((other) => (
<Link key={other} href={`/teas/${other}`}>
{other}{" "}
</Link>
))}
</p>
</main>
);
}
Mint. After chai, the router kept chai, lemon and green. Mint was the fourth, so its hidden page was dropped with its state. Back on mint, the counter starts again at 0, as the recording shows.
Check your understanding
For each question, ask which stage brings the content: the App Shell, a per-link prefetch, or the click.
Question 1 of 7With
partialPrefetching: true, three links point at/teas/green,/teas/mintand/teas/lemon. What does the router prefetch for the tea page itself?Choose an answer to see the explanation.
Question 2 of 7The Mint tea link has
prefetch={true}, and the tea page is this one. What does the heading show the moment the visitor clicks?Read the code, then predictTSXimport { Suspense } from "react"; import { navigation } from "next/cache"; import { getStock, getTea } from "@/lib/supplier"; type Params = PageProps<"/teas/[tea]">["params"]; export default function TeaPage({ params }: PageProps<"/teas/[tea]">) { return ( <main> <p>Packed by hand, sent the same day.</p> <Suspense fallback={<h1>Finding the tea…</h1>}> <TeaDetails params={params} /> </Suspense> <Suspense fallback={<p>Checking stock…</p>}> <Stock params={params} /> </Suspense> </main> ); } async function TeaDetails({ params }: { params: Params }) { await navigation(); const { tea } = await params; const details = await getTea(tea); return <h1>{details.name}: ${details.price}</h1>; } async function Stock({ params }: { params: Params }) { const { tea } = await params; const stock = await getStock(tea); return <p>In stock: {stock.boxes} boxes</p>; }Choose an answer to see the explanation.
Question 3 of 7Which function keeps content out of the App Shell, but still lets a
<Link prefetch={true}>load it before the click?Choose an answer to see the explanation.
Question 4 of 7A page at
app/teas/[tea]/page.tsxsetsexport const ensureStatic = "navigation"and has nogenerateStaticParams. What happens?Choose an answer to see the explanation.
Question 5 of 7With Cache Components on, how many pages does the router keep at one folder level, for the Back button?
Choose an answer to see the explanation.
Question 6 of 7Where do you see Instant Insights?
Choose an answer to see the explanation.
Question 7 of 7The account page reads a session cookie in its shell. What does a plain
<Link>to it prefetch for Asha?Choose an answer to see the explanation.
Key takeaways
- A navigation is instant when the next page shows at the click, from what was prefetched, while its holes stream in.
- With partial prefetching, a plain
Linkprefetches its route's App Shell, once per route, reused by every link to it. For a route that reads cookies, the shell is made for each session. <Link prefetch={true}>adds a per-link prefetch with the static and cached content behind the link's URL. It costs a server render for each link.prefetch()keeps work out of the App Shell;navigation()keeps it out of every prefetch. Both let it stay cached.ensureStaticrequires the shell, the prefetches, or the whole route to be static."navigation"is checked bynext build.- With Cache Components, the router keeps the page on the screen plus two hidden ones at each folder level, with their state.
- Instant Insights and the Navigation Inspector, in
next dev, show what would block and what shows first.
Remember the one-liner.
Prefetch the shell for every link, more for the links that matter, and let the holes stream after the click.
These links point at release v16.4.3, the version every example on this page was built with. The router's prefetches are scheduled in scheduler.ts and sent from cache.ts, and the hidden pages are kept by bfcache-state-manager.ts. The course's last stage, Under the hood, reads them.
Coming next: Incremental Static Regeneration. It shows static pages that update without a rebuild, with and without Cache Components.