DEV Community

Cover image for Fix "cookies() should be awaited" Error in Next.js 15
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Fix "cookies() should be awaited" Error in Next.js 15

The fix is one keyword: cookies() from next/headers returns a Promise in Next.js 15+, so write const cookieStore = await cookies() before calling .get(), .getAll(), .has(), or .set() — and mark the enclosing function async. The same contract change applies to headers(), draftMode(), and the params/searchParams props of pages, layouts, and route handlers.

This article is the full migration: why the API changed, every App Router surface it touches, the next-async-request-api codemod and its blind spots, and the Supabase SSR factory pattern that the codemod cannot rewrite. If you just want the two-minute patch-and-verify version, that lives in the companion fix: cookies() Returns a Promise in Next.js 15/16.

The exact warning Next.js 15 logs

After next@15, the dev server logs:

Route "/dashboard" used `cookies().get('sb-access-token')`. `cookies()` should
be awaited before using its value. Learn more:
https://nextjs.org/docs/messages/sync-dynamic-apis
Enter fullscreen mode Exit fullscreen mode

The page still renders (in 15 the API is sync-compatible with a warning), but in Next.js 16 this turns into a runtime error. Same warning shape applies to headers() and draftMode().

How the warning shows up in a real codebase

  • Console warnings on every server-rendered request mentioning sync-dynamic-apis.
  • The route works locally but you suspect it'll break on upgrade.
  • Supabase SSR throws "Cookies can only be modified in a Server Action or Route Handler" (a related but distinct issue, also fixed below).
  • TypeScript errors after npm i next@15: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'.

Why cookies() became async — and the full list of affected APIs

Up to Next.js 14, cookies(), headers(), and draftMode() returned values synchronously. To support streaming Server Components without forcing Next.js to materialize the request before render starts, the React team made these APIs thenable in 15. Calling .get() on a Promise hits the synchronous compatibility shim that warns you to migrate.

This isn't a bug — it's a deliberate semantic change. The migration path is await before the method call. The Next.js version history is unambiguous: cookies was introduced in v13.0.0, became an async function in v15.0.0-RC, and the docs state that in "version 14 and earlier, cookies was a synchronous function" whose sync access is kept in 15 only "to help with backwards compatibility".

The change is wider than cookies(). Per the sync-dynamic-apis reference, the asynchronous "Dynamic APIs" in Next.js 15 are:

  • cookies(), headers(), and draftMode() from next/headers
  • the params prop in layout.js, page.js, route.js, default.js, and the metadata image files (opengraph-image, twitter-image, icon, apple-icon)
  • the searchParams prop in page.js

The warning also fires on indirect sync access: spreading ({...params}), Object.keys(params), or iterating (for (const cookie of cookies())) all count as synchronous reads.

One more consequence worth knowing before you migrate: cookies() is a Request-time API, so calling it in a page or layout opts that route into dynamic rendering. The docs' advice is to delay unwrapping the Promise (with await or React.use) until you actually consume the value — that lets Next.js statically render more of the page around it.

Fix — Server Component

Before:

// app/dashboard/page.tsx
import { cookies } from 'next/headers';

