DEV Community

Cover image for I Tested generateStaticParams in Next.js Cache Components. My Layout Broke Twice
Shubhra Pokhariya
Shubhra Pokhariya

Posted on AI-assisted

I Tested generateStaticParams in Next.js Cache Components. My Layout Broke Twice

The last post here got picked apart in the comments in the best possible way. @nazar-boyko asked the sharpest version of the question I'd skipped entirely: if the ids are known at build time, does the page go fully static, or does Cache Components still want a stream, cache, or block answer regardless. @mudassirworks flagged the nested-component wrinkle, two server components independently awaiting params, and where the Suspense boundary actually has to sit. @suraj09, @glenallen, and @merbayerp circled the same architecture question from different angles, what happens once a layout and the page underneath it both depend on the same runtime data. @alexshev pushed on something none of us had done, turning a one-off fix into something that survives after the post is forgotten. None of it had a real answer from me at the time, so I built a fresh project and went and got one.

So I built a fresh test project, four routes this time instead of one, and ran every one of these to an actual answer.

Does generateStaticParams make a route static?

Short version: no. I gave a route three known ids up front and left the data access unguarded, same as the original test.

export async function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }, { id: "3" }];
}

export default async function ProductStaticPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Build fails, and it names id 1 specifically, one of the exact three I told it about ahead of time. Knowing the id at build time and being allowed to skip the stream, cache, or block decision are two different things.

That part's a clean answer, but not the interesting one. The interesting part is what happens once you actually pick a fix, because the two fixes I tried don't produce the same result.

Wrap the data access in Suspense, and the build passes, but all three known ids come out marked as Partial Prerender. The data stays deferred behind the static shell, the same as it does for an id the route has never seen. Swap that for caching the data function instead, one word changed, "use cache" instead of a Suspense wrapper, and those same three ids flip to fully static, baked into the build output.

Build output showing all three known ids from generateStaticParams marked fully static after switching the fix from Suspense to use cache

That's the real answer to Nazar's question. generateStaticParams decides which paths get attempted. Whether they end up static depends on the work the route performs for those params and how that work gets handled, not on the ids being known ahead of time by itself.

One more dead end worth flagging before you go looking for it yourself: I tried pairing generateStaticParams with the old dynamicParams = false option, expecting a 404 for anything outside the known set. dynamicParams still exists as a route segment config in Next.js generally, but it's disabled specifically once cacheComponents is on, so the build rejects it instead of applying it. In this Cache Components setup, unlisted ids are handled at request time instead.

The bug that took three attempts

This is the one I'd read even if you skip everything else, because it's the part that answers Suraj, Glen, and Mustafa's question, and it's the part that actually surprised me.

Setup: a layout and a page sharing a route segment, both reading the same id, both calling the same two data functions. Both unguarded. Build fails, expected.

My first move was the obvious one: fix the page, leave the layout alone, since I'd already handled the page's own component.

Still failed. Fixing the page did nothing for an unguarded params access in the layout above it. A Suspense boundary in the page can't reach back and cover work that already happened in the parent before the page even rendered.

So I fixed the layout too, wrapped the component reading the data in its own Suspense, the same move that had worked everywhere else so far.

Still failed. Same error. This is where I stopped guessing and ran next build --debug-prerender to get an actual trace instead of a route name.

It pointed at const { id } = await params;, sitting in the layout, above the Suspense boundary I'd just added. Not the data fetch inside the wrapped component. The line that produced the id in the first place.

Debug prerender trace pointing to the exact await params line in layout.tsx, above the Suspense boundary

I'd wrapped the component that used the id. I hadn't wrapped the line that produced it.

The real fix passes the whole params promise down and resolves it inside the wrapped child, instead of resolving it in the parent and handing over a plain value:

async function WorkspaceHeader({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const workspace = await getWorkspace(id);
  return <header>{workspace.name}</header>;
}

export default function WorkspaceLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ id: string }>;
}) {
  return (
    <div>
      <Suspense fallback={<header>Loading...</header>}>
        <WorkspaceHeader params={params} />
      </Suspense>
      <div className="p-8">{children}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That builds clean. Same principle applied to the page underneath it.

One negative result worth keeping, since it's the mistake I'd expect someone to reach for first on a real app: I also tried leaving both layers completely unguarded and just caching the two shared data functions instead, assuming that would cover both callers at once. It didn't touch the failure at all. The unguarded operation was never the data fetch, it was await params itself, sitting outside any boundary in both places. Caching a function a broken access calls doesn't fix the access that calls it.

Mudassir's nested-sibling case turned out to follow the same underlying rule, just one layer shallower: two independent components both reading params, and wrapping only one of them left the other exposed. The fix isn't a specific boundary shape, it's making sure nothing reading runtime data sits outside every boundary, whether that's one shared wrapper or several separate ones.

What I'd actually check on a real app

Audit layouts before pages. A layout's unguarded access fails on its own terms, independent of anything you've already fixed below it, and it's the thing most likely to get missed because layouts don't feel like "the route" the way a page does.

When a fix doesn't work and the error still points at something you've technically wrapped, check where the await itself happens, not just where the result gets used. Resolving a promise above a boundary and passing the plain value into a wrapped child still counts as an unguarded access.

Alex, on the regression test question, here's what I landed on: a small script that rebuilds, then hits each fixed route including the unlisted-id case, since a plain build won't catch that on its own. Worth being honest that an HTTP check only proves the route responds, not which rendering mode it's using. That classification only ever comes from the build output itself.

I put the full four-route walkthrough, all the build screenshots, the exact debug trace, and the regression script in the full Next.js generateStaticParams and nested-layout walkthrough if you want the whole thing end to end rather than the condensed version here.

Has anyone hit this layout-versus-page split in a real production route tree yet? I'm curious whether the same params boundary issue shows up once several layouts start sharing data.

Top comments (0)