BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Next.js Cache Components: Static Shell Pattern Guide

Next.js Cache Components: Static Shell Pattern Guide

Keep layout, navbar and footer cached with cacheComponents while handling cookies, headers, Draft Mode, and metadata…

25th August 2026·Updated on:28th August 2026··
Next.js
Next.js Cache Components: Static Shell Pattern Guide

⚡ 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.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • What actually changed between PPR and Cache Components
  • Step 1: Enable Cache Components and cache the shell
  • "Uncached data was accessed outside of `<Suspense>`"
  • What's safe to read inside `use cache`, and what throws
  • Fixing `generateMetadata` without breaking the shell
  • The root layout attribute trap
  • FAQ
  • Wrapping up
On this page:
  • What actually changed between PPR and Cache Components
  • Step 1: Enable Cache Components and cache the shell
  • "Uncached data was accessed outside of `<Suspense>`"
  • What's safe to read inside `use cache`, and what throws
  • Fixing `generateMetadata` without breaking the shell
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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.

AspectNext.js 14/15 experimental PPRNext.js 16 Cache Components
Enable viaexperimental.ppr + experimental_ppr per segmentcacheComponents: true in next.config.ts
Default for data fetchingStatic, unless a dynamic API is touchedDynamic, until you add "use cache"
Keeping something in the static shellAutomatic, as long as you avoid dynamic APIsExplicit "use cache" directive, with a cacheLife profile
Keeping something request-time<Suspense> boundary around the dynamic API<Suspense> boundary, still required
Runtime supportEdge and Node.jsNode.js only

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.

ts
// File: next.config.ts
import type { NextConfig } from "next";

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

export default nextConfig;
tsx
// File: src/app/[locale]/[tenant]/layout.tsx
import { cacheLife } from "next/cache";
import { ThemeProvider } from "@/components/theme-provider";
import { TenantNav } from "@/components/tenant-nav";

export default async function TenantLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ tenant: string; locale: string }>;
}) {
  "use cache";
  cacheLife("hours");

  const { tenant, locale } = await params;
  const brand = await getTenantBrandConfig(tenant);

  return (
    <ThemeProvider theme={brand.theme}>
      <TenantNav locale={locale} links={brand.navLinks} />
      {children}
    </ThemeProvider>
  );
}

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 "use cache" and isn't inside one either. The fix is the same shape in each case: either cache the data with "use cache", or give it a Suspense boundary and let it render at request time.

tsx
// File: src/app/[locale]/[tenant]/products/[slug]/page.tsx
import { Suspense } from "react";
import { ProductSkeleton } from "@/components/skeletons";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ tenant: string; slug: string }>;
}) {
  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductDetail params={params} />
    </Suspense>
  );
}

async function ProductDetail({
  params,
}: {
  params: Promise<{ tenant: string; slug: string }>;
}) {
  const { tenant, slug } = await params;
  const product = await getProduct(tenant, slug);
  return <ProductView product={product} />;
}

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.

tsx
// File: src/data/tenant-content.ts

// ❌ Throws: next-request-in-use-cache
async function getExampleData() {
  "use cache";
  const isLoggedIn = (await cookies()).has("session");
  // ...
}

// ✅ Read outside the cached scope, pass the value in
async function getExampleData(isLoggedIn: boolean) {
  "use cache";
  // isLoggedIn is now part of this function's 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.

tsx
// File: src/data/get-page-content.ts
import { draftMode } from "next/headers";
import { cacheLife } from "next/cache";

export async function getPageContent(tenant: string, slug: string) {
  "use cache";
  cacheLife("hours");

  const { isEnabled: isDraft } = await draftMode();
  const source = isDraft
    ? `https://cms.internal/draft/${tenant}/${slug}`
    : `https://cms.internal/published/${tenant}/${slug}`;

  const res = await fetch(source);
  return res.json();
}

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:

tsx
// File: src/app/[locale]/[tenant]/products/[slug]/page.tsx
import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ tenant: string; slug: string }>;
}): Promise<Metadata> {
  const { tenant, slug } = await params;
  const product = await getCachedProductSeo(tenant, slug);
  return { title: product.title, description: product.description };
}

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
// File: src/app/[locale]/[tenant]/dashboard/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function RequestMarker() {
  await connection();
  return null;
}

export default function DashboardPage() {
  return (
    <>
      <StaticDashboardShell />
      <Suspense fallback={null}>
        <RequestMarker />
      </Suspense>
    </>
  );
}

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.

tsx
// File: src/app/layout.tsx

// ❌ Forces the entire app dynamic — no child to wrap in Suspense
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const theme = (await cookies()).get("theme")?.value ?? "light";
  return (
    <html lang="en" data-theme={theme}>
      <body>{children}</body>
    </html>
  );
}

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 use cache is the framework protecting you from a cache key that would be different on every single request. draftMode() gets a pass because Draft Mode already disables the cache when it matters.

If you're running Payload CMS alongside this setup, the admin panel has its own Cache Components caveats worth checking before you enable the flag app-wide, and the Draft Mode and live preview wiring pairs directly with the draftMode() pattern covered here.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija