
Next.js Optimizing prefetching
There's a ceiling to how instant a navigation can feel once you've adopted Cache Components and Partial Prefetching: the shared App Shell prefetched for a route covers everything static and everything session-based, but it deliberately does not cover anything that depends on the specific destination URL — a search query string, a dynamic segment value not covered by generateStaticParams. Those pieces still stream in after the click, because the App Shell is one object shared by every link pointing at that route, and it can't simultaneously represent every possible query string a user might click into.
This article is about the next layer up: resolving that per-destination data before the click, for the specific links where it's worth the cost of doing so. It assumes you've already read the companion articles on instant navigation and Cache Components — this is optimization on top of an already-correct caching structure, not a substitute for one.
What the App Shell does and doesn't cover
Quick recap of the boundary, since getting this right is the whole point: with Cache Components and Partial Prefetching enabled, a <Link> prefetches one reusable App Shell per route, not per destination URL. That shell includes the route's static output, and — if the route reads cookies() or headers() — session-specific UI as well, shared across every link pointing at that route regardless of query string.
What it explicitly does not include is anything keyed to searchParams or unresolved params — URL data that genuinely differs between /search?q=react and /search?q=next, even though both hit the exact same route. Those two links share one App Shell; the search results themselves are what's missing from it.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
Resolving URL data ahead of the click
Setting prefetch={true} on a specific <Link> tells Next.js to resolve that link's URL-dependent content before navigation, rather than after:
// app/page.tsx
import Link from "next/link";
export default function Home() {
return (
<nav>
<Link href="/search?q=react" prefetch={true}>
React
</Link>
<Link href="/search?q=next" prefetch={true}>
Next.js
</Link>
</nav>
);
}
// app/search/page.tsx
import { Suspense } from "react";
export default function SearchPage({ searchParams }: PageProps<"/search">) {
return (
<>
<h1>Search</h1>
<Suspense fallback={<ResultsSkeleton />}>
<Results searchParams={searchParams} />
</Suspense>
</>
);
}
async function Results({
searchParams,
}: {
searchParams: PageProps<"/search">["searchParams"];
}) {
const { q } = await searchParams;
return <ResultList items={await search(q)} />;
}
async function search(q: string) {
"use cache";
return db.search(q);
}
Without prefetch={true}, clicking either link renders the shell's <h1> immediately, then streams <Results> in after the click resolves. With it, the router prefetches a prerender that has already resolved <Results> for that specific query — because q is known from the link's own href at prefetch time, and search(q) is cached. Click, and the results render with no fallback at all, because the work already happened before you clicked.
Structurally, what's actually happening is that the prerender walks forward through everything static or cached, and only stops — falling back to whatever <Suspense> boundary is in place — when it hits something genuinely uncached. That boundary needs to already exist from your instant-navigation structuring work; prefetch={true} doesn't create Suspense boundaries, it just gives the existing ones something resolved to show instead of their fallback.
Worth internalizing early: a cold cache still costs a spinner. If nobody has searched "react" recently and the cache entry expired, the server genuinely has to compute search('react') from scratch, and the first user to trigger that prefetch sees the same loading state they would have without prefetch={true} at all. This optimization pays off on warm caches; it doesn't eliminate cold-start latency, it just moves where in the timeline that latency has to happen.
The same logic extends to dynamic segments. A param value not covered by generateStaticParams still needs a <Suspense> boundary even when other values of that same param are statically known — because a specific param value belongs to one specific URL, and prefetch={true} is what resolves the values the static generation didn't already cover.
What this actually costs
This isn't free, and understanding the cost model is the difference between using this well and quietly making your app slower under load. Generating a per-link prefetch costs a server invocation per prefetchable link — every <Link prefetch={true}> visible in the viewport is a request to your server, not a free client-side operation. If the page behind that link is entirely static content with no uncached reads, Next.js serves that prefetch straight from the static cache and the cost is negligible. But the moment the page touches genuinely non-static data, every prefetchable link generates its own per-prefetch invocation.
That's the reasoning behind treating this as opt-in per link rather than a blanket setting — a grid of twenty product cards, each linking to /products/[id] with prefetch={true}, means twenty server invocations fire the moment that grid scrolls into view, whether or not the user ever clicks any of them.
Including session data, separately from URL data
prefetch={true} is specifically about URL data — query params, path params. Session data (anything derived from cookies() or headers()) is handled differently, and it's worth understanding as its own mechanism rather than conflating the two.
A route reading cookies() or headers() — including through "use cache: private" — gets an App Shell that already includes its session-specific content, cached client-side per session, and ready on navigation with no per-link prefetch needed at all. But getting there requires bridging a real constraint: "use cache" cannot read cookies() directly inside the cached function. Two patterns solve this, and which one you reach for depends on how broadly the resulting cache entry should be shared.
Extract and pass, when the result is shared across sessions
If a cookie value maps to something many users share — a team ID, say, where every member of the same team should see identical cached content — read the cookie outside the cached function and pass the resulting value in as a plain argument:
// app/dashboard/user-nav.tsx
import { cookies } from "next/headers";
async function UserNav() {
const team = (await cookies()).get("team")?.value;
const topics = await getTopics(team);
return (
<nav>
{topics.map((topic) => (
<a key={topic.id} href={topic.href}>
{topic.label}
</a>
))}
</nav>
);
}
async function getTopics(team: string | undefined) {
"use cache";
return db.topics.forTeam(team);
}
The cookies() call itself stays entirely outside the cache boundary; only the resolved team value crosses it as an argument, giving the cached function a clean, deterministic signature to key on. Because the cache key is the team value rather than a per-user identifier, every session on the same team shares one cache entry — meaning the underlying data-fetch traffic scales with team count, not session count, which matters a great deal once you have more sessions than teams.
"use cache: private", when it's genuinely per-session
Some lookups can't be extracted this way — an auth helper that checks Date.now() against a token's expiry internally, or a session helper that reads cookies deep inside its own implementation, with no clean seam to pull the cookie read out to the call site. For those, "use cache: private" lets the cached function read runtime data directly, at the cost of scoping results to the browser, per session, rather than sharing across users:
// app/dashboard/user-nav.tsx
import { cookies } from "next/headers";
async function UserNav() {
const user = await getUser();
return <nav>{user.name}</nav>;
}
async function getUser() {
"use cache: private";
const session = (await cookies()).get("session")?.value;
return db.users.findBySession(session);
}
One rule worth internalizing: colocate "use cache: private" as close as possible to the actual runtime data access, and don't let unrelated logic ride along inside the same cached scope — everything inside shares one cache lifetime, so bundling more into the function than strictly needs that lifetime just makes the caching behavior harder to reason about later.
Content without any caching directive at all — extracted, private, or otherwise — simply streams in after navigation as before. The App Shell only ever holds what was actually prepared ahead of time; it was never meant to represent the entire page.
When to reach for prefetch={true}, and when to skip it
The decision is genuinely a cost-benefit calculation, not a default you flip on everywhere. Use it when three things are simultaneously true: part of the tree depends on URL data not covered by generateStaticParams, that dependent content has an expressible cache lifetime (via use cache or use cache: private), and the traffic on that link actually justifies paying for a server invocation per view.
Skip it in the mirror-image cases. If a route has little or no URL-data dependency, the plain App Shell is already instant — there's nothing left to optimize. If the dependent content genuinely must be fresh on every single request (no caching directive applies), the prerender stops at the exact same <Suspense> fallback either way, so prefetch={true} buys you nothing but the extra server cost. And if the route is rarely navigated to, you're still paying the per-visible-link cost regardless of whether anyone actually clicks — visibility triggers the cost, not intent.
There's a specific failure mode worth naming directly: a grid or feed of many links, each individually reasonable-looking with prefetch={true} set, becomes one server request per card the moment the grid scrolls into view — a cost that scales with grid size, not with actual user interest. The better pattern there is prefetching on intent rather than visibility — a hover-triggered prefetch (covered in the general Prefetching guide) fires only for the specific links a user is actually likely to click, which is a meaningfully different cost profile than "every card in a twenty-item grid, unconditionally."
It's also worth remembering this is explicitly best-effort. On a slow connection, or when a user navigates faster than the prefetch can resolve, the navigation simply falls back to the plain App Shell — there's no error state, no broken behavior, just a slightly less-optimized experience than the best case. Treat prefetch={true} as "improves the odds of an instant navigation," not "guarantees one."
Key Takeaways
| Scope | App Shell (default) | prefetch={true} |
|---|---|---|
| Applies per | Route | Visible link |
| Covers | Static output + session data (if any) | Same, plus that link's specific URL data |
| Server cost | Bounded by number of distinct routes | Bounded by number of visible prefetchable links |
| Best for | Every route, by default | High-traffic links where URL data has a real cache lifetime |
| Session data (cookies/headers) | Handled via extract-and-pass or use cache: private, not prefetch | Same mechanism, unaffected by this prop |
Optimizing prefetching is really a targeting exercise: the App Shell already gets you most of the way to "instant" for free, and prefetch={true} is a scalpel for the specific, high-traffic links where the remaining URL-dependent gap is worth a server invocation to close. Reach for it selectively, on links you can justify, rather than as a blanket setting — the cost model punishes indiscriminate use precisely where it looks most tempting, on grids and feeds with many visible links at once.


