Skip to content

Fix: Next.js 15 cookies() Should Be Awaited — Route Used cookies, Cannot Modify Errors, and Library Mismatch

FixDevs ·

Part of:  React & Frontend Errors

Quick Answer

Fix Next.js 15 async cookies() and headers() errors — 'Route used cookies', 'Cookies can only be modified in a Server Action or Route Handler', codemod misses, library compatibility, and TypeScript type mismatches after upgrade.

What Broke When You Upgraded

I upgraded a 200-route production Next.js app from 14 to 15 last quarter and the codemod handled roughly 80 percent of my cookies() and headers() call sites cleanly. The other 20 percent turned into a one-week migration project, because the codemod cannot see what it cannot statically analyse: library code calling cookies() under the hood, dynamic property access through helpers, conditional reads inside try blocks, and middleware patterns that use NextRequest.cookies instead of the runtime cookies() API. None of those are covered by the codemod and all of them broke production until I tracked them down.

If you have just upgraded, you are probably staring at one of these:

Error: Route "/dashboard" used `cookies().get('session')`. `cookies()` should be awaited before using its value.
Error: Cookies can only be modified in a Server Action or Route Handler.
Error: Route "/api/log" used `headers().get('x-trace-id')`. `headers()` should be awaited before using its value.
TypeError: cookies(...).get is not a function

The first two are runtime errors that surface only on production builds. The third is the same error pattern but for headers(). The last is a TypeScript-friendly variant where the library code did not pick up the new return type and the runtime fails on a method that no longer exists on a Promise.

The shared root cause is that cookies(), headers(), draftMode(), and the request-bound params / searchParams all became asynchronous in Next.js 15. The Next docs talk about this as if it were one migration. In practice the cookies/headers half is harder than the params half, because cookies and headers get called from inside every authentication, analytics, feature-flag, and observability library on the planet, and those libraries upgrade on their own schedules.

For the params should be awaited variant of this issue see Next.js params should be awaited, which covers the same async contract from the dynamic-route side.

How Next.js 15 Decides When cookies() Needs await

The async contract for cookies() and headers() is part of the Partial Pre-Rendering rendering model. The principle is simple. A page is split into a static shell that can be pre-rendered at build time and dynamic sections that need request data. Reading a cookie is a dynamic operation by definition: the value depends on the request. So cookies() returns a Promise, and the act of awaiting it is what marks a boundary in the React tree as dynamic. The static shell renders without waiting; the dynamic boundary suspends on the first await and streams in once cookies resolve.

This design choice has a non-obvious consequence for where cookies() calls are legal. There are now three call site classes:

  1. Read-only contexts: Server Components, generateMetadata, generateStaticParams. You can await cookies() and read values. You cannot mutate the cookie store.
  2. Read-write contexts: Server Actions, Route Handlers. You can both read and call cookieStore.set(...) / delete(...).
  3. Middleware: still uses NextRequest.cookies and NextResponse.cookies, which are synchronous and unchanged. Middleware never calls the cookies() runtime helper.

The “cookies can only be modified in a Server Action or Route Handler” error fires when you accidentally call .set() from a Server Component context. The codemod does not catch this because the codemod only inserts await. It does not refactor write operations into Server Actions.

Solution 1: Direct cookies() Calls in Your Code

The simplest case is a direct call site that the codemod missed (because it lives inside a callback, a try block, or a conditional). The fix is mechanical.

// Before (Next 14)
import { cookies } from 'next/headers';

export default function Page() {
  const session = cookies().get('session')?.value;
  return <div>Welcome {session}</div>;
}
// After (Next 15)
import { cookies } from 'next/headers';

export default async function Page() {
  const cookieStore = await cookies();
  const session = cookieStore.get('session')?.value;
  return <div>Welcome {session}</div>;
}

Two style choices come up here that I have strong opinions on.

Whether to assign the cookie store to a variable. The Next codemod prefers (await cookies()).get('session') which inlines the await. I push back on that in code review for any function that reads more than one cookie, because each (await cookies()) is a fresh await even though the underlying object is the same. The compiler is smart enough to fold these in most cases, but the code reads as if you are awaiting once per read. Assign once, read many times.

Whether to make the function async or use React.use. Server Components can be async, so I make them async. Client Components cannot, so I reach for React.use(cookies()) instead. The codemod handles this distinction correctly for the page component but misses it for helper functions imported into the component, which I had to fix by hand.

Solution 2: cookieStore.set() Outside Server Actions

This is the error I spent the most time on during my upgrade. Code that worked in 14 by calling cookies().set(...) from a Server Component context now throws:

Error: Cookies can only be modified in a Server Action or Route Handler.

The fix is structural, not syntactic. You have to move the write into a context that is allowed to mutate cookies.

// Before — sets a cookie inside a Server Component (allowed in 14, throws in 15)
import { cookies } from 'next/headers';

export default async function Page() {
  const cookieStore = await cookies();
  if (!cookieStore.get('visit-id')) {
    cookieStore.set('visit-id', crypto.randomUUID());
  }
  return <Layout />;
}
// After — set the cookie inside middleware (which still uses NextResponse.cookies)
// middleware.ts
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  if (!request.cookies.get('visit-id')) {
    response.cookies.set('visit-id', crypto.randomUUID());
  }
  return response;
}

Or move the write into a Server Action triggered from a form. The general rule I now follow: any cookie that needs to be set on first visit, or whose value depends on the request, belongs in middleware. Any cookie set in response to user action belongs in a Server Action or Route Handler. Server Components are read-only with respect to cookies.

For middleware that actually fails to run, see Next.js middleware not running, which covers the matcher and bundler edge cases that are independent of the async cookies migration.

Solution 3: searchParams and Other Async APIs Bundled With cookies

