Keep layout, navbar and footer cached with cacheComponents while handling cookies, headers, Draft Mode, and metadata…
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
How the Next.js 16 caching model flips the old Partial Prerendering assumptions, and the pattern that keeps your layout, navbar, and footer in the static shell while cookies, headers, and Draft Mode still resolve correctly deeper in the tree.
Next.js 16 folds Partial Prerendering into Cache Components. Enable cacheComponents: true in next.config.ts, and data fetching becomes dynamic by default: every component renders at request time unless you mark it with "use cache". That's the opposite of the old experimental_ppr model, where a route stayed static by default and you opted specific pieces into dynamic behavior. Teams that upgrade without adjusting for this get a passing build and a route that quietly renders on every request, because nothing in it ever asked to be cached. This guide covers the pattern I use to keep the outer shell — layout, navigation, theme wrapper — in the static prerender while cookies, headers, and Draft Mode still resolve correctly further down the tree, plus the two error messages you'll hit along the way and what each one is actually telling you.
I've run Cache Components in production on multi-tenant Payload CMS builds since it shipped, on routes that need Draft Mode so editors can preview unpublished content across dozens of tenant sites. Moving from experimental.ppr to cacheComponents: true is more than a config rename. It changes which parts of a route are static by default, and I re-learned three edge cases building tenant-aware routing with Draft Mode before the pattern below stopped throwing errors.
What actually changed between PPR and Cache Components
Next.js 16 removes the experimental.ppr flag in next.config and the experimental_ppr route segment export entirely. A codemod handles the removal for you. Partial Prerendering is now part of Cache Components, turned on with a single cacheComponents: true flag that also controls the useCache and dynamicIO behavior that used to be separate experimental options.
The part that catches teams off guard is the default. Under the old experimental PPR model, a route segment prerendered as static unless it touched a request-time API, and you had to explicitly wrap the dynamic parts in <Suspense> to keep the rest of the page static. Under Cache Components, data fetching is dynamic by default. A component that fetches from your database or calls an external API renders at request time unless you explicitly cache it with "use cache". Nothing about your static shell is automatic anymore. You ask for it.
That last row matters if you have routes on runtime = 'edge'. Cache Components requires the Node.js runtime, so those routes need to migrate before you can turn the flag on.
Step 1: Enable Cache Components and cache the shell
Start with the config flag, then mark the layout that should stay in the static shell.
The "use cache" directive at the top of the layout puts it back into the static prerender the same way it rendered by default under the old model, this time as an explicit choice with a lifetime attached through cacheLife. The layout still reads params, and reading route params inside a cached scope is fine; they're part of the cache key. children passes through untouched, so whatever renders inside this layout keeps its own caching behavior independent of the shell around it.
"Uncached data was accessed outside of <Suspense>"
This is the exact error title Next.js shows when a component does uncached async work without a Suspense boundary above it, and it's the direct replacement for the vague deopt-to-dynamic behavior from the old model. Next.js validates the tree and names the exact component responsible, so you get a specific, actionable error at build time or in the dev overlay.
It fires in three situations: awaiting params or searchParams without a Suspense boundary, calling cookies(), headers(), or connection() outside one, or fetching data that isn't wrapped in and isn't inside one either. The fix is the same shape in each case: either cache the data with , or give it a Suspense boundary and let it render at request time.
The page component itself does nothing that requires request time. It renders a Suspense boundary immediately and hands the params promise to a child component that does the actual awaiting. Next.js prerenders the fallback into the static shell and streams ProductDetail in once the params and product data resolve. If getProduct should be part of the static shell too, add "use cache" and a cacheLife profile to it directly, and the Suspense boundary becomes unnecessary for that piece.
What's safe to read inside use cache, and what throws
Cached functions and components can't access cookies(), headers(), or searchParams. The restriction follows the call stack, so a helper function that a cached component calls into fails the same way. Trying it throws immediately with an error naming the route and the API: something close to used cookies() inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. The fix is the pattern Next.js documents directly: read the value outside the cached function, then pass it in as an argument, where it becomes part of the cache key.
draftMode() is the one exception worth knowing about, and it's easy to get wrong because it looks like the same category of API as cookies() and headers(). You can read isEnabled from draftMode() directly inside a "use cache" scope. When Draft Mode is on, every cached function and component in that scope re-executes on every request and its output is never saved to the cache. When it's off, the cached path runs and caches normally. That single check does the work the original PPR-era pattern needed a separate uncached "gate" component for.
cookies() and headers() don't get this treatment, even while Draft Mode is active. If your route needs an actual cookie value beyond the Draft Mode boolean, an auth token or a locale preference, for example, keep the Suspense-wrapped gate pattern from the previous section: read the cookie in an uncached component, then pass the resolved value into whichever cached child needs it.
Fixing generateMetadata without breaking the shell
generateMetadata can't be wrapped in <Suspense>. Metadata resolves before the page streams, so there's no child boundary to hand it off to, and any uncached data fetch inside it triggers the same "uncached data" error described above at the route level.
The straightforward fix is to route the metadata fetch through the same cached loader the page uses:
getCachedProductSeo carries its own "use cache" directive, so metadata resolution reads from the cache during prerendering and doesn't force the route dynamic. This covers the majority of cases: SEO data drawn from the same content that already gets cached for the page body.
For the rarer case where metadata genuinely needs per-request data that can't be cached, Next.js's own migration guide documents a narrow workaround: an isolated marker component that calls connection() inside its own Suspense boundary, positioned beside the static content.
tsx
{ } ;
{ connection } ;
() {
();
;
}
() {
(
);
}
connection() is a narrow tool for the specific case where a piece of the route genuinely can't be deterministic and can't be expressed through "use cache". The risk shows up when it gets applied broadly, at the top of a route or layout, forcing everything beneath it into request-time rendering when only one small piece actually needed that. Keep it scoped to the smallest component that needs it, and everything around it keeps its place in the static shell.
The root layout attribute trap
One case doesn't have a Suspense-based fix. If a cookie or header value drives an attribute on the <html> element in your root layout, lang, dir, or a data-theme attribute set from a stored preference, reading it there makes the whole subtree request-bound. There's no child component to hand the Suspense boundary to; the root layout is the outermost point in the tree.
The workaround is to keep the root layout static and set the attribute client-side, before paint, with a small inline script in <head> that reads the stored preference and applies it directly to the <html> element. It runs before hydration, so there's no flash, and the root layout stays part of the cached shell.
FAQ
Does wrapping a component in <Suspense> make it dynamic?
No. <Suspense> provides a fallback while async work completes. It doesn't opt a component into request-time rendering by itself. A component that only does synchronous work completes during prerendering whether or not it sits inside a Suspense boundary. What makes something dynamic is touching a runtime API or fetching data that isn't cached.
Can I still use experimental_ppr on Next.js 16?
No, the flag and the route segment export were both removed in Next.js 16. Run the official codemod when upgrading and it strips the segment config for you; the cacheComponents flag replaces it at the config level.
Does Cache Components work on Edge runtime or the Pages Router?
Cache Components requires the Node.js runtime and applies to the App Router. Routes still exporting runtime = 'edge' need to move to the Node.js runtime before you can enable cacheComponents, and if you need edge behavior for specific paths, Next.js 16's proxy.ts convention is the place for that now, not the route runtime export.
What happens to a "use cache" entry while Draft Mode is on?
It re-executes on every request and the result is never written to the cache. That's what makes reading draftMode().isEnabled inside a cached scope safe: the cache effectively disables itself for that scope while Draft Mode is active, and resumes normal caching once it's off.
Why does my static shell keep rendering dynamically after I enabled cacheComponents?
Almost always because nothing in the route has been marked "use cache". Under the old PPR flag, avoiding dynamic APIs was enough to stay static. Under Cache Components, staying static is something you ask for explicitly, starting with the layout and any component that doesn't need per-request data.
Wrapping up
Cache Components swapped the default underneath the PPR flag rename: static shells are now something you build explicitly with "use cache". Holding that mental model makes each of the errors above feel specific and fixable, pointing at the exact component and the exact API call responsible. "Uncached data was accessed outside of <Suspense>" is Next.js telling you exactly where the boundary needs to move, and the cookies()/headers() restriction inside is the framework protecting you from a cache key that would be different on every single request. gets a pass because Draft Mode already disables the cache when it matters.