export default function DashboardPage() {
  const cookieStore = cookies();              // ⚠️ sync
  const token = cookieStore.get('sb-access-token')?.value;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

After:

// app/dashboard/page.tsx
import { cookies } from 'next/headers';

export default async function DashboardPage() {
  const cookieStore = await cookies();        // ✅ awaited
  const token = cookieStore.get('sb-access-token')?.value;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The page function becomes async. Server Components support async natively — no wrapper, no use() hook.

Fix — Route Handler

// app/api/me/route.ts
import { cookies, headers } from 'next/headers';

export async function GET() {
  const cookieStore = await cookies();
  const headersList = await headers();

  const token = cookieStore.get('sb-access-token')?.value;
  const userAgent = headersList.get('user-agent');

  return Response.json({ token: Boolean(token), userAgent });
}
Enter fullscreen mode Exit fullscreen mode

Route handlers were already async — just add the await.

Fix — Server Action

'use server';
import { cookies } from 'next/headers';

export async function logout() {
  const cookieStore = await cookies();
  cookieStore.delete('sb-access-token');
}
Enter fullscreen mode Exit fullscreen mode

Server Actions are always async. Same pattern. Two write-side constraints from the API reference are worth internalizing while you're here: .set() and .delete() only work in a Server Action or Route Handler, because "HTTP does not allow setting cookies after streaming starts" — a Server Component render can read cookies but never write them. And .delete() additionally requires the same domain and protocol as the .set() that created the cookie.

Fix — params and searchParams props

The same Promise contract applies to route props. In an async Server Component, await them:

// app/[id]/page.tsx
export default async function Page(props: {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  const { id } = await props.params;
  const { query } = await props.searchParams;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Fix — Client Component (React.use)

Client Components can't be async, so await is off the table. The documented pattern is unwrapping the Promise with React.use():

'use client';
import * as React from 'react';

export default function Page(props: { params: Promise<{ id: string }> }) {
  const { id } = React.use(props.params);
  return <p>ID: {id}</p>;
}
Enter fullscreen mode Exit fullscreen mode

This only applies to params/searchParamscookies() and headers() are server-only imports and never reach a Client Component in the first place.

Escape hatch — temporary synchronous access

If you need to ship the upgrade before finishing the migration, Next.js 15 documents a typed escape hatch. It still logs a dev warning, and the sync behavior exists only for backwards compatibility, so treat it as a tracked TODO, not a pattern:

import { cookies, type UnsafeUnwrappedCookies } from 'next/headers';

const cookieStore = cookies() as unknown as UnsafeUnwrappedCookies;
// will log a warning in dev
const token = cookieStore.get('token');
Enter fullscreen mode Exit fullscreen mode

UnsafeUnwrappedHeaders and UnsafeUnwrappedDraftMode exist for the other two APIs.

Fix — Supabase SSR (createServerClient)

This is where most upgrades break silently. The Supabase cookies option needs async getters:

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // setAll was called from a Server Component (read-only).
            // This is expected when refreshing the session in a Server
            // Component — middleware will refresh it on the next request.
          }
        },
      },
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things matter:

  1. createClient is itself async — the factory awaits cookies once, then the inner closures use the resolved store synchronously.
  2. setAll swallows the "read-only" throw. Server Components can't write cookies; that throw is expected and your middleware handles refresh on the next request anyway.

Then every call site:

// Before
const supabase = createClient();

// After
const supabase = await createClient();
Enter fullscreen mode Exit fullscreen mode

Run the codemod

npx @next/codemod@canary next-async-request-api .
Enter fullscreen mode Exit fullscreen mode

It rewrites simple sync call sites automatically:

  • cookies()await cookies() (and marks the enclosing function async)
  • Same for headers() and draftMode()
  • Adds the necessary async keywords up the call chain when safe

What it won't fix:

  • Custom factories like the Supabase createClient above
  • Sync utility functions that internally call cookies() and are themselves called from non-async contexts
  • Cases where the function signature change would be a public API break

For anything it can't migrate, the codemod leaves a marker comment instead of guessing:

export function MyCookiesComponent() {
  const c =
    /* @next-codemod-error Manually await this call and refactor the function to be async */
    cookies()
  return c.get('name')
}
Enter fullscreen mode Exit fullscreen mode

These comments are not advisory. Per the docs, if you leave @next-codemod-error comments unaddressed, Next.js errors in both dev and build until you either fix the call site and delete the comment, or explicitly replace the prefix with @next-codemod-ignore to accept the sync behavior. That build-time enforcement is your regression guard — there's no separate ESLint rule to configure.

Review the diff, run npm run typecheck, fix the remaining ~10% by hand.

Verification

# 1. Type-check passes
npm run typecheck

# 2. Dev server logs no sync-dynamic-apis warnings
next dev

# Hit a few routes, watch the terminal:
# Expected: no warnings
# Bad: "Route X used cookies().get()..."

# 3. Build succeeds — remember that any leftover @next-codemod-error
#    comment fails the build by design
next build
Enter fullscreen mode Exit fullscreen mode

There is no ESLint rule to configure for this: the regression guard is the codemod's @next-codemod-error comments, which Next.js enforces at dev and build time until each one is resolved or downgraded to @next-codemod-ignore.

Debug checklist

  1. Is the call site await-ed? Most warnings are literally one missing keyword.
  2. Is the enclosing function async? If TypeScript complains about await outside async, that's the cause.
  3. Does await work in your Server Component? It must — if you see "await is not allowed here," you accidentally put it in a Client Component ('use client').
  4. For Supabase SSR: did you upgrade @supabase/ssr to 0.5+? Older versions don't support the async pattern cleanly.
  5. After migrating, are you accidentally fetching cookies twice per render? await cookies() is cheap but not free — destructure once at the top.

Prevention

  • Pin Next.js minor versions until you've migrated. Auto-updates that cross a major bump bite teams that don't watch the release notes.
  • Read the upgrade guide before bumping — Next.js publishes a per-version migration page. Five minutes of reading saves an hour of debugging.
  • Adopt the lint rule so new code can't reintroduce the sync pattern.
  • Wrap framework primitives behind your own factories where you can — when the next API change lands, you change one file.

The sync-to-async migration is one of the cleanest breaking changes Next.js has ever shipped. Codemod + a half-hour cleanup and you're done.

Related reading


Originally published at https://www.iloveblogs.blog

Top comments (0)