
Nextjs Adopting Partial Prefetching
Prefetching is one of those Next.js features that quietly does a lot of work and gets almost none of the credit. Hover a <Link>, and Next.js has usually already fetched most of what it needs before you even click. That's a huge part of why App Router navigations feel instant compared to a typical client-rendered SPA. But "prefetch everything, always" has a cost: bandwidth, server invocations, and — on any route with per-user or per-request data — a growing pile of edge cases about what's safe to prefetch in the first place.
Partial Prefetching is Next.js's answer to that cost problem for apps running Cache Components. Instead of prefetching a full page render per link, it prefetches a single shared "App Shell" per route and reuses it everywhere that route is linked to. It's a meaningful behavior change, and if you're adopting it on an existing app rather than a fresh project, there's real work involved in making sure nothing that used to load instantly suddenly doesn't. This article walks through what changes, how to turn it on, and — more importantly — how to audit an existing codebase so the switch doesn't quietly regress your UX.
The problem Partial Prefetching solves
Before Partial Prefetching, when Cache Components is enabled, every <Link> on the page prefetches the destination's cached render. That's fine for pages that are mostly static, but it starts to hurt as an app grows: a list page linking to a hundred product detail pages means a hundred potential prefetches, each one downloading a full render of that destination — dynamic content and all, if a link is marked prefetch={true}.
The insight behind Partial Prefetching is that most of what makes up a page doesn't actually depend on which specific link you clicked. Your navigation bar, footer, layout chrome, and any content that's fully static or cached the same way regardless of URL — none of that needs to be fetched separately for every single link pointing at the same route. It can be computed once, cached, and reused.
That reusable, URL-independent bundle is what Next.js calls the App Shell. With Partial Prefetching on, a <Link> prefetches the App Shell for its destination route — not a full render of that specific URL. Only one App Shell exists per route, no matter how many links (with how many different dynamic params) point at it. Rendering a grid of a hundred product links no longer multiplies the prefetch work by a hundred.
A prerequisite you can't skip: Cache Components
Partial Prefetching only works when cacheComponents is enabled in your next.config.ts. If you haven't adopted Cache Components yet, this feature isn't reachable — you'll need to go through that migration first (Next.js has a companion guide and codemods for it). I'm not going to re-litigate what Cache Components is here, but the short version: it's the caching model where you explicitly mark cacheable work with use cache, and Next.js builds a static "shell" around whatever's left uncached, streaming the rest in behind Suspense boundaries. Partial Prefetching is really an extension of that same idea applied to link prefetching specifically — instead of prefetching "the render," you prefetch "the shell."
If you're on a brand-new project with Cache Components already enabled, there's genuinely nothing to migrate here — turn the flag on and move along. The real substance of this guide is for apps that already have <Link prefetch={true}> calls scattered around, built against the old prefetch behavior.
Enabling the flag
The switch itself is a one-line change to next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
The moment this flag flips on, every <Link> in your app starts prefetching App Shells instead of full page renders. This is a global, all-at-once change — there's no gradual per-route rollout unless you deliberately architect one (more on that below). If you're starting a project fresh, you're done: there's no legacy behavior to reconcile, because there are no old links assuming the previous prefetch semantics.
If you're retrofitting an existing app, the flag flip is the easy part. The work is in what comes next.
What actually changes for <Link>
It helps to see the before/after side by side, because the difference is subtle enough to miss if you're skimming:
<Link> usage | Before Partial Prefetching | After Partial Prefetching |
|---|---|---|
<Link href="/x"> | Prefetched the full cached render of /x | Loads the shared App Shell for /x |
<Link href="/x" prefetch={true}> | Prefetched the full render, dynamic content included | Loads the App Shell, plus URL-specific content if the destination opts into per-link prefetching |
<Link href="/x" prefetch={false}> | Prefetching disabled | Unchanged — still disabled |
The important row is the middle one. Before this change, prefetch={true} was your escape hatch for "I want this link's destination fully ready before the click, dynamic parts included." After the change, that escape hatch's meaning shifts — it no longer implies the dynamic content comes along for free. This is exactly why an in-place audit matters: every prefetch={true} in your codebase was written with an assumption that's now different, and silently leaving them as-is either does nothing useful (redundant flag) or means content you were counting on being pre-loaded is now streaming in after the click instead.
Two ways to do the migration
Next.js documents two paths here, and they're worth knowing about even if you only use one.
The scripted path uses an official adoption skill built for coding agents (next-partial-prefetching-adoption, distributed via npx skills add vercel/next.js). It audits your <Link prefetch={true}> calls, flips the flag, and sweeps your routes for the two dev-time insights this guide is built around. If you're already using an AI coding assistant day-to-day, this is worth trying first — it mechanizes exactly the manual process below.
npx skills add vercel/next.js --skill next-partial-prefetching-adoption
The manual path is what the rest of this article walks through. Even if you use the skill, understanding this manual process is what lets you sanity-check what it did, and it's the only option if you're not working inside a coding-agent workflow.
Both insights that guide the manual process are development-only — they surface in the dev overlay with clickable fix cards, and neither one blocks your production build. If a route genuinely isn't ready to be touched yet, you can export instant = false from its page or layout to opt it out of the validation entirely and come back to it later.
Step 1: audit every prefetch={true}
This is the part that actually takes judgment. For each link currently marked prefetch={true}, you need to decide what its destination should still deliver ahead of the click, and that decision comes down to what kind of data that destination reads.
One thing worth internalizing before you start: cookies() and headers() don't count as "URL data" in this system. They vary by session, not by which link you clicked, so content behind them can still live in the shared App Shell — it's only params and searchParams that are genuinely tied to one specific URL and therefore can't be baked into a shell shared across every link to that route.
With that distinction in mind, here's the decision table:
| What the destination does | What to do |
|---|---|
| Fully static, or content already cached | Remove the now-redundant prefetch={true} |
| Uncached content you want to keep prefetched | Wrap it in use cache, then remove prefetch={true} |
Content behind cookies() or headers() | Cache the lookup behind the session value, then remove prefetch={true} |
Reads params or searchParams | Keep prefetch={true} — this is genuine URL data |
| Real-time content that must stay fresh | Remove prefetch={true} and let it stream in |
Let's go through the two cases that actually require code changes.
Uncached content that needs to survive in the shell. If a page fetches data without caching it, that data was never going to be in the App Shell to begin with — you need to cache it explicitly with use cache so it becomes part of the shell content:
// Before — fetched fresh on every render, so it can't live in a shared shell
export default async function Page() {
const res = await fetch("https://api.example.com/products");
return <ProductList products={await res.json()} />;
}
// After — cached, so the App Shell now carries it
async function getProducts() {
"use cache";
const res = await fetch("https://api.example.com/products");
return res.json();
}
export default async function Page() {
return <ProductList products={await getProducts()} />;
}
There's a detail buried in the docs worth calling out explicitly because it will bite people: the App Shell only carries cached content whose stale time (set via cacheLife) is at least five minutes. That's true of the default cache profile and every built-in preset except seconds. If you're using a short-lived cache profile specifically because your data changes often, don't be surprised when it doesn't show up in the App Shell — that's working as intended, not a bug. Short-lived content is expected to stream in after navigation rather than being baked into a shell that's reused across every visitor.
Content behind cookies or headers. This one's subtler because the fix isn't "just add use cache" — reading cookies() or headers() directly inside a use cache function isn't the pattern. Instead, you read the session value outside the cached function, then pass it in as an argument so the cache key varies per session value instead of per request:
// Before
import { Suspense } from "react";
import { cookies } from "next/headers";
async function TeamTopics() {
const team = (await cookies()).get("team")?.value;
const topics = await db.topics.forTeam(team);
return <TopicList topics={topics} />;
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<TeamTopics />
</Suspense>
);
}
// After — the lookup is cached, keyed on the session value passed in
import { Suspense } from "react";
import { cookies } from "next/headers";
async function getTopics(team: string | undefined) {
"use cache";
return db.topics.forTeam(team);
}
async function TeamTopics() {
const team = (await cookies()).get("team")?.value;
return <TopicList topics={await getTopics(team)} />;
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<TeamTopics />
</Suspense>
);
}
Notice the shape here: cookies() still gets called outside the cached function (it has to — you can't call it inside use cache), but the expensive work, the database lookup, is what gets cached, parameterized by the resolved team value. This pattern comes up constantly once you start working seriously with Cache Components — cache the derived work, not the raw request API access.
For links that read genuine URL data (params/searchParams), there's nothing to fix in this pass — you leave prefetch={true} in place, because that content genuinely can't live in a shell shared across every URL to the route. I'll come back to what you can do about those in a minute. And for real-time content, there's equally nothing to preserve — a prefetch of live data would be stale by the time someone clicks anyway, so just drop the flag and let it stream in normally.
Step 2: doing this incrementally, without flipping the global switch
If your app is large enough that a single all-at-once migration would produce an unreviewable diff, or you want some routes benefiting from Partial Prefetching before others, there's a per-route escape hatch: export const prefetch = 'partial' from a page or layout, with the global partialPrefetching config flag still off.
// Before
export default function Page() {
return <Dashboard />;
}
// After — adopted on this route without the global flag
export const prefetch = "partial";
export default function Page() {
return <Dashboard />;
}
While the global flag is off, prefetch={true} elsewhere in the app still performs the old full-render prefetch, so nothing breaks for routes you haven't touched yet. Navigating through a link into one of the not-yet-adopted destinations in development surfaces a dev-only insight pointing you at what to fix next. Work through your routes one at a time, deploy incrementally, and once every route in scope has been adopted, flip the global partialPrefetching flag on.
After that, the per-route prefetch = 'partial' exports become redundant — they're not harmful, but there's no reason to keep them scattered through your codebase. Next.js ships a codemod that strips them out in one pass:
npx @next/codemod@canary remove-partial-prefetch ./app
If your app puts routes under src/app, pass that path instead — and actually check the reported file count. A wrong path silently reports "0 ok" rather than erroring, which is an easy thing to miss if you're running this as part of a script rather than watching the output.
Step 3: auditing routes for URL data leaking into the shell
Once the flag is on, Next.js validates every App Shell as you navigate around in development. The rule is straightforward once you internalize it: the App Shell is one shared object per route, so it structurally cannot contain anything that's specific to a single URL. If your page reads params or searchParams outside a <Suspense> boundary, that read ties the whole shell to that one specific URL — which defeats the entire point of having a shared shell, and Next.js will flag it in dev.
The fix is a restructuring move that will feel familiar if you've done any Suspense-based streaming work before: keep everything that doesn't depend on the URL outside the boundary, and push the actual params/searchParams read into a child component wrapped in <Suspense>.
// Before — awaiting params at the top ties the whole shell to one URL
export default async function Page({ params }: PageProps<"/products/[slug]">) {
const { slug } = await params;
const product = await getProduct(slug);
return (
<ProductLayout>
<Details product={product} />
</ProductLayout>
);
}
// After — the promise is passed down unresolved
import { Suspense } from "react";
import { ProductDetails } from "./product-details";
export default function Page({ params }: PageProps<"/products/[slug]">) {
return (
<ProductLayout>
<Suspense fallback={<DetailsSkeleton />}>
<ProductDetails params={params} />
</Suspense>
</ProductLayout>
);
}
// app/products/[slug]/product-details.tsx
export async function ProductDetails({
params,
}: Pick<PageProps<"/products/[slug]">, "params">) {
const { slug } = await params;
const product = await getProduct(slug);
return <Details product={product} />;
}
ProductLayout and everything else outside the boundary stays in the shared App Shell across every link to any product page. Only the actual product details — the part that genuinely differs per URL — renders per navigation, behind its own Suspense fallback. This is a good pattern to keep in your back pocket generally, not just for this migration: the more you can push URL-dependent work down into small, isolated components, the more of your page benefits from shared caching and prefetching, full stop.
One more spot this same rule applies that's easy to forget: generateMetadata. Reading params or searchParams inside generateMetadata surfaces its own, separately-named insight in the dev overlay — worth checking your metadata functions too, not just your page bodies.
Step 4: making URL-dependent content prefetchable anyway
So far, "reads URL data" has meant "streams in after navigation, no way around it." That's true by default, but there's a way to claw back a prefetch for these routes specifically: per-link prefetching, combined with caching the URL-dependent read itself.
Take a search page reading searchParams:
// Before — results stream in after navigation, no way to prefetch them
import { Suspense } from "react";
async function getResults(query: string) {
const res = await fetch(`https://api.example.com/search?q=${query}`);
return res.json();
}
async function Results({
searchParams,
}: Pick<PageProps<"/search">, "searchParams">) {
const { q } = await searchParams;
return <ResultList results={await getResults(q)} />;
}
export default function Page({ searchParams }: PageProps<"/search">) {
return (
<Suspense fallback={<Skeleton />}>
<Results searchParams={searchParams} />
</Suspense>
);
}
// After — cached behind the resolved query, and prefetchable per link
import { Suspense } from "react";
async function getResults(query: string) {
"use cache";
const res = await fetch(`https://api.example.com/search?q=${query}`);
return res.json();
}
async function Results({
searchParams,
}: Pick<PageProps<"/search">, "searchParams">) {
const { q } = await searchParams;
return <ResultList results={await getResults(q)} />;
}
export default function Page({ searchParams }: PageProps<"/search">) {
return (
<Suspense fallback={<Skeleton />}>
<Results searchParams={searchParams} />
</Suspense>
);
}
Wrapping getResults in use cache means a link pointing at a specific search query, marked prefetch={true}, can now resolve and cache that specific query's results ahead of the click — at the cost of one server invocation per prefetchable link. That trade-off is exactly why this isn't the default behavior: it's a deliberate opt-in for the routes where it's worth paying for. A search results page with high-value, frequently-repeated queries might be worth it. A page with effectively infinite unique URL combinations probably isn't — you'd just be generating cache entries and server invocations that get used once and never again.
Practical notes the docs don't spell out
A few things I'd flag if you're actually doing this migration on a real app rather than reading about it in the abstract:
This is not a drop-in performance win, it's a trade-off you're actively managing. The entire point of Partial Prefetching is to reduce what gets prefetched by default. If you don't do the audit work in Step 1, you'll get a real, measurable regression on any page that used to eagerly prefetch dynamic content and now doesn't. The flag alone doesn't make things faster — it makes the default cheaper, and it's on you to explicitly re-request the expensive behavior on the handful of routes that actually need it.
Watch your cacheLife profiles closely during this migration. The five-minute stale threshold for shell inclusion is a hard cutoff, not a soft guideline. If you've got custom cacheLife profiles with short stale windows (anything approaching the seconds preset), content behind them will never make it into the App Shell no matter how you structure your components. If you're seeing content stream in after navigation that you expected to be in the shell, check the cache profile before you start restructuring components — it might just be a cache life issue.
The dev-only insights are genuinely useful, don't skip them. Because neither the prefetch={true} audit reminder nor the URL-data-outside-Suspense warning blocks your production build, it's tempting to treat them as optional. In practice, they're the only signal you get that a specific route needs attention, and they disappear once you fix the underlying issue — so use them as your actual migration checklist rather than trying to reason about every route from first principles.
If you're not ready for a route, say so explicitly. instant = false on a page or layout isn't a hack — it's the documented way to tell Next.js "don't validate this route yet." Use it liberally during a large migration rather than trying to force every route through the audit in one pass.
Key Takeaways
| Concept | What it means for you |
|---|---|
| App Shell | The one cached, URL-independent bundle Next.js builds per route and reuses across every link to it |
cacheComponents | Hard prerequisite — Partial Prefetching doesn't exist without it |
partialPrefetching | The next.config.ts flag that flips the global default |
prefetch={true} | No longer means "prefetch everything" — now means "also resolve URL-specific content, if the destination opts in" |
params / searchParams | The only things that count as true URL data and can't live in a shared shell |
cookies() / headers() | Session data, not URL data — can still live in the shell if cached behind the resolved value |
prefetch = 'partial' | Per-route opt-in for incremental migration with the global flag still off |
Five-minute stale threshold | Content cached with a shorter stale window never makes it into the App Shell |
Partial Prefetching is a good example of a Next.js feature that looks like a one-line config change on the surface and is actually a re-architecting exercise underneath. The payoff — prefetches that scale with your route count instead of your link count — is real, but only if you do the audit work honestly instead of just flipping the flag and hoping nothing regresses.