The codemod groups all the now-async APIs together but its replacement is uneven across them. In my experience the order of “least missed” to “most missed” is roughly: paramssearchParamscookiesheadersdraftMode. By the time it gets to headers() the codemod is reliable for direct call sites but fragile for everything else.

searchParams deserves a special mention. A pattern I see often:

// Before
export default function SearchPage({ searchParams }: { searchParams: { q: string } }) {
  return <Results query={searchParams.q} />;
}
// After
type Props = { searchParams: Promise<{ q: string }> };
export default async function SearchPage({ searchParams }: Props) {
  const { q } = await searchParams;
  return <Results query={q} />;
}

The codemod adds the await but does not always update the type. After the codemod runs I do a sweep through every file it touched looking for Promise<{ in the type position. Anywhere it is missing is a TypeScript landmine that will fire the first time someone touches the file.

Solution 4: Library Code That Calls cookies() Internally

The codemod only modifies code in your repository. It does not touch node_modules. If your auth library, analytics SDK, feature flag client, or observability tooling calls cookies() internally and has not shipped a Next 15 compatible version, you will see the runtime error from inside the library’s stack trace.

The libraries I had to upgrade for my migration:

LibraryMinimum compatible versionSymptom before upgrade
next-auth (Auth.js v5)5.0.0-beta.22cookies().get is not a function during session check
Sentry8.30.0Transaction names missing on requests; cookie context dropped
LaunchDarkly Next SDK0.10.0Flag evaluation returned defaults on every request
PostHog Next.js helper1.180.0Cookie-based identity stitching broke
Honeybadger Next.js helper6.0.0Same as Sentry, no cookie context
Custom internal libraries(rewrite)Pick your poison

When I am triaging an error in a library context, the first question is whether the library has shipped a Next 15 release. If it has, the fix is npm install. If it has not, my options are to pin to Next 14 until the library catches up, write a thin local fork, or replace the library. I picked all three for different libraries during my migration; no single strategy was correct everywhere.

For more on the npm side of upgrade churn see npm WARN deprecated, which goes into the differences between direct and transitive deprecations that surface during major framework upgrades.

Solution 5: TypeScript Type Mismatch After Upgrade

Even after the runtime fix is in place, TypeScript can still be wrong. The next/headers exports’ types changed in 15. If your editor or tsc cache holds onto the old types, you will see one of two patterns.

Type is still inferred as the synchronous shape. Properties like .get and .set appear directly on cookies() return value. This means a stale node_modules/next/dist/server/...d.ts is being read.

# Force a clean type resolution
rm -rf node_modules .next
npm install

In VS Code, restart the TypeScript server after this: Cmd-Shift-P then “TypeScript: Restart TS Server”. JetBrains: invalidate caches and restart.

Type is Promise<ReadonlyRequestCookies> but you wrote it as the old shape. The fix is to update your local type annotations, not just the call site. I keep grep handy during the upgrade:

grep -r "cookies\(\): " src/    # old return type annotations
grep -r "{ params: { " src/     # old params type annotations

For broader TypeScript module resolution issues that look like the above but are actually tsconfig misconfiguration, see TypeScript Cannot find module.

These are the cookies/headers failure modes I personally tracked down during my Next 15 upgrade that I have not seen written up.

Server Action returns trigger a re-render that reads stale cookies. After a Server Action calls cookieStore.set(...) the Server Component that re-renders sees the OLD cookie value, not the new one. This is a known limitation: the action sets the cookie on the response, but the same response carries the rendered HTML that was already computed against the pre-set cookie. Force a revalidatePath() or redirect() at the end of the action to make the next render see the new value.

Test environments mocking cookies() need to return a Promise. If your test setup stubs next/headers to return a synchronous object, the production code calls .get() on a Promise and throws get is not a function. Update the mock to return Promise.resolve(realCookieStore). I have seen test suites pass locally and fail in CI because the local dev server fell back to the old sync behavior via the dev warning while CI used the production build.

Edge runtime cookie size limits surface during the migration. Setting a cookie larger than 4KB on the Edge runtime silently truncates it. This was true in 14 too but I rediscovered it during the 15 migration because more of my cookie reads were now genuinely round-tripping. If your auth library serializes a JWT bigger than 4KB into a cookie, the symptom looks like cookies-are-not-persisting after a Server Action set; the real cause is Edge truncation.

Server Component caching can hide cookie reads. A Server Component that reads cookies and returns a value cached by unstable_cache will return the OLD cookie value on subsequent requests. The fix is to either not cache, or cache against the cookie value as a key. I learned this one by deploying a feature flag based on a cookie and watching it serve the same flag to every user for an hour.

Middleware setting a cookie that a Server Component reads in the same request. The cookie set in middleware is on the OUTGOING response. The Server Component in the same render cycle reads from the INCOMING request and does not see it. The cookie shows up on the next request. To work around this I append the cookie to both the request (so the SC sees it on this render) and the response (so the browser receives it).

// middleware.ts
const visitId = crypto.randomUUID();
request.cookies.set('visit-id', visitId);   // visible to Server Components this render
const response = NextResponse.next();
response.cookies.set('visit-id', visitId);  // shipped to the browser
return response;

Production builds reveal errors that dev mode masked. Dev mode in Next 15 still emits a warning rather than an error for many sync cookie reads, partly for compatibility with older libraries. Production builds enforce the contract. If your CI does not include a next build && next start step that exercises real routes, the divergence between dev and prod will land on you in production. I run a tiny smoke-test job that hits every dynamic route with curl after next start specifically to catch this.

For the broader category of production-only Next.js errors, see Next.js 500 internal server error, which goes into how to surface the underlying stack traces that production hides by default.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles