DEV Community

Cover image for Next.js 16: Fixing "cookies() can't be read inside 'use cache'" (and Caching Public Data Without It)
Sushil
Sushil

Posted on

Next.js 16: Fixing "cookies() can't be read inside 'use cache'" (and Caching Public Data Without It)

You turn on Cache Components, add 'use cache' to a data function, and the build fails:

Error: Route "/broken": `cookies()` can't be read inside `"use cache"`. Read it outside the cached function and pass what you need as an argument.
Learn more: https://nextjs.org/docs/messages/next-request-in-use-cache
    at h (lib/server-client.ts:6:29)
    at i (app/broken/page.tsx:7:24)
Enter fullscreen mode Exit fullscreen mode

The confusing part is that your cached function might never call cookies() itself. In this example, the call is inside a helper that creates a "server client", the kind of cookie-aware factory that many auth and database SDKs ship for the App Router. The rule follows the call stack, so a cookie read anywhere below a 'use cache' boundary breaks it.

This post shows why that happens and the four patterns that fix it, each built and run on Next.js 16.4.3.

What you'll learn

  • Why cookies(), headers() and searchParams are not allowed in 'use cache'
  • How to cache public data when your SDK's default client reads cookies
  • How to pass a cookie value into a cached function safely
  • Where per-user data should go instead (and the Suspense rule that comes with it)
  • When 'use cache: private' is the right tool, and what it does not do

Prerequisites and versions

  • Next.js 16.4.3 with the App Router
  • React 19.3.1
  • TypeScript
  • Cache Components enabled (below)

Since Next.js 16.0, cacheComponents is a top-level config option. The current docs recommend setting partialPrefetching explicitly alongside it. Leaving it unset logs a warning:

// next.config.ts
import type { NextConfig } from 'next'

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

export default nextConfig
Enter fullscreen mode Exit fullscreen mode

Background: why the rule exists

A 'use cache' function's output is stored and reused across requests and users. Its cache key is built from the build ID, a hash of the function's identity, and its serializable arguments (see the use cache reference).

Cookies and headers aren't arguments. If a cached function could read them, the cache key wouldn't reflect them, and one user's cookie-dependent result could be served to someone else. So Next.js forbids request-time APIs inside the cached scope:

Cached functions and components cannot access runtime APIs like cookies(), headers(), or searchParams, and the restriction follows the call stack: a helper the cached function calls that reads one of these fails the same way.

The docs also warn that on a dynamically rendered route this "can pass next build and fail under next start". Don't treat a green build as proof.

Reproducing the error

Here's a stand-in for a cookie-aware SDK factory. Real ones (for example, an auth or database "server client" for the App Router) usually take the cookie store so they can attach the user's session:

// lib/server-client.ts
import { cookies } from 'next/headers'

