---
title: "Next.js Cache Components: Static Shell Pattern Guide"
slug: "nextjs-cache-components-static-shell-pattern"
published: "2026-08-25"
updated: "2026-08-28"
validated: "2026-08-28"
categories:
  - "Next.js"
tags:
  - "Next.js Cache Components"
  - "cacheComponents"
  - "static shell pattern"
  - "Next.js Draft Mode"
  - "use cache"
  - "generateMetadata Next.js"
  - "cookies() headers() Next.js"
  - "Partial Prerendering"
  - "Suspense boundary Next.js"
  - "cacheLife"
  - "Payload CMS Next.js"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js 16"
  - "react"
  - "node.js"
  - "payload cms"
  - "next/cache"
status: "stable"
llm-purpose: "Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…"
llm-prereqs:
  - "Access to Next.js 16"
  - "Access to React"
  - "Access to Node.js"
  - "Access to Payload CMS"
  - "Access to next/cache"
llm-outputs:
  - "Completed outcome: Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…"
---

**Summary Triples**
- (Next.js 16, folds, Partial Prerendering into Cache Components)
- (next.config.ts, must set, cacheComponents: true to enable Cache Components)
- (Default rendering behavior, is, dynamic at request time for components unless they include "use cache")
- ("use cache" directive, marks, a component as cacheable/static under cacheComponents)
- (Static shell pattern, keeps cached, outer layout, navbar, footer while allowing cookies/headers/Draft Mode to resolve deeper)
- (Cookies, headers, Draft Mode resolution, should run, in deeper (dynamic) components, not in the outer static shell)
- (Teams upgrading from experimental_ppr, must, explicitly mark components with "use cache" to restore previous static defaults)
- (Pattern applicability, tested in production on, multi-tenant Payload CMS builds requiring Draft Mode previews)

### {GOAL}
Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…

### {PREREQS}
- Access to Next.js 16
- Access to React
- Access to Node.js
- Access to Payload CMS
- Access to next/cache

### {STEPS}
1. Enable cacheComponents in config
2. Mark layout with "use cache"
3. Wrap request-time parts in Suspense
4. Read cookies and headers outside cache
5. Handle Draft Mode inside cache
6. Serve metadata from cached loader
7. Use client script for root attributes

<!-- llm:goal="Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…" -->
<!-- llm:prereq="Access to Next.js 16" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to next/cache" -->
<!-- llm:output="Completed outcome: Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…" -->

# Next.js Cache Components: Static Shell Pattern Guide
> Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…
Matija Žiberna · 2026-08-25

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

| Aspect | Next.js 14/15 experimental PPR | Next.js 16 Cache Components |
|---|---|---|
| Enable via | `experimental.ppr` + `experimental_ppr` per segment | `cacheComponents: true` in `next.config.ts` |
| Default for data fetching | Static, unless a dynamic API is touched | Dynamic, until you add `"use cache"` |
| Keeping something in the static shell | Automatic, as long as you avoid dynamic APIs | Explicit `"use cache"` directive, with a `cacheLife` profile |
| Keeping something request-time | `<Suspense>` boundary around the dynamic API | `<Suspense>` boundary, still required |
| Runtime support | Edge and Node.js | Node.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](https://www.buildwithmatija.com/blog/nextjs-cache-components-payload-cms-support) before you enable the flag app-wide, and the [Draft Mode and live preview wiring](https://www.buildwithmatija.com/blog/live-preview-payload-cms-nextjs-implementation-guide) 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

## LLM Response Snippet
```json
{
  "goal": "Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…",
  "responses": [
    {
      "question": "What does the article \"Next.js Cache Components: Static Shell Pattern Guide\" cover?",
      "answer": "Next.js Cache Components: master the static shell pattern to keep layouts cached while safely reading cookies, headers and Draft Mode—includes fixes and…"
    }
  ]
}
```