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.
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:
// 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:
// 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#
// 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#
'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:
// 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:
createClientis itself async — the factory awaits cookies once, then the inner closures use the resolved store synchronously.setAllswallows 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();
Run the codemod#
npx @next/codemod@latest next-async-request-api .
It rewrites simple sync call sites automatically:
cookies()→await cookies()(and marks the enclosing functionasync)- Same for
headers()anddraftMode() - Adds the necessary
asynckeywords up the call chain when safe
What it won't fix:
- Custom factories like the Supabase
createClientabove - 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#
# 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:
// 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#
- Is the call site
await-ed? Most warnings are literally one missing keyword. - Is the enclosing function
async? If TypeScript complains aboutawaitoutside async, that's the cause. - Does
awaitwork in your Server Component? It must — if you see "await is not allowed here," you accidentally put it in a Client Component ('use client'). - For Supabase SSR: did you upgrade
@supabase/ssrto0.5+? Older versions don't support the async pattern cleanly. - 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#
Frequently Asked Questions
Continue Reading
Fix Next.js `revalidatePath` Not Working in Server Actions (6 Production Causes + Cheat Sheet 2026)
Your Server Action mutates data but the page shows stale values until you hard-refresh. `revalidatePath` is one of those APIs that "succeeds" while doing nothing. Here are the six reasons it no-ops, with the exact fix for each — including the one nobody tells you about: `dynamic = 'force-static'`.
10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)
Avoid these critical mistakes when building with Next.js and Supabase. Learn from real-world errors that cost developers hours of debugging and discover proven solutions.
Stripe Webhook Signature Verification Failed in Next.js (Production Fix + Retry Strategy 2026)
If Stripe webhooks return `Webhook signature verification failed`, your Next.js route is parsing the JSON before Stripe sees it. Here's the exact raw-body pattern for App Router, Pages Router, and Vercel Edge — plus the three secret-mismatch traps that cause the same error.
Browse by Topic
Find stories that matter to you.