Type something to search...
Next.js ISR with Cache Components

Next.js ISR with Cache Components

If you've been building with Next.js for a while, you've probably run into the same trade-off every team hits eventually: you have a catalog with ten thousand product pages, and you cannot realistically prerender all ten thousand at build time. Classic Incremental Static Regeneration solved half of this problem — it let you regenerate a static page on a schedule instead of rebuilding your whole site. But the other half of the problem, what happens on the very first request to a URL nobody has visited yet, was always a little awkward. Someone had to eat a full server render, cold, with no shortcuts.

Cache Components changes that. Paired with Partial Prefetching, it gives you a route that behaves like a fully static page even for URLs that were never part of your build. The mechanism is different enough from classic ISR that it's worth treating as its own topic rather than a footnote on the older model, so that's what this article does: how the App Shell gets built, what happens on that crucial first visit, and how the upgrade actually works once Next.js has seen a URL for the first time.

The problem this solves

Picture an e-commerce site with categories and products nested under them: /tops/tee, /shorts/joggers, and so on, but also thousands of long-tail combinations you'll never enumerate by hand. At build time, you can reasonably call generateStaticParams for your handful of top categories and best-selling products. Everything else is unknown until someone actually asks for it.

With the classic model, an unknown URL either 404s, or you set dynamicParams to allow it through and eat a full dynamic render on that first request — no caching, no shell, just a cold server doing the entire job while the visitor waits. That's not catastrophic for one request, but it means your slowest, worst first-impression page load happens to be the one served to first-time visitors of pages you didn't predict would be popular. That's exactly backwards from what you want.

ISR with Cache Components inverts this. Instead of "unknown URL = full cold render," it becomes "unknown URL = instant shell, upgraded quietly in the background." The visitor never sees the cold-render tax. The next visitor to that same URL gets something even better: a cached, upgraded page that skips the shell step entirely.

The two ingredients: Cache Components and Partial Prefetching

This behavior isn't automatic — it's the product of two flags working together, and understanding what each one contributes will save you a debugging session later.

Cache Components (the cacheComponents config flag) is what produces the App Shell in the first place. It's the mechanism that splits a route's render into a static, URL-independent part and a dynamic, URL-dependent part, and lets Next.js prerender the static part even when it doesn't know every param value yet.

Partial Prefetching is what turns that shell into a full page once the params become known. Cache Components alone gives you an instant shell; Partial Prefetching is the piece that goes and fetches the real content in the background and swaps it in, whether that's triggered by a prefetch on hover, a Link entering the viewport, or the click itself.

You need both enabled for the full experience described in this article:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;

If you only enable cacheComponents, you get the App Shell on first visit but you lose the eager background upgrade behavior that makes subsequent navigations feel instant. It's worth checking both are on before you conclude this feature "doesn't work" — I've seen people enable one and not the other and get confused about why their shells never seem to upgrade until a manual refresh.

Building a worked example: a product catalog

Let's build the scenario properly so the mechanics are concrete rather than abstract. Assume a category layout and a product page, each with their own generateStaticParams.

The category layout prerenders two known categories:

import Link from "next/link";
import { Suspense } from "react";
import { getCategory, getTopCategories } from "../lib/data";

export async function generateStaticParams() {
  const categories = await getTopCategories();
  return categories.map((c) => ({ category: c.slug }));
}

async function CategoryHeader({
  params,
}: Pick<LayoutProps<"/[category]">, "params">) {
  const { category } = await params;
  const data = await getCategory(category);

  return (
    <div>
      <Link href="/">&larr; All categories</Link>
      <h1>{data?.name ?? "Category"}</h1>
      {data?.description && <p>{data.description}</p>}
    </div>
  );
}

export default function CategoryLayout(props: LayoutProps<"/[category]">) {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <CategoryHeader params={props.params} />
      </Suspense>
      {props.children}
    </div>
  );
}

The detail worth pausing on here: CategoryLayout does not await props.params itself. It hands the params promise down into CategoryHeader, which lives inside a <Suspense> boundary, and the await happens there. This is not a stylistic preference — it's the entire mechanism. If you await params above the Suspense boundary, you tie the whole layout's shell to a specific URL, which defeats the purpose of having a generic shell at all. Keep the read inside the boundary even for categories that generateStaticParams already covers, so the same component works whether the category is known or not.

