cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Read what a link prefetchesFind the App Shell, the per-link prefetch and the navigation request in a recording, and say what each one holds.
  • 02
    Move content between stagesUse <Link prefetch={true}>, navigation() and prefetch() to choose what's ready at the click and what waits.
  • 03
    Guarantee 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:

Clicking Mint tea on a slow networkNext.js
app/teas/[tea]/lib/
app/teas/[tea]/page.tsx
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>;}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
Home

Packed by hand, sent the same day.

Finding the tea…

Checking stock…

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), files and other sites included

  1. 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.

The Mint tea page at three stages. The App Shell, which a plain Link prefetches: Packed by hand, Finding the tea…, Checking stock…. A per-link prefetch, from a Link with prefetch true: Packed by hand, Mint tea $3, Checking stock…. After the click: Packed by hand, Mint tea $3, In stock: 12 boxes.shellPacked by handFinding the tea…Checking stock…a plain <Link>linkPacked by handMint tea: $3Checking stock…prefetch={true}clickPacked by handMint tea: $3In stock: 12 boxesafter the click
What the Mint tea page holds at each stage. The App Shell is shared by every link to a tea. A link with prefetch={true} also loads what depends on its own URL and is cached. The click brings the rest, like the stock.
Real-life analogyTables set before the guests arrive

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
The main idea

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$tea is 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.

Left, partial prefetching on: three links, Green tea, Mint tea and Lemon tea, all point at one App Shell for the tea route. Right, partial prefetching off: each of the three links has a prefetch of its own.partial prefetching onGreen teaMint teaLemon teashell/teas/[tea]partial prefetching offGreen teaa prefetchMint teaa prefetchLemon teaa prefetch
Left: with 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.
These names are this version's

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:

Playground: one shell, or a prefetch for each link?
The tea shop, with partial prefetching on or offNext.js
app/teas/[tea]/lib/
app/teas/[tea]/page.tsx
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>;}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Home
Tea shop
  • 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

  1. GET/200a whole page
  2. GET/_next/static/chunks/1x6obpmlu1_c2.js200application/javascript
  3. GET/_next/static/chunks/06bs-nf19b2js.js200application/javascript
  4. GET/_next/static/chunks/2ki52vdnbo2ft.js200application/javascript
  5. GET/_next/static/chunks/3l_qmp-61z4yc.js200application/javascript
  6. GET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascript
  7. GET/_next/static/chunks/3oh00mkbkq8u6.js200application/javascript
  8. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /
  9. GET/teas/greenno answer yetfetch
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /
  10. GET/teas/mintno answer yetfetch
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /
  11. GET/teas/lemonno answer yetfetch
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /
Try it yourself
partialPrefetching: true

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.

Both versions were built with next build from Next.js 16.4.3 and opened in real Chrome, which waited until the tea page's prefetches had answered, made the network slow and clicked Mint tea. Steps 1 to 3 come before the click. Reset goes back to the first version.

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.

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:

The three navigation stages
StageWhat loads itWhat may be in it
The App ShellA 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 prefetchA <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 navigationThe 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:

Holding work back for a later stage
CallWhat it does to the code below itWhere that code runsCan 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:

Playground: keep the picks in the shell?
The account page, with this version of app/account/page.tsxNext.js
app/account/sign-in/
app/account/page.tsx
import { Suspense } from "react";import { cookies } from "next/headers";import { cacheLife } from "next/cache"; export default function AccountPage() {  return (    <main>      <h1>Your account</h1>      <Suspense fallback={<p>Loading…</p>}>        <Greeting />      </Suspense>      <Suspense fallback={<p>Choosing teas for you…</p>}>        <Picks />      </Suspense>    </main>  );} async function Greeting() {  const name = (await cookies()).get("session")?.value ?? "guest";  return <p>Welcome back, {name}.</p>;} async function Picks() {  const picks = await getPicks();  return <p>Picked for you: {picks.join(", ")}</p>;} async function getPicks(): Promise<string[]> {  "use cache: private";  cacheLife("hours");  const name = (await cookies()).get("session")?.value;  const response = await fetch(`${process.env.SUPPLIER_API_URL}/picks?for=${name}`);  return response.json();}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/accountTea shop
Home Your account
Your account

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

Try it yourself
Picks in the shell

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.

Both versions were built with next build from Next.js 16.4.3 and opened in real Chrome: Asha signs in, waits until the shell made for her session has answered, and clicks Your account. Reset goes back to the first version.

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:

What each ensureStatic value requires
ValueOutput that must be staticChecked 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.
falseNothing, 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:

app/teas/[tea]/page.tsx: the whole route must be staticTSX
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:

Playground: make the tea page static
The tea shop, with this version of the tea pageNext.js
app/teas/[tea]/lib/
app/teas/[tea]/page.tsx
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>;}
next build failed · Next.js 16.4.3
$ 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'}
Try it yourself
Only ensureStatic

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().

Every version was built with next build from Next.js 16.4.3. The Terminal shows what the build printed. Reset goes back to the first version.

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:

Four teas, then Back three timesNext.js
app/teas/[tea]/
app/teas/[tea]/page.tsx
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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintTea shop
Home
mint tea

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.

Four tea pages visited in order: mint, green, lemon, chai. Chai is on the screen. Lemon, with one cup brewed, and green are kept hidden. Mint, visited first, with one cup brewed, is dropped, so its count is lost.1stmint teaCups: 1dropped2ndgreen teaCups: 0hidden3rdlemon teaCups: 1hidden4thchai teaCups: 0on screen
At the [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:

app/account/layout.tsx: navigations into /account may waitTSX
// 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>;
}
app/account/orders/page.tsx: moving between account pages must notTSX
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.

e2e/tea.test.ts: the tea page's shell shows at the clickTypeScript
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:

Which stage brings this content?
  • 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()
Try it yourself
0 of 8 correct

Sort each piece of the tea shop's pages into the navigation stage that first brings it to the browser.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 below navigation(). 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 what next build enforces.
  • "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.

Exercise 1 · Warm-upCount the page prefetches

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?

Starter codeTSX
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>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict the screen at the click

    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?

    Starter codeTSX
    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>;
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMake the price ready for one link

      Change the home page so Mint tea: $3 is already on the screen when the visitor clicks Mint tea. What do you add?

      Starter codeTSX
      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>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead the ensureStatic error

        This page stops next build before anything renders. What does the build say is missing?

        Starter codeTSX
        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>;
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeLet the static page 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?

          Starter codeTypeScript
          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();
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhich counter was lost?

            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?

            Starter codeTSX
            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>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which stage brings the content: the App Shell, a per-link prefetch, or the click.

              The instant navigations quiz · 7 questionsScore: first tries count
              1. Question 1 of 7With partialPrefetching: true, three links point at /teas/green, /teas/mint and /teas/lemon. What does the router prefetch for the tea page itself?

                Choose an answer to see the explanation.

              2. 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 predictTSX
                import { 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.

              3. 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.

              4. Question 4 of 7A page at app/teas/[tea]/page.tsx sets export const ensureStatic = "navigation" and has no generateStaticParams. What happens?

                Choose an answer to see the explanation.

              5. 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.

              6. Question 6 of 7Where do you see Instant Insights?

                Choose an answer to see the explanation.

              7. 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 Link prefetches 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.
              • ensureStatic requires the shell, the prefetches, or the whole route to be static. "navigation" is checked by next 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.