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
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(), anddraftMode()fromnext/headers - the
paramsprop inlayout.js,page.js,route.js,default.js, and the metadata image files (opengraph-image,twitter-image,icon,apple-icon) - the
searchParamsprop inpage.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;
// ...
}
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. 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;
// ...
}
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>;
}
This only applies to params/searchParams — cookies() 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');
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.
}
},
},
}
);
}
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@canary 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
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')
}
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
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
- 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
- Hub: Next.js + Supabase: The Complete Resource Hub
- cookies() Returns a Promise in Next.js 15/16: the two-minute fix version
- Next.js App Router Folder Structure at Scale
- Supabase Auth Complete Session Middleware Guide
- Next.js + Supabase SSR Session Management
- Next.js Hydration Mismatch Error: Exact Fixes for App Router and React 19
- How to get query string parameters in Next.js
- Next.js Redirect from / to another page
Originally published at https://www.iloveblogs.blog
Top comments (0)