The product page follows the same shape, one level deeper, and its generateStaticParams receives the parent category param so it can scope which products to prerender per category:

import { Suspense } from "react";
import Link from "next/link";
import { getProduct, getPopularProducts } from "../../lib/data";

export async function generateStaticParams({
  params,
}: {
  params: { category: string };
}) {
  const products = await getPopularProducts(params.category);
  return products.map((p) => ({ product: p.slug }));
}

async function ProductDetails(props: PageProps<"/[category]/[product]">) {
  const { category, product } = await props.params;
  const data = await getProduct(category, product);

  if (!data) {
    return <p>Product not found.</p>;
  }

  return (
    <>
      <Link href={`/${category}`}>&larr; Back to products</Link>
      <h2>{data.name}</h2>
      <p>${data.price}</p>
      <p>{data.description}</p>
    </>
  );
}

export default function ProductPage(props: PageProps<"/[category]/[product]">) {
  return (
    <div>
      <Suspense fallback={<div>Loading product...</div>}>
        <ProductDetails {...props} />
      </Suspense>
    </div>
  );
}

You can use a loading.tsx file instead of an inline <Suspense> boundary if you'd rather put the fallback at the segment edge — the difference is purely about where you want to place the boundary in the tree. Inline <Suspense> gives you more granular control (as in the layout above, where only the header is boundary-wrapped, not the whole layout).

Behind these components, the data-fetching helpers use 'use cache' at the module level, which caches every exported function in the file:

"use cache";

const API = "https://next-recipe-api.vercel.dev";

export async function getCategory(slug: string) {
  const res = await fetch(`${API}/categories/${slug}`);
  if (!res.ok) return null;
  return res.json();
}

export async function getProduct(category: string, slug: string) {
  const res = await fetch(`${API}/products/${category}/${slug}`);
  if (!res.ok) return null;
  return res.json();
}

export async function getTopCategories() {
  const res = await fetch(`${API}/categories`);
  const categories = await res.json();
  return categories.slice(0, 2);
}

export async function getPopularProducts(category: string) {
  const res = await fetch(`${API}/products?category=${category}`);
  const products = await res.json();
  return products.slice(0, 1);
}

This is the piece that makes the App Shell useful rather than just a spinner. Because the data functions are cached, their results can be folded into the static shell wherever the params are already known. If your components read runtime-only APIs like cookies or headers, wrap those reads in <Suspense> too — their fallback becomes part of the static shell instead of forcing a dynamic render for everyone.

What actually happens at build time

Run next build against the example above, with two known categories (tops, shorts) and one known product per category (tee, joggers). Next.js prerenders:

  • The category layout for each known category, plus one extra render where await params suspends — this produces the generic App Shell used for any category not in the list.
  • The product page for each known product under each known category, plus one extra render where await params suspends — same idea, one level deeper.

The combined output looks like this:

  • /tops/tee and /shorts/joggers — fully static, every param known.
  • /tops/[product] and /shorts/[product] — the category header is rendered, but the product portion shows the fallback.
  • /[category]/[product] — the fully generic shell, both category and product show fallbacks.

That middle tier is the interesting one. It means a visit to an unknown product inside a known category — /tops/overshirt, say — gets a shell where the category chrome (breadcrumb, header, description) is already correct and only the product card streams in. You get partial specificity for free, not an all-or-nothing shell.

What happens at runtime

Three visitors, three different experiences:

Visit to /tops/tee. Both params were prerendered at build time. This visitor gets the fully static page — no shell, no streaming, nothing to wait on.

First visit to /tops/overshirt. The product is unknown, but the category is known. Next.js serves the App Shell for /tops/[product], with the category header already rendered from the cache, and the product streams in as soon as it resolves.

First visit to /shoes/basketball-shoes. Neither the category nor the product was ever seen before. Next.js falls back to the fully generic /[category]/[product] shell — both pieces stream in.

