Next.js 16 cookies() Async Error in Route Handlers Fix

Next.js 16 throwing 'cookies() should be awaited' inside your App Router route handlers? Fix the async API and silent Set-Cookie failure with working code.
Next.jsApp RouterBug Fix
May 1, 20265 min read999 words

The Problem

Bumped a SaaS app from Next.js 15.4 to 16.2 over the weekend. Build passed, types passed, tests passed. Pushed to staging and watched the auth flow break in two different ways within five minutes.

First failure was loud. The /api/login route handler threw on every request:

Error: Route "/api/login" used `cookies().set('session', ...)`. `cookies()` should be awaited before using its value.

Fine, await it, deploy, error gone. But then the second failure: login returns 200, the response body says { ok: true }, the network tab shows a Set-Cookie header on the response… and the cookie never lands in the browser. No CORS issue, no SameSite mismatch, nothing in DevTools to suggest why.

If you have just upgraded a route handler that issues cookies and you are seeing either of those symptoms, the API surface changed in two places, not one. Here is what is actually happening.

Why It Happens

Next.js 15 made cookies(), headers(), and draftMode() async-compatible but kept the synchronous shape working. In 16, the synchronous shape is removed for dynamic APIs, and on top of that the cookies API in route handlers no longer mutates the outgoing response.

Two distinct things, often hit together.

Thing 1: cookies() returns a Promise. Reading a cookie is now (await cookies()).get('foo') or const cookieStore = await cookies(); cookieStore.get('foo'). Calling .get() directly on the unresolved promise gives you TypeError: cookies(...).get is not a function in production, and the friendlier "should be awaited" message in development. Same for .set(), .delete(), .has().

Thing 2: cookies().set() no longer attaches to the route handler response. This is the one that catches everyone. In 14 and early 15, you could write cookies().set('session', token) inside a route handler and Next.js would copy that mutation onto the outgoing Response. As of 16.0, route handlers must use the NextResponse cookies API to mutate the response. Calling (await cookies()).set() inside a route handler succeeds without error, but the mutation is scoped to the request context, never serialised to a Set-Cookie header, and never reaches the browser. There is no warning. The cookie just silently does not get set.

The split is: read with await cookies(), write with response.cookies.set() in route handlers, write with (await cookies()).set() only in Server Actions and Server Components that opt into dynamic.

The Fix

Step 1: Update every read. Find every call to cookies(), headers(), and draftMode() and await them:

// app/api/me/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'

export async function GET() {
  const cookieStore = await cookies()
  const session = cookieStore.get('session')?.value

  if (!session) {
    return NextResponse.json({ user: null }, { status: 401 })
  }

  const user = await getUserFromSession(session)
  return NextResponse.json({ user })
}

If you have a lot of these, run a codemod first:

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

The codemod handles the obvious cases. It does not catch dynamic property access (cookies()[name]) or destructured calls (const { get } = cookies()), so grep for those manually.

Step 2: Switch route handler writes to NextResponse. The fixed login handler:

// app/api/login/route.ts
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const { email, password } = await req.json()
  const session = await authenticate(email, password)

  if (!session) {
    return NextResponse.json({ ok: false }, { status: 401 })
  }

  const response = NextResponse.json({ ok: true })

  response.cookies.set({
    name: 'session',
    value: session.token,
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  })

  return response
}

response.cookies.set() writes to the actual Set-Cookie header on the outgoing response. This is the only API that works in a route handler in 16.

Step 3: Server Actions still use cookies().set(). This is the part of the migration that confuses people. The async API plus mutation works in Server Actions and pages with dynamic = 'force-dynamic'. The route handler is the special case.

// app/actions/login.ts
'use server'

import { cookies } from 'next/headers'

export async function login(formData: FormData) {
  const session = await authenticate(
    formData.get('email') as string,
    formData.get('password') as string,
  )
  if (!session) return { ok: false }

  const cookieStore = await cookies()
  cookieStore.set('session', session.token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  })

  return { ok: true }
}

This works because Server Actions run inside a request context that Next.js threads cookie mutations through automatically. Route handlers do not get that thread.

Step 4: Catch the silent failure in CI. Add an integration test that hits the login route and asserts the Set-Cookie header is on the response:

test('login sets session cookie', async () => {
  const res = await fetch('http://localhost:3000/api/login', {
    method: 'POST',
    body: JSON.stringify({ email: 'test@example.com', password: 'pw' }),
  })
  expect(res.headers.get('set-cookie')).toMatch(/^session=/)
})

The cookie not being set is the kind of bug that ships to production because it does not throw. Test for the header explicitly. Same applies to logout (response.cookies.delete()). The official Next.js cookies docs cover the full surface area.

The Lesson

In 16, await every dynamic API read, and use NextResponse for every cookie write inside a route handler. The migration looks like a one-line await change, but the silent route-handler write is the bug that actually breaks auth in production.

If your Next.js upgrade left auth or sessions broken in subtle ways and you need someone to rebuild the cookie layer cleanly, see my services. For other 16 upgrade traps, the server actions invalid action error fix covers the related Server Actions migration.

Back to blogStart a project