// Stand-in for a cookie-aware SDK factory such as an auth or database
// "server client". The important part: it calls cookies() internally.
export async function createServerClient() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session')?.value ?? null
  return {
    async getPublicPosts() {
      return [{ id: 1, title: 'Hello' }, { id: 2, title: 'World' }]
    },
    async getMyDrafts() {
      if (!token) return []
      return [{ id: 99, title: `Draft for ${token}` }]
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

And a page that caches "public" posts with it:

// app/broken/page.tsx
import { cacheLife } from 'next/cache'
import { createServerClient } from '@/lib/server-client'

async function getPublicPosts() {
  'use cache'
  cacheLife('hours')
  const client = await createServerClient() // calls cookies() inside the cache scope
  return client.getPublicPosts()
}

export default async function Page() {
  const posts = await getPublicPosts()
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
Enter fullscreen mode Exit fullscreen mode

next build fails with the error at the top of this post. The stack trace points to the cookies() call in the helper and to the cached function that called it. That's the fastest way to find the culprit in a real codebase.

The fix, in four patterns

All four patterns below live on one page. Together they built cleanly. Next.js marked the route as Partial Prerender (◐) with Revalidate 1h and Expire 1d, which come from the hours profile.

Pattern 1: Public data uses a client that never touches cookies

If the data is the same for everyone, it shouldn't go through a cookie-aware client at all. Create a separate client for public reads. With real SDKs, this usually means the plain client created with a public/anon key, not the App Router "server client" helper.

// lib/public-client.ts
// Stand-in for a cookie-free client (for example a plain SDK client created
// with a public/anon key). It never touches cookies() or headers().
export function createPublicClient() {
  return {
    async getPublicPosts() {
      return [
        { id: 1, title: 'Hello' },
        { id: 2, title: 'World' },
      ]
    },
  }
}
Enter fullscreen mode Exit fullscreen mode
// 1. Public data: cookie-free client, safe to share across all users.
async function getPublicPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')
  const posts = await createPublicClient().getPublicPosts()
  return { posts, generatedAt: new Date().toISOString() }
}
Enter fullscreen mode Exit fullscreen mode

generatedAt is there to show the caching. With the page served by next start, two requests without cookies and one request with Cookie: locale=fr; session=alice all returned the same generatedAt value. The entry was filled during the build and reused for everyone.

Security note: Only do this for data that really is public. If your database enforces row-level security based on the user's session, a cookie-free client sees what an anonymous user sees. That's correct for public data and wrong for anything personal.

Pattern 2: Read the cookie outside and pass the value in

This is the pattern the error page recommends. The value you pass becomes part of the cache key, so each distinct value gets its own entry.

// 2. Data that depends on one small cookie value: read the cookie outside,
//    pass the value in. The value becomes part of the cache key.
async function getGreeting(locale: 'en' | 'fr') {
  'use cache'
  cacheLife('days')
  return locale === 'fr' ? 'Bonjour' : 'Hello'
}

async function Greeting() {
  const raw = (await cookies()).get('locale')?.value
  const locale = raw === 'fr' ? 'fr' : 'en'
  return <p id="greeting">{await getGreeting(locale)}</p>
}
Enter fullscreen mode Exit fullscreen mode

The response without cookies showed Hello. With Cookie: locale=fr it showed Bonjour.

Two design choices here matter:

  • Normalize the value before passing it. raw === 'fr' ? 'fr' : 'en' limits the cache to two entries. If you pass a raw cookie straight through, any client can create a new cache entry by sending a random value.
  • Prefer low-cardinality values such as locale, theme, plan tier or feature-flag variant. Passing a session token or user ID technically works (one entry per user), but at that point you're building a per-user cache. That's usually better handled by Pattern 3 or 4.

Pattern 3: Per-user data stays uncached, inside Suspense

Personal data, like "my drafts", can just stay dynamic. Use the cookie-aware client outside any cache scope:

// 3. Per-user data: not in a shared cache at all. Rendered at request time
//    inside a Suspense boundary.
async function MyDrafts() {
  const client = await createServerClient()
  const drafts = await client.getMyDrafts()
  return (
    <ul id="drafts">
      {drafts.map((d) => (
        <li key={d.id}>{d.title}</li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

The full page puts it all together:

// app/fixed/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'
import { createPublicClient } from '@/lib/public-client'
import { createServerClient } from '@/lib/server-client'

// 1. Public data: cookie-free client, safe to share across all users.
async function getPublicPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')
  const posts = await createPublicClient().getPublicPosts()
  return { posts, generatedAt: new Date().toISOString() }
}

// 2. Data that depends on one small cookie value: read the cookie outside,
//    pass the value in. The value becomes part of the cache key.
async function getGreeting(locale: 'en' | 'fr') {
  'use cache'
  cacheLife('days')
  return locale === 'fr' ? 'Bonjour' : 'Hello'
}

async function Greeting() {
  const raw = (await cookies()).get('locale')?.value
  const locale = raw === 'fr' ? 'fr' : 'en'
  return <p id="greeting">{await getGreeting(locale)}</p>
}

// 3. Per-user data: not in a shared cache at all. Rendered at request time
//    inside a Suspense boundary.
async function MyDrafts() {
  const client = await createServerClient()
  const drafts = await client.getMyDrafts()
  return (
    <ul id="drafts">
      {drafts.map((d) => (
        <li key={d.id}>{d.title}</li>
      ))}
    </ul>
  )
}

export default async function Page() {
  const { posts, generatedAt } = await getPublicPosts()
  return (
    <main>
      <p id="generated">{generatedAt}</p>
      <ul id="posts">
        {posts.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
      <Suspense fallback={<p>Loading greeting…</p>}>
        <Greeting />
      </Suspense>
      <Suspense fallback={<p>Loading your drafts…</p>}>
        <MyDrafts />
      </Suspense>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

The test results: without a session cookie the drafts list was empty. With session=alice it contained Draft for alice. The public posts and their timestamp were identical in both responses.

Why the Suspense boundaries matter. With Cache Components, reading cookies() outside <Suspense> blocks prerendering. This page fails the build:

// app/nosuspense/page.tsx — fails the build with Cache Components
import { cookies } from 'next/headers'

export default async function Page() {
  const session = (await cookies()).get('session')?.value
  return <p>{session ?? 'anonymous'}</p>
}
Enter fullscreen mode Exit fullscreen mode
Error: Route "/nosuspense": 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

Wrapping the cookie-reading component in <Suspense> (as Greeting and MyDrafts are above) is the "[stream]" option. The static shell, which includes the cached public posts, is served right away, and the personal parts stream in.

Pattern 4: 'use cache: private' when you can't refactor

Sometimes the cookie read is buried in code you can't change easily. 'use cache: private' allows cookies(), headers() and searchParams inside the cached scope:

// app/private/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'

async function getDashboard() {
  'use cache: private'
  cacheLife({ stale: 60 })
  const session = (await cookies()).get('session')?.value ?? 'guest'
  return { session, at: new Date().toISOString() }
}

async function Dashboard() {
  const { session, at } = await getDashboard()
  return <p id="dash">{session} @ {at}</p>
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Dashboard />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

The important thing is what "private" means. From the docs: "In production, matching calls within one request can reuse the same result, but Next.js does not store it in a server cache across production requests." The client router can keep the output in browser memory for the stale time.

The test confirmed this. Under next start, consecutive requests with the same session=alice cookie got a different at timestamp every time. So 'use cache: private' deduplicates within a request and feeds the browser's router cache. It does not make repeated server requests cheaper. If you need that, use Patterns 1–3.

Other limits from the docs:

  • connection() is still not allowed inside it.
  • You can't configure custom cache handlers for it.
  • Its stale time feeds the route's client cache. It must be at least 30 seconds for per-link prefetching and at least 5 minutes to be included in the route's App Shell.

Choosing a pattern

Your data Pattern
Same for everyone 1: cookie-free client inside 'use cache'
Varies by a small, known set of values from a cookie/header 2: read outside, normalize, pass as argument
Specific to the signed-in user 3: uncached, in a component wrapped in <Suspense>
Per-user, and the cookie read can't be moved 4: 'use cache: private', but no cross-request server cache

Common mistakes and troubleshooting

  • "My function doesn't call cookies()." Something it calls does. Check the stack trace. The first frame is the actual cookies() call, and the next frames show the path back to your cached function.
  • The build passes, production throws. On dynamically rendered routes, the error shows up when the route runs. Exercise the route under next start before deploying.
  • Passing the whole cookie store or headers() object. Arguments must be serializable, and class instances aren't. Extract the plain values you need.
  • Passing unbounded values. Each distinct argument creates a cache entry. Validate and normalize anything that comes from the client.
  • Using React.cache to smuggle values in. React.cache is isolated inside 'use cache' boundaries. Values set outside aren't visible inside. Use arguments.
  • Module-level promise caches. The error page notes that a cached function can stall if it awaits a promise stored outside the function (for example, in a module-scoped Map). 'use cache' already deduplicates calls with the same arguments, so remove the extra layer.
  • Draft Mode. You can read draftMode().isEnabled inside 'use cache', but cookies() and headers() are still off-limits, even in draft mode.

Limitations and alternatives

  • With the default in-memory cache handler on serverless platforms, runtime cache entries "typically don't persist across requests", although build-time caching works normally. Self-hosted servers keep entries in memory. If you need a shared runtime cache, look at 'use cache: remote', which uses a platform-provided handler and has network and cost trade-offs.
  • Cache entries don't survive a new deploy, because the build ID (or deploymentId) is part of the key.
  • Cache Components needs the Node.js runtime and doesn't support the runtime route segment config. Remove export const runtime = ... from routes when you adopt it.
  • Don't wait for plain 'use cache' to start accepting cookies()/headers(). A reply in discussion #82873 (August 2025) says "There's no plan to allow use cache to access cookies/headers" and suggests using 'use cache' as much as possible and the private variant only rarely. The current docs take the same position.

Conclusion

The error is Next.js protecting you from serving one user's data to another. Most of the time the fix is structural. Keep public reads on a cookie-free client inside 'use cache'. Read cookies at the edge of your component tree, normalize the values, and pass them in as arguments. Keep personal data uncached behind <Suspense>. Use 'use cache: private' only when you can't move the cookie read, and remember that it doesn't give you a cross-request server cache.

Next step: search your codebase for helpers that call cookies() or headers() and check whether any of them run below a 'use cache' boundary. Then run the affected routes under next start, not just next build.

References

Top comments (0)