DEV Community

Cover image for cookies() Returns a Promise in Next.js 15/16: Fix
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

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

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:

// ❌ Next.js 14 pattern — throws in 15/16
import { cookies } from "next/headers";

export function getToken() {
  return cookies().get("sb-access-token")?.value;
}
Enter fullscreen mode Exit fullscreen mode
// ✅ 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;
}
Enter fullscreen mode Exit fullscreen mode

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.

// lib/session.ts
import { cookies } from "next/headers";

export async function readSessionCookie(name: string) {
  const store = await cookies();
  return store.get(name)?.value ?? null;
}
Enter fullscreen mode Exit fullscreen mode

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:

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

const cookieStore = cookies() as unknown as UnsafeUnwrappedCookies;
const token = cookieStore.get("sb-access-token")?.value;
Enter fullscreen mode Exit fullscreen mode

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:

"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: "/",
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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 Incidents


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

Top comments (0)