After any of these first visits, Next.js renders the page again in the background using the now-known params. The next visitor to that same URL gets the upgraded, cached result directly — no shell, no streaming, as if it had been in the original build all along.

This is where Partial Prefetching earns its keep beyond the initial click. A prefetch counts as a "first visit" for the purposes of triggering the upgrade. If a <Link> pointing at an unlisted URL scrolls into the viewport, or you call router.prefetch() on it manually, Next.js kicks off the background upgrade before the user even clicks. By the time they do click, there's a decent chance the upgraded version is already sitting in cache waiting for them — so the "first visit" experience a real user notices can, in practice, be the second one under the hood.

One version detail worth knowing if you're comparing behavior across releases: the instant App Shell for unlisted params ships from Next.js 16.3 onward. On earlier 16.x versions, an unlisted URL still waits for a full server render before responding — the shell-first behavior for unknown params specifically is new as of 16.3.

What the background upgrade actually produces

The upgrade isn't a single fixed outcome — it depends on what the render touches once the real params are known:

  • If every data access on the route is cached (via 'use cache') and all params are now resolved, the upgrade produces a fully static page, indistinguishable from something that had been in the original build.
  • If the params are resolved but the render still touches uncached data or a runtime API like cookies or headers inside a <Suspense> boundary, the upgrade produces a cached page with those specific fallbacks intact — everything static gets cached, and only the genuinely dynamic slice streams in per-request.
  • Params resolve in route order. If a param value was never returned by generateStaticParams, it stays unresolved, and that blocks any deeper params in the tree from upgrading too. A category that never got listed will keep every product beneath it perpetually on the fallback path, no matter how many times individual products get visited.

That last point matters operationally: if you notice a whole subtree of routes never seems to "graduate" out of shell mode no matter how much traffic it gets, check whether the parent segment's param is actually present in its own generateStaticParams output. A missing top-level category will silently cap everything under it.

Deciding what's worth prerendering upfront

It's tempting to reach for generateStaticParams and try to enumerate everything, but that instinct works against you here. Every param combination you prerender adds build time and adds to the artifact you have to store and deploy — and if a chunk of that catalog never gets visited before your next deploy, that build work bought you nothing.

The better mental model: use generateStaticParams for the routes that clearly benefit from being ready ahead of time — your bestsellers, your evergreen landing pages, whatever you already know gets disproportionate traffic. Let everything else ride the shell-then-upgrade path. You're not choosing between "fast" and "slow" routes anymore; you're choosing between "fast from the first millisecond of the build" and "fast from the first visit onward." Both are fast. The second is just fast slightly later, and costs you nothing in build time for pages nobody was going to hit anyway.

This is a genuine mindset shift if you're coming from a classic-ISR world where every route you didn't prerender felt like a liability. Under Cache Components, an un-prerendered route isn't a liability, it's just deferred — and deferred well enough that most users will never notice the difference.

Coming from the Pages Router

If your mental model is still rooted in getStaticPaths/getStaticProps, here's the direct mapping:

  • fallback: true in getStaticPaths is now simply the default behavior once cacheComponents is on. You don't opt into a fallback mode explicitly — every route gets one implicitly, and it's a real <Suspense> fallback rather than a "loading" flag you had to check yourself.
  • router.isFallback disappears entirely. There's no client-side flag to inspect because the server already handled the distinction by generating a proper static shell rather than shipping an empty page and a boolean.
  • getStaticProps with a revalidate value maps to 'use cache' paired with cacheLife — the equivalent of "regenerate this every N seconds" is now a cache-scoped concern rather than a page-scoped one.
  • getStaticPaths itself maps to generateStaticParams — same job, different name, and now composable per route segment instead of one function per page.

If you're doing this migration for real, expect the biggest adjustment to be conceptual, not mechanical: you're moving from "a page either has fallback or it doesn't" to "every layer of nested params can independently be known or unknown," which is a more granular and, once it clicks, more useful way to think about partial availability.

How this relates to streaming

It's worth being precise about the relationship between this feature and streaming, because they solve adjacent but distinct problems, and it's easy to conflate them.

