← Back to Fixes

cookies() Returns a Promise in Next.js 15/16: Fix

cookies() from next/headers returns a Promise since Next.js 15 — change const c = cookies() to const c = await cookies() and make the caller async.

· Updated

The patch is two edits per call site: change const store = cookies() to const store = await cookies(), and mark the enclosing function async. That is the entire fix for cookies() should be awaited — the rest of this page is the fast diagnostic (find every call site), the minimal patch patterns for each App Router surface, and the verification pass that catches the call site you missed.

If you upgraded a Next.js app to 15 or 16 and your logs suddenly filled with Error: Route "/dashboard" used cookies().get(...). cookies() should be awaited before using its value, nothing in your code is broken in the way the message suggests. Next.js changed the contract of cookies() — and the same change applies to headers(), draftMode(), and the params/searchParams props passed to pages and layouts. This page is the express repair; for the full migration story — why the API went async, the next-async-request-api codemod, Client Component React.use() unwrapping, and the Supabase createServerClient factory pattern — see the companion deep dive: Fix "cookies() should be awaited" Error in Next.js 15.

What actually changed#

In Next.js 14, cookies() from next/headers returned a ReadonlyRequestCookies object synchronously. You could call .get() on it immediately. In Next.js 15 the function became asynchronous: it now returns a Promise, and you have to await it before touching .get(), .getAll(), .has(), or .set().

This was a deliberate move. Making these request-scoped APIs async lets the framework start rendering the static shell of a route before the dynamic, per-request data (cookies, headers) is resolved. It is the groundwork for Partial Prerendering. The catch is that the codemod does not always reach every call site, and a synchronous .get() on the returned promise fails in a way that looks like an auth bug rather than a syntax change.

Why it looks like a session bug#

In Next.js 15, un-awaited cookies().get(...) mostly still works: the docs say sync access is kept "to help with backwards compatibility", so a shim answers the call and logs the warning instead of crashing. That compatibility is exactly what makes the bug sneaky — the app appears fine while every request spams the log, and the access patterns the shim does not cover (spreading, iterating, passing the store around before it resolves, and TypeScript treating the return value as Promise<ReadonlyRequestCookies>) surface as undefined session values instead of a clean error. A session lookup that returns null means middleware treats the user as logged out, and you get redirects to /login or empty SSR pages even though the cookie is present in the request. That is why this shows up as "auth broke after the upgrade" rather than a syntax error — and why the sync grace period is a migration window, not a fix.

The fix#

Await the call, and make the enclosing function async:

ts
// ❌ Next.js 14 pattern — throws in 15/16
import { cookies } from "next/headers";
 
export function getToken() {
  return cookies().get("sb-access-token")?.value;
}
ts
// ✅ Next.js 15/16
import { cookies } from "next/headers";
 
export async function getToken() {
  const cookieStore = await cookies();
  return cookieStore.get("sb-access-token")?.value ?? null;
}

The single most effective change is to centralize cookie access in one helper and await it there. Mixed patterns — one route awaits, another does not — are the hardest version of this bug to track down, because half your app works.

ts
// lib/session.ts
import { cookies } from "next/headers";
 
export async function readSessionCookie(name: string) {
  const store = await cookies();
  return store.get(name)?.value ?? null;
}

Every server component, route handler, and server action then calls await readSessionCookie(...) and there is exactly one place that knows the API is async.

If a call site truly cannot be made async today, Next.js 15 documents a typed escape hatch that keeps the sync behavior while still warning in dev:

ts
import { cookies, type UnsafeUnwrappedCookies } from "next/headers";
 
const cookieStore = cookies() as unknown as UnsafeUnwrappedCookies;
const token = cookieStore.get("sb-access-token")?.value;

The name is the documentation: it is unsafe, temporary, and exists only for backwards compatibility. Treat every occurrence as an open migration ticket.

Writing cookies#

.set() follows the same rule, but it can only be called from a Server Action or a Route Handler — not from a server component during render. If you try to set a cookie inside a page component you will get a different error about mutating cookies during rendering. Move the write into an action:

ts
"use server";
import { cookies } from "next/headers";
 
export async function login(token: string) {
  const store = await cookies();
  store.set("sb-access-token", token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
  });
}

Middleware is the exception#

Inside middleware.js you do not use next/headers. You read cookies from the request object, and that API stayed synchronous:

ts
export function middleware(request) {
  const token = request.cookies.get("sb-access-token")?.value;
  // ...
}

Mixing the two — awaiting in a server component but expecting the middleware API to behave the same — is a common source of "it works in the route but not the redirect" confusion.

Verifying the fix#

Do not trust a clean dev console alone; the async gap can still silently return null in a code path the compiler cannot check. Verify behaviorally:

  1. grep -rn "cookies()\." src app and confirm every hit is preceded by await.
  2. Run a production build (next build && next start), log in, and hard-refresh a server-rendered protected route. The session must survive the refresh.
  3. Check the server logs during that refresh — a lingering cookies() should be awaited line means a call site was missed.

A missed call site in a rarely-hit route is exactly how this reaches production, so the grep-plus-refresh pass matters more than the passing build.

Related fixes & guides