Fix "cookies() should be awaited" Error in Next.js 15 App Router (Complete Migration Fix 2026)
Technology

Fix "cookies() should be awaited" Error in Next.js 15 App Router (Complete Migration Fix 2026)

Next.js 15 broke synchronous `cookies().get()`. Every server-side call must now `await cookies()` first. Here's the precise migration — App Router pages, route handlers, Server Actions, and Supabase SSR — plus the codemod that fixes 90% of call sites automatically.

2026-05-19
7 min read
Fix "cookies() should be awaited" Error in Next.js 15 App Router (Complete Migration Fix 2026)

Upgraded to Next.js 15 and now every page console-warns about cookies? You missed one of the biggest breaking changes in the App Router. Two-minute fix.

The problem#

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

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().

Symptoms#

  • 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>'.

Root cause#

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.

Fix — Server Component#

Before:

tsx
// 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;
  // ...
}

After:

tsx
// 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;
  // ...
}

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

Fix — Route Handler#

ts
// 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 });
}

Route handlers were already async — just add the await.

Fix — Server Action#

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

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

Server Actions are always async. Same pattern.

Fix — Supabase SSR (createServerClient)#

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

ts
// 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.
          }
        },
      },
    }
  );
}

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:

tsx
// Before
const supabase = createClient();

// After
const supabase = await createClient();

Run the codemod#

bash
npx @next/codemod@latest next-async-request-api .

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

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

Verification#

bash
# 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 with the eslint rule enabled
next build

Add this ESLint rule to prevent regressions:

js
// eslint.config.mjs
export default [
  {
    rules: {
      '@next/next/no-sync-scripts': 'error',
      // Next.js 15 ships an internal rule that catches sync cookies/headers
      // in App Router files. Enable it explicitly:
      'next/no-sync-dynamic-apis': 'error',
    },
  },
];

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.

Frequently Asked Questions

|

Have more questions? Contact us