
Ensuring instant navigations
Every framework promises fast navigation. Next.js is one of the few that gives you a way to actually verify it, route by route, before a user ever complains about a spinner. "Instant navigation" isn't a marketing phrase in the App Router — it's a specific, checkable property: the browser starts rendering the new page the moment someone clicks, with static and cached content appearing immediately while the server streams in whatever's left.
Getting there isn't automatic. It depends on where you put your <Suspense> boundaries, what you've marked as cached, and whether your app is built on Cache Components and Partial Prefetching. This guide walks through what "instant" actually means, the tools Next.js gives you to build it, a worked example of turning a blocking route into an instant one, and how to lock the result in with tests so it doesn't regress six months later.
What "instant" actually means
A navigation is instant when the browser can start rendering the destination page the moment the user clicks — static content, cached content, and Suspense fallbacks all show up right away, and the server streams in the rest.
That definition comes with an asterisk: it assumes warm caches. The very first request to a route still has to compute whatever's cacheable at least once. Instant navigation is a property of the second visit onward, not a promise that nothing is ever slow.
Here's the part that trips people up the first time they hear it: a direct page load and a client-side navigation to the exact same route can render completely different initial UI. A direct visit (typing the URL, refreshing, following an external link) gets the full static shell as HTML, usually served straight from a CDN. A client navigation — clicking a <Link> inside your app — only re-renders the part of the tree below the layout the current and destination routes share. Everything above that point, including any <Suspense> boundary in your root layout, simply isn't part of the render on that navigation. It's already on screen and staying there.
This is worth sitting with, because it explains a specific class of "why does this only break on client navigation" bug. Take useSearchParams(). On a full page load, that hook suspends — search params genuinely aren't available at build time, so whatever component reads them needs a Suspense boundary above it. On a client navigation, though, the router already has the destination URL, params included, before the transition starts. The hook resolves synchronously. Same component, same hook call, two completely different rendering behaviors depending on how the user got there. If you've ever seen a loading skeleton flash on a fresh page load but never on in-app navigation, this is why.
The pieces that make it work
Before touching your code, it's worth knowing the four primitives Next.js gives you here, because the rest of this guide is just combinations of them.
Caching directives ("use cache" and its variants) assign a lifetime to whatever an async function returns. That's what qualifies its output to be baked into the static shell — the part of the page Next.js can render once and reuse across requests. There's a sibling directive, "use cache: private", worth knowing about early: it caches functions that read request-scoped APIs like cookies() or headers(), but the result lives only in the browser, never on the server. Because of that, it can never be part of the static shell — it's for a different job (pairing with per-link prefetching), which I'll get to.
<Suspense> marks the parts of the tree that read uncached data or runtime APIs, and gives them a fallback to show while that data resolves. One detail that's easy to miss: a fallback itself can read cookies(), headers(), or the full URL, and if it does, that fallback suspends too — so you need a Suspense boundary further up the tree to catch it.
The App Shell is Next.js's per-route package of everything static, generated so it can render during client navigations while the rest streams in. This is the thing Partial Prefetching prefetches by default for every visible <Link> — and critically, multiple links to the same destination share one App Shell request instead of each firing its own.
Per-link prefetching is what happens when you add prefetch={true} to a specific <Link> on top of Partial Prefetching. It resolves that link's URL-dependent data — params, searchParams, the full URL — before the click happens, not after.
Turning this on
Getting real value out of any of this requires two flags together, not one:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
cacheComponents alone gets you the caching model and validation. partialPrefetching alone gets you App Shell prefetching. You want both, because the validation system (which I'll walk through next) is what actually tells you, route by route, where your navigations are blocking — and without it you're debugging this by feel, which does not scale past a handful of routes.
If you're doing this migration with an AI coding agent, Next.js ships companion Skills for exactly this (next-cache-components-adoption and next-partial-prefetching-adoption at skills.sh), and the docs even include a ready-made prompt for handing the whole migration to an agent while keeping you in the loop on PR-sizing decisions. Worth knowing about even if you do this by hand first, since it's a good template for what a careful automated migration looks like — explain before changing, verify against a live dev server at each step, surface decisions rather than making them silently.
Watching validation do the work for you
Once cacheComponents is on, Next.js validates every Page and Default segment automatically in development, at the default validationLevel: 'warning'. It simulates both an initial page load and a client navigation into that segment and tells you, specifically, what would keep either one from being instant: a missing Suspense boundary, uncached data reaching the user, or both.
This is the part I'd flag as genuinely different from most "just optimize your app" advice: it's not a lint rule checking for a pattern, it's a simulation of the two different rendering paths I described above, run against your actual route tree. That's why a route can pass the page-load check and still fail the client-navigation check — they're validated independently, because they render different subtrees.
If you'd rather only validate segments that explicitly opt in (useful once you have a large app and don't want warnings on routes you haven't gotten to yet), switch to manual mode:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
experimental: {
instantInsights: {
validationLevel: "manual-warning",
},
},
};
export default nextConfig;
One escape hatch worth knowing up front: pages that are entirely "use client" behave like a single-page-app transition on a soft navigation — no server render at navigation time at all, which trivially makes them instant. The dev overlay doesn't bother surfacing fix cards for these, because the "fix" would be architectural (go client-only), not a caching tweak. That said, "use client" doesn't exempt you from the page-load validation — hooks like useSearchParams() still need a Suspense boundary on that path, client component or not.
A route that's already instant
Here's a small store app with a product page at /store/[slug]. There's no generateStaticParams, so the slug is only known at request time — both components below have to await params, which means both suspend, and each gets its own boundary:
// app/store/[slug]/page.tsx
import { Suspense } from "react";
import { db } from "@/lib/db";
export default function ProductPage(props: PageProps<"/store/[slug]">) {
return (
<div>
<Suspense fallback={<p>Loading product...</p>}>
<ProductInfo params={props.params} />
</Suspense>
<Suspense fallback={<p>Checking availability...</p>}>
<Inventory params={props.params} />
</Suspense>
</div>
);
}
type Params = PageProps<"/store/[slug]">["params"];
async function ProductInfo({ params }: { params: Params }) {
const { slug } = await params;
const product = await getProduct(slug);
return (
<>
<h1>{product.name}</h1>
<p>${product.price}</p>
</>
);
}
async function getProduct(slug: string) {
"use cache";
return db.products.findBySlug(slug);
}
async function Inventory({ params }: { params: Params }) {
const { slug } = await params;
const item = await db.inventory.findBySlug(slug);
return <p>{item.count} in stock</p>;
}
Notice the asymmetry here, and it's deliberate: product info (name, price) rarely changes, so it's wrapped in "use cache" and gets its own Suspense boundary. Inventory count has to be fresh on every single request — it's not cached, but it's still wrapped in its own boundary, separate from product info. That separation is the whole trick. A user clicking between products sees the name and price essentially immediately from cache, while "Checking availability..." streams in a beat later. Nobody's staring at a blank page waiting for a stock count that changes by the second.
Watching it happen: the Navigation Inspector
Validation tells you whether a navigation is instant. It doesn't show you what it looks like. For that, Next.js DevTools has a Navigation Inspector, available once Cache Components is on. Open DevTools, select Navigation Inspector, toggle "Pause on navigations," and the next refresh or link click freezes the page mid-navigation so you can inspect exactly what's in the shell.
Refresh the product page cold, and you'll see both fallbacks — "Loading product..." and "Checking availability..." — because on a fresh cache, nothing's been computed yet. Refresh again, and the product name appears immediately, straight from cache; only inventory streams. Click from /store/shoes to /store/hats, and the Inspector labels it "Client nav" instead of "Page load," showing both the source and destination URL — this is the concrete version of the page-load-vs-client-nav split from earlier, not just a description of it.
Pairing this with the React DevTools Suspense panel is worth doing at least once per route you're optimizing: it lists every boundary in the tree and lets you toggle each between fallback and resolved state by hand, so you can see precisely which boundary is responsible for which visible chunk of the page. This is the single fastest way I've found to catch a Suspense boundary that's technically present but placed so high it swallows content that didn't need to be behind a spinner at all.
Fixing a route that blocks
Validation passing on the store example above is the easy case. Here's a route that starts out failing, and the two-step process of getting it to pass — this is the part that actually matters, because it's the loop you'll repeat across a real app.
// app/products/[slug]/page.tsx
export default async function ProductPage(
props: PageProps<"/products/[slug]">,
) {
const featured = await getFeatured();
const { slug } = await props.params;
const res = await fetch(
`https://next-recipe-api.vercel.dev/products/${slug}`,
);
const product = await res.json();
return (
<div>
<FeaturedSection items={featured} />
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</div>
);
}
async function getFeatured() {
const res = await fetch(
"https://next-recipe-api.vercel.dev/products?limit=3",
);
return res.json();
}
Two things block here, and validation surfaces them one at a time, not all at once — an uncached "featured" fetch at the top of the component, and a per-slug fetch that also awaits params. Both are top-level awaits with nothing wrapping them, so the entire page waits on both before anything can render.
Step one: isolate the slug-dependent work. Validation flags the per-slug fetch first. The fix is to pull it into its own component and give it a boundary:
// app/products/[slug]/page.tsx
import { Suspense } from "react";
async function ProductInfo({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const res = await fetch(
`https://next-recipe-api.vercel.dev/products/${slug}`,
);
const product = await res.json();
return (
<>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</>
);
}
export default async function ProductPage(
props: PageProps<"/products/[slug]">,
) {
const featured = await getFeatured();
return (
<div>
<FeaturedSection items={featured} />
<Suspense fallback={<p>Loading product...</p>}>
<ProductInfo params={props.params} />
</Suspense>
</div>
);
}
Now await props.params and the fetch that depends on it suspend together, contained to their own boundary. That error clears, and validation moves on to the next one.
Step two: cache the fetch that doesn't need to be fresh. getFeatured() is still uncached and still blocking. Since a featured-products list has no reason to be request-fresh, the fix is a one-line directive:
async function getFeatured() {
"use cache";
const res = await fetch(
"https://next-recipe-api.vercel.dev/products?limit=3",
);
return res.json();
}
That's it — validation passes, the featured section ships as part of the App Shell, and only the product details stream in on navigation. One thing worth flagging here that's easy to get burned by in production: if you're deployed serverless, in-memory "use cache" results don't persist across instances. Each cold serverless invocation might recompute what you thought was cached. If that matters for your traffic pattern, "use cache: remote" gives you persistence across instances at the cost of a network round-trip to your cache backend.
Validation passing isn't the finish line
It's tempting to treat a green validation result as "done." It isn't — it just means a navigation can be instant, not that what streams in first is any good. A single <Suspense> boundary wrapped around your entire page body will absolutely pass validation, and it will also mean every navigation shows one big skeleton, which feels exactly as slow as no optimization at all, just with better marketing.
The actual goal is maximizing what's real and visible immediately, and minimizing what sits behind a spinner. A product page that keeps the header, hero image, and description on screen with only price and live availability behind fallbacks reads as dramatically faster than a full-page skeleton — even if the total time to fully-loaded is identical. Users judge speed by what they can see and start reading, not by a stopwatch.
Locking it in with end-to-end tests
Validation checks structure — it can tell you a shell exists, but not whether the right content is in it. That gap is what e2e tests close, and Next.js ships a purpose-built helper for it in @next/playwright:
npm install -D @next/playwright @playwright/test
// e2e/navigation.test.ts
import { test, expect } from "@playwright/test";
import { instant } from "@next/playwright";
test.describe("Product page (/store/[slug])", () => {
test("is instant on an initial page load", async ({ page, baseURL }) => {
await instant(
page,
async () => {
await page.goto("/store/hats");
await expect(page.locator("h1")).toContainText("Baseball Cap");
await expect(page.getByText("In stock")).toHaveCount(0);
},
{ baseURL },
);
await expect(page.getByText("In stock")).toBeVisible();
});
test("is instant on a client navigation", async ({ page }) => {
await page.goto("/store/shoes");
await instant(page, async () => {
await page.click('a[href="/store/hats"]');
await page.waitForURL((url) => url.pathname === "/store/hats");
await expect(page.locator("h1")).toContainText("Baseball Cap");
await expect(page.getByText("In stock")).toHaveCount(0);
});
await expect(page.getByText("In stock")).toBeVisible();
});
});
The instant() callback scopes your assertions to exactly what's visible the moment the navigation completes — inside it, you assert the product name is already there and the stock count explicitly is not yet visible. Everything else stays paused until the callback exits, at which point you assert the stock count eventually shows up. That two-phase assertion is the actual regression guard: it fails the moment someone adds an uncached fetch above the wrong boundary, long before a user notices in production.
Two details that will save you a confusing afternoon: pass Playwright's baseURL into instant() whenever page.goto() is the first navigation in the test — the helper needs the origin before it can request the document. And for the client-navigation test, wait for the destination URL before asserting on its content, not just the click — without that wait, a shared selector (like a generic <h1>) can match against the source page before the new one has committed, giving you a false pass or a confusing timeout depending on timing.
These tests run against next dev automatically, since the testing API is enabled there by default. To run the same tests in CI against a production build — which is where you actually want your regression guard living — you need to explicitly expose that API:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
experimental: { exposeTestingApiInProductionBuild: true },
};
export default nextConfig;
Don't try to cover every route with this. Pick the navigations that actually matter to your product — checkout, the main content browsing flow, whatever your users hit constantly — and protect those specifically.
When not to chase this
Not every route deserves this treatment, and Next.js gives you an explicit way to say so instead of silently fighting validation forever. Set instant = false on a layout or page:
// app/dashboard/layout.tsx
export const instant = false;
This opts that segment out of validation feedback — it can still end up instant if its structure happens to support it, Next.js just stops surfacing insights for it. Sibling navigations below it are still validated normally; this only affects navigations into the opted-out segment from outside.
A genuinely useful case for this: internal admin dashboards behind auth, where the data is inherently per-user and request-fresh, and the navigation cost of an extra few hundred milliseconds simply isn't worth restructuring the component tree around. If content depends on cookies() or headers() but has a knowable freshness window, reach for "use cache: private" before reaching for the opt-out — it can still get that data into the App Shell ahead of the click, as long as its stale time is at least five minutes. The opt-out is for when neither caching nor restructuring makes sense, not a default first move.
Key Takeaways
| Situation | What to reach for |
|---|---|
| Data rarely changes | "use cache" so it lands in the static shell |
| Data must be fresh per request | Leave it uncached, but give it its own <Suspense> boundary |
| Data depends on cookies/headers but has a freshness window | "use cache: private", paired with per-link prefetching |
A route reads searchParams or params | Consider prefetch={true} for per-link prefetching |
| Checking whether a route is actually instant | Cache Components validation in dev, plus the Navigation Inspector |
| Guarding against regressions | @next/playwright's instant() helper in CI |
| A route genuinely doesn't need this | export const instant = false on that segment |
Instant navigation in the App Router isn't a single switch — it's the combination of Cache Components (for the static shell), Partial Prefetching (for the App Shell), Suspense placement (for what streams versus what's static), and validation (for catching the gap between "looks fine" and "actually instant" before your users do). The good news is that once the two config flags are on, Next.js stops making you guess: it tells you, route by route, exactly what's blocking, and exactly which of the three levers — push the work down, cache it, or prefetch its URL data — will fix it.


