DEV Community

Cover image for I Turned On Cache Components in Next.js 16.3. It Refused to Build My Simplest Page.
Shubhra Pokhariya
Shubhra Pokhariya

Posted on

I Turned On Cache Components in Next.js 16.3. It Refused to Build My Simplest Page.

Strict prerendering rules catch simple routes

I didn't want to write another "what's new in Next.js 16.3" post. Enough of those exist. I wanted to know what actually happens when you flip the flags on a real project, so I scaffolded a fresh app, turned on cacheComponents and partialPrefetching, and tried to build the simplest possible dynamic page.

It didn't build.

The setup

Nothing fancy. Fresh create-next-app, Next.js 16.3.1, two flags in next.config.ts:

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};
Enter fullscreen mode Exit fullscreen mode

Then a product page. No database, no external API, not even a real fetch, just a fake delay and the route's own params:

async function getProduct(id: string) {
  await new Promise((resolve) => setTimeout(resolve, 300));
  return { id, name: `Product ${id}`, price: 42 };
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold">{product.name}</h1>
      <p>${product.price}</p>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's about as plain as a dynamic route gets. I expected next build to shrug and move on.

What actually happened

Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
Enter fullscreen mode Exit fullscreen mode

Next.js 16.3 build error for params route showing the uncached-or-runtime-data error with stream/cache/block options

Full build failure. Exit code 1. For a page whose only "dynamic" behavior is reading a param that's obviously going to be there.

My first reaction was that this felt aggressive. My second reaction, after rereading it, was that this is the whole point of the release and I'd missed it by only skimming the changelog. Cache Components in 16.3 doesn't treat an unguarded dynamic access as a warning. It treats it as a decision you have to make, explicitly, before it'll ship your build. Stream it, cache it, or mark it blocking on purpose. No silent fourth option where it just works and you find out it was never instant three months from now.

The fix, and what it actually buys you

I took the option the error suggested first: wrap the part that awaits params in <Suspense>.

import { Suspense } from "react";

async function getProduct(id: string) {
  await new Promise((resolve) => setTimeout(resolve, 300));
  return { id, name: `Product ${id}`, price: 42 };
}

async function ProductDetails({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);
  return (
    <>
      <h1 className="text-2xl font-bold">{product.name}</h1>
      <p>${product.price}</p>
    </>
  );
}

export default function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <main className="p-8">
      <Suspense fallback={<p>Loading product...</p>}>
        <ProductDetails params={params} />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ran the build again:

Route (app)
┌ ○ /
├ ○ /_not-found
└   /products/[id]
  └ ◐ /products/[id]

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
Enter fullscreen mode Exit fullscreen mode

Next.js 16.3 successful build output showing Partial Prerender symbol ◐ for /products/[id]

That next to my product route is the actual feature. Next.js pulled out everything around the Suspense boundary, the shell, and prerendered it. The part that actually depends on which product you clicked streams in behind it. That's Cache Components' Partial Prerendering doing exactly what it's supposed to, a real route in a real build output, not a description from the release notes. It's also the same shell mechanism that Partial Prefetching, the client-side navigation feature 16.3 adds on top of this, depends on existing in the first place.

Browser rendering the product page at localhost:3000/products/123 showing Product 123 and $42

What this told me that the changelog didn't

Reading about Partial Prefetching, "a reusable shell per route, cached on the client," sounds like a nice-to-have you'd adopt when you get around to it. Hitting the build error told me something different: on a 16.3 app with Cache Components on, there's no version of "get around to it." Every uncached or runtime access Next.js finds during prerendering needs an answer, stream, cache, or block, the moment you turn the flag on. It's not gradual. It's a build gate.

That's a bigger adoption cost than "add two lines to your config" makes it sound, and I think that's exactly why Next.js ships next-cache-components-adoption as a first-party agent Skill now instead of leaving you to hunt down every unguarded params and cookies() call by hand across a real app. On a five-file test project this took me two minutes. On a real production route tree, I wouldn't want to do it without that tool, or without a very good sense of where every Suspense boundary in the app already is.

Where I'd actually start

If you're evaluating this for a real app: don't turn the flag on for the whole project and see what catches fire. Pick one route, ideally your simplest dynamic one, and watch what the build tells you it needs. It will tell you exactly what to do next. That part, at least, the error message gets right.

I wrote a longer breakdown of the full 16.3 release, what upgrades automatically with zero config changes, what Instant Navigations and Partial Prefetching actually change, and what shipped for AI agents, on my website if you want the whole picture before you decide whether to flip these flags on something real.

Has anyone else run the migration on an actual production route tree yet? I'd like to know what the first pass looks like on a real app.

Top comments (32)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Nice work! What happens when generateStaticParams is in the mix? Did you try it? If the ids are known at build time the page could go fully static, so I wonder whether the error only fires for unlisted ids or the flag wants a stream, cache, or block answer either way. Would be a nice follow-up experiment on the same five-file project.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Nazar. Good question, and no, I didn't try generateStaticParams. My test kept the route deliberately simple and used params at request time, so I don't have a result to point to there.

It would be a useful follow-up, especially the unlisted-id case. I'd want to see whether the build treats the generated params differently and what happens when a request falls outside that set.

I haven't run that experiment yet, but it would be interesting to test on the same project.

Collapse
 
nazar-boyko profile image
Nazar Boyko

Makes sense. Thanks for clarifying and for sharing the experiment. It would be interesting to see how that case behaves too.

Collapse
 
ofri-peretz profile image
Ofri Peretz

The detail that stuck with me is the choice to make this a build failure rather than a runtime warning or a degraded-performance heuristic. In static analysis work there's a constant tension between "warn and let it ship" vs "block until the developer makes an explicit decision" — and the answer usually hinges on how invisible the silent failure is. Next.js clearly judged that "this page renders slower than you expect because you never declared your intent" is invisible enough to warrant a hard stop, which I think is the right call. The thing I'd watch for in practice is whether teams start treating instant = false the way they treat // eslint-disable-next-line — a fast escape hatch that accumulates until nobody knows which suppressions are intentional and which are panic fixes. The enforcement is only as strong as the culture around what you're allowed to suppress.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

I think that’s the right thing to watch for, Ofri. The build gate makes the decision explicit, but instant = false can still make it easy to satisfy the build without really thinking through why the route should be blocking.

I like the eslint-disable-next-line comparison too. The problem isn't having an escape hatch, it's losing track of why it was used.

Collapse
 
ofri-peretz profile image
Ofri Peretz

You're right that instant: false is the easier exit — it compiles, the PR ships, the reason gets buried in the commit message. The failure mode I've hit is annotation drift: a route gets flagged instant: false because it reads a user preference at render time, then that preference gets lifted into a layout-level server action six months later, but the flag stays because nobody has a forcing function to revisit it. Now the route blocks every request for no reason. The eslint-disable analogy holds exactly there — the dangerous moment isn't when you add the annotation, it's when the original condition disappears and the annotation doesn't.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Ofri. "Annotation drift" is a good name for that, and it's the sharper version of what I was getting at. My concern was losing track of why the escape hatch was added. Your example shows the other side of it: the condition that led to the decision can change later, while the decision itself stays in place.

That's also something the build gate can't tell you. It can force the decision when you first hit the problem, but the code can change around it later. At that point, the question becomes whether anyone goes back and checks if the original decision still makes sense.

Thread Thread
 
ofri-peretz profile image
Ofri Peretz

You're right that the gate is one-directional — it fires when the escape hatch is introduced, forces the decision, and then never re-triggers. The staleness is entirely silent afterward. The concrete failure mode I've hit: a use client directive added because a specific hook wasn't server-compatible; the hook gets replaced six months later; the directive stays; now you have unnecessary client-side hydration for the lifetime of that component. Nothing breaks, no warning surfaces, the build keeps passing. That's actually what makes annotation drift harder to address than the original build failure — the original error at least told you something was wrong.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Ofri. The use client example makes the silence concrete in a way "annotation drift" alone didn't for me. The build failure at least announces itself. This one just costs you extra hydration for as long as nobody looks at that file again.

And that's the tricky part. The build can check whether the directive is there, but it can't really know whether the reason it was added still exists. Once the code changes around it, the old decision can just sit there unnoticed.

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Nice debugging as usual! 😀

Builds are so tricky for me. They succeed in my local environment but always fail in other environments and the CI! 🤣

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you! I know that feeling. Everything can look perfectly fine while developing locally, and then the build decides to surprise you when you run it somewhere else. 😊

Collapse
 
suraj09 profile image
Suraj Suradkar

I like that the build error forces an explicit decision instead of silently accepting runtime work. The interesting part is that the build becomes a kind of architecture check: every dynamic dependency has to declare whether it should stream, cache, or block. That seems much more valuable than discovering the same performance trade-off in production.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Suraj. “Architecture check” is a good way to put it. That’s basically what it felt like even in my small test project. I went in expecting a warning and got a build gate that wouldn't let me skip the decision.

In my params example, choosing Suspense didn't just make the build pass. It changed the route to a Partial Prerender, with the shell prerendered and the product content streamed in.

That was the part that made it click for me. The choice isn't just about fixing the build, it actually determines how the route behaves.

Collapse
 
suraj09 profile image
Suraj Suradkar

Exactly. The build gate forces the architectural decision, but the real test is whether that decision improves the user experience. I’d be curious to see how this behaves on a larger route tree.

Collapse
 
alexshev profile image
Alex Shev

The durable part of this approach is the feedback loop. If the team can reproduce the condition, measure the impact, and keep a regression test close to the change, the learning survives beyond the original incident.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

That’s a good point, Alex. In my case, I stopped at reproducing the failure, fixing it, and documenting what I found. I like the idea of carrying that further though, especially keeping the original failure reproducible so the same issue doesn't quietly come back later.

Collapse
 
alexshev profile image
Alex Shev

Exactly. Keeping the original failure reproducible is what turns a good incident note into a regression boundary. The smallest useful version is often one focused test plus a short note about what it is protecting.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

"One focused test plus a short note about what it's protecting" is a good bar to hold this to. I did test the failure and the fix while working through the post, but I didn't turn that into a regression test that stays with the code.

The note gives the context, and the test gives you something that can catch the same condition if it comes back. I like that as a way of making the learning stick beyond the original failure.

Collapse
 
suraj09 profile image
Suraj Suradkar

The build gate is probably the more interesting part here than the caching feature itself. It turns “we should think about this dynamic access eventually” into an explicit architectural decision at build time.

I’m especially curious about larger existing apps though — did you find that the hard part was adding the Suspense boundaries, or deciding which data should actually be cached vs streamed vs intentionally blocking?

That distinction feels like it could get messy once you have several nested layouts and shared data dependencies.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Suraj. Honestly, neither was hard in my case, and that's kind of the tell. I had one dynamic value and one boundary, so there was no real decision to make between stream, cache, and block. The error pointed at the fix and I took it.

What you're describing is the part I haven't tested: nested layouts where the same data feeds more than one place, and it's not obvious which layer should own the caching decision. I suspect that's where it gets less mechanical, but I haven't run that scenario yet.

Collapse
 
glenallen profile image
Glen Allen

What I find valuable here is that the build failure exposes a decision that was previously easy to leave implicit. That changes the role of the compiler from simply catching mistakes to enforcing architectural intent. The interesting challenge now is making sure teams understand the performance and UX consequences of each choice rather than treating the build fix as the end of the problem.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Glen. I think that's the important distinction here: the build makes you make the decision, but it doesn't tell you whether you made the right one.

The team part is something I can't really speak to from this test. I was looking at one small route, so there wasn't much of a review or onboarding problem to uncover. That's the part I'd want to test on a real route tree: whether the boundaries that satisfy the build also make sense to the people maintaining the code and the UX they're creating.

Collapse
 
glenallen profile image
Glen Allen

The migration strategy is what I find most interesting here. A build failure gives you a precise list of places where the framework needs an explicit decision, which could make incremental adoption much safer than trying to redesign the whole application upfront. The real challenge seems to be keeping those decisions consistent as the route tree grows and multiple components start sharing the same dynamic data.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Glen. "Precise list of places" is a good way to describe it. The build output told me exactly where I hadn't made a decision yet.

Consistency as the tree grows is the part I keep coming back to. The nested-params case and the boundary-placement question are both versions of that same problem.

The build can tell you whether the decision satisfies the build, but it can't tell you whether the boundary is the right one for the route. That's the part I'd want to watch on a real migration, especially once multiple components start depending on the same data.

Collapse
 
mudassirworks profile image
Mudassir Khan

the 'no silent fourth option' line is the right frame. the error isn't being aggressive — it's charging you a decision tax that you would have paid later anyway, just silently.

we hit the same fail on a product listing page. the wrinkle: if you have nested server components that each independently await params, the Suspense boundary has to sit above the outermost one. took about an hour to figure that out.

are you testing with a CDN layer in front? curious whether the shell TTL behaves predictably when the underlying product data changes.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Mudassir. That nested params case is interesting. I only had one server component in my test, so I didn't hit that wrinkle. Having the boundary above the outermost component is definitely something I'd keep in mind when testing a real route tree.

And no, I haven't tested this behind a CDN yet. My experiment was focused on the build and rendering behavior first, so I don't want to guess at how the shell TTL behaves when the underlying product data changes.

That would be a good next test, especially on a real product listing or detail page.

Collapse
 
merbayerp profile image
Mustafa ERBAY

The part I find most interesting here is that performance behavior is becoming something closer to a compile-time contract.

Previously, a dynamic access could quietly turn a route into something slower than intended and you might only discover that later through profiling or production telemetry.

With this model, the framework effectively asks you to classify the behavior before shipping:

stream it, cache it, or explicitly accept blocking.

That feels less like a caching feature and more like policy enforcement.

The obvious downside is migration friction on a large route tree, but I actually prefer that friction to silently changing rendering characteristics.

One thing I’d be curious to measure during a real migration is not just “how many routes fail the first build,” but how many Suspense boundaries introduced to satisfy the build are actually good UX boundaries.

Because a technically valid streaming boundary and a meaningful user-perceived loading boundary aren’t necessarily the same thing.

The compiler can force us to make the decision, but it still can’t decide where the right experience boundary belongs.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Mustafa. “Compile-time contract” is a better term for this than anything I used in the post, honestly. That really is the shift: instead of discovering it at runtime, you have to make that decision before the code ships.

Your last point is the one I keep thinking about. Satisfying the build only tells you the Suspense boundary is valid, not that it's in the right place from a UX perspective. My product page is the easy case because there's really only one sensible boundary. A real route tree could be much less obvious.

I didn't measure that in this post. I was mainly looking at the build output and route symbols. If I run this against a real route tree, I'd want to track that too, not just how many routes fail the first build, but how many of the required boundaries actually make sense as user-facing loading states.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.