Streaming is about a single render: given one request, how do you get something on screen quickly while slower parts of the same page are still resolving. That's a per-request concern, and it works the same way whether the route was prerendered or not.

ISR with Cache Components is about which render you're even doing in the first place. It decides whether a given request gets the fully upgraded cached page, or the App Shell that then streams in its dynamic pieces. Once you're inside the App Shell path, streaming is exactly what makes that shell useful rather than just a static skeleton with a permanent spinner — the fallback content in your <Suspense> boundaries is what the visitor sees for the fraction of a second before the real content streams in.

Put differently: streaming is the mechanism, and the App Shell is one particular situation where that mechanism gets used automatically, on your behalf, for URLs you never explicitly planned for. If you already understand <Suspense> boundaries and fallback UI from building normal streaming pages, you already understand most of what's happening inside an App Shell — the only new idea is that Next.js decided to serve that particular streaming version of the page because it didn't have a cached final answer yet.

Cache storage and multi-instance considerations

The background upgrade that happens after a first visit has to land somewhere, and where it lands matters if you're running more than one server instance behind a load balancer.

In a single-instance deployment, this is invisible — the process that served the shell is the same process that computes and stores the upgrade, and the next request naturally sees it. In a multi-instance deployment, the instance that served the App Shell for the first visitor might not be the same instance that receives the second visitor's request. Whether that second visitor actually gets the upgraded page depends on whether your cache handler is shared across instances (a Redis-backed handler, a shared file system, or a platform-managed cache) or local to each instance's memory.

If you're self-hosting across multiple instances without a shared cache handler, you can end up in a situation where every instance independently discovers "this URL is unknown" on its own first request, does its own background upgrade, and never benefits from another instance's earlier work. This isn't a bug in the feature so much as a natural consequence of caching being instance-local by default — the fix is the same one you'd already reach for to make regular 'use cache' behavior consistent across instances: point your cacheHandlers configuration at shared storage. If you're on a managed platform, this is frequently handled for you already, but it's worth confirming rather than assuming, especially if you notice the "upgrade" seemingly never sticking under real traffic.

Common mistakes to watch for

Awaiting params above the Suspense boundary. This is the single most common way to accidentally disable the whole mechanism. If you read params before entering <Suspense>, you've made the entire subtree depend on that specific URL, and there's no shell left to generate for unknown values.

Forgetting 'use cache' on data helpers. Without it, your "static" shell still hits the network on every render attempt, which either slows down the shell generation at build time or, worse, quietly turns a section you thought was cached into an uncached runtime cost.

Enabling only one of the two flags. As covered above, cacheComponents without partialPrefetching gets you the instant shell but not the eager background upgrade — routes will still upgrade eventually (on the next real visit), just without the prefetch-triggered head start.

Assuming a missing parent param is harmless. A category absent from generateStaticParams doesn't just affect that one shell — it blocks every product underneath it from ever upgrading past the fallback state, regardless of how often those individual products are visited.

Confusing this with classic ISR's revalidate window. This feature is about the first visit to an unknown URL, not about periodically refreshing a known one. If your actual problem is "this page's data goes stale after an hour," that's a cacheLife/revalidateTag question, not an App Shell question.

Key takeaways

ConceptWhat it means here
App ShellThe URL-independent part of a route, prerendered even when specific params are unknown
cacheComponentsThe flag that enables splitting a render into shell + dynamic parts
partialPrefetchingThe flag that upgrades a shell to the full page, triggered by prefetch or navigation
First visitServed instantly from the shell; triggers a background render with real params
PrefetchCounts as a "first visit" — can pre-upgrade a route before the user clicks
Subsequent visitsServed from the fully upgraded, cached result — no shell, no streaming
generateStaticParamsNow a prioritization tool, not an exhaustive list — prerender what benefits most
Pages Router equivalentfallback: true, but automatic, with a real <Suspense> fallback instead of a boolean flag

Used well, this feature quietly removes one of the last reasons to over-build your static param lists "just in case." You get to prerender only what you're confident is worth the build cost, and let the shell-and-upgrade path absorb everything else — without shipping your slowest page load to the visitors who happen to be first.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper