Logo
Published on

cookies() and headers() Are Async Now: The Full await Migration Guide

Authors

The change

In Next.js 15, cookies() and headers() from next/headers became asynchronous. They return a Promise instead of a value directly. In Next.js 16 this is no longer optional. If you call them without awaiting, your code does not work.

// Before (Next.js 14 and earlier)
import { cookies } from 'next/headers'

export default function Page() {
  const cookieStore = cookies()
  const token = cookieStore.get('token')
  return <p>{token?.value}</p>
}

// After (Next.js 15 and 16)
import { cookies } from 'next/headers'

export default async function Page() {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')
  return <p>{token?.value}</p>
}

The codemod that ships with Next.js 16 tries to add await where it can. But it misses a lot of cases. If you access cookies or headers in a helper function, a custom hook, or a wrapper component, the codemod does not know about it. You have to fix those manually.

This guide covers every pattern I have seen in real codebases and the fix for each.

Why they did this

The short version is that cookies() and headers() read from the request context. In the App Router, request data is only available during the request, not during the render phase. Making these functions async lets Next.js defer the read until the point where the request context is actually available.

This matters for Partial Prerendering. With PPR, the static parts of your page render at build time. The dynamic parts (which use cookies and headers) render at request time. If cookies were synchronous, the whole page would have to wait for the request context, defeating the purpose of PPR.

Making them async lets React suspend on the cookie read and only resolve it during the dynamic part of the render. The static parts do not need to wait.

It is a good architectural change. It is also annoying to migrate. Let me make it less annoying.

Pattern 1: Direct call in a page or layout

This is the simplest case and the one the codemod handles.

// Before
import { cookies } from 'next/headers'

export default function Page() {
  const cookieStore = cookies()
  const theme = cookieStore.get('theme')?.value
  return <p>Theme: {theme}</p>
}

// After
import { cookies } from 'next/headers'

export default async function Page() {
  const cookieStore = await cookies()
  const theme = cookieStore.get('theme')?.value
  return <p>Theme: {theme}</p>
}

Two changes: add await before cookies() and add async to the function. The codemod does both of these.

Pattern 2: Call in a helper function

The codemod does not touch helper functions. You need to make the helper async and await the call inside it. Then every caller of the helper also needs to be async and await the helper.

// Before
import { cookies } from 'next/headers'

export function getCurrentUser() {
  const cookieStore = cookies()
  const token = cookieStore.get('token')?.value
  if (!token) return null
  return verifyToken(token)
}

// After
import { cookies } from 'next/headers'

export async function getCurrentUser() {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')?.value
  if (!token) return null
  return verifyToken(token)
}

Now every component that calls getCurrentUser needs to await it:

// Before
import { getCurrentUser } from '@/lib/auth'

export default function Page() {
  const user = getCurrentUser()
  return <p>Hello {user.name}</p>
}

// After
import { getCurrentUser } from '@/lib/auth'

export default async function Page() {
  const user = await getCurrentUser()
  return <p>Hello {user.name}</p>
}

This cascades through your whole app. If getCurrentUser is called by getDashboardData which is called by Page, all three need to be async and awaited. The codemod cannot trace this chain. You have to follow it manually.

Pattern 3: Call in a Server Action

Server Actions can also use cookies() and headers(). Same migration.

// Before
'use server'

import { cookies } from 'next/headers'

export function addToCart(productId: string) {
  const cookieStore = cookies()
  const cartId = cookieStore.get('cartId')?.value
  return db.cart.add({ cartId, productId })
}

// After
'use server'

import { cookies } from 'next/headers'

export async function addToCart(productId: string) {
  const cookieStore = await cookies()
  const cartId = cookieStore.get('cartId')?.value
  return db.cart.add({ cartId, productId })
}

Server Actions are already async in most cases, so you just need to add await before cookies().

Pattern 4: Call in a Route Handler

Route Handlers also need the migration.

// Before
import { cookies } from 'next/headers'

export function GET() {
  const cookieStore = cookies()
  const token = cookieStore.get('token')?.value
  if (!token) {
    return new Response('Unauthorized', { status: 401 })
  }
  return Response.json({ data: 'protected' })
}

// After
import { cookies } from 'next/headers'

export async function GET() {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')?.value
  if (!token) {
    return new Response('Unauthorized', { status: 401 })
  }
  return Response.json({ data: 'protected' })
}

Route Handlers are usually already async, so this is a small change.

Pattern 5: Call in middleware (now proxy)

In Next.js 16, middleware is now proxy.ts. The cookies() and headers() functions from next/headers do not work in proxy files. Proxy files use request.cookies and request.headers from the NextRequest object instead.

// proxy.ts (formerly middleware.ts)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const token = request.cookies.get('token')?.value
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  return NextResponse.next()
}

This did not change. Proxy files always used the request object directly. If you were importing cookies from next/headers in your middleware, that was already wrong and now it will actually break.

Pattern 6: headers() in generateMetadata

generateMetadata can use headers() to read request headers. Same migration.

// Before
import { headers } from 'next/headers'

export function generateMetadata() {
  const headersList = headers()
  const userAgent = headersList.get('user-agent')
  return {
    title: isBot(userAgent) ? 'Bot Page' : 'User Page'
  }
}

// After
import { headers } from 'next/headers'

export async function generateMetadata() {
  const headersList = await headers()
  const userAgent = headersList.get('user-agent')
  return {
    title: isBot(userAgent) ? 'Bot Page' : 'User Page'
  }
}

The codemod catches some of these but not all. If your generateMetadata is in a layout file or uses a wrapper, check it manually.

Pattern 7: draftMode()

draftMode() also became async. Same fix.

// Before
import { draftMode } from 'next/headers'

export default function Page() {
  const draft = draftMode()
  if (draft.isEnabled) {
    return <PreviewContent />
  }
  return <LiveContent />
}

// After
import { draftMode } from 'next/headers'

export default async function Page() {
  const draft = await draftMode()
  if (draft.isEnabled) {
    return <PreviewContent />
  }
  return <LiveContent />
}

People forget about draftMode() because it is less common. But it follows the same pattern.

Common errors you will see

"cookies should be awaited"

Error: In Next.js 15 and later, `cookies()` returns a Promise.
You should await it before accessing its value.

This is the error you get when you forget to await. The fix is always the same: add await and make the containing function async.

Property 'get' does not exist on type Promise of ReadonlyRequestCookies

This is the TypeScript error you get when you call .get() on the Promise instead of the resolved value.

// Wrong: calling .get() on the Promise
const token = cookies().get('token')

// Right: await first, then call .get()
const cookieStore = await cookies()
const token = cookieStore.get('token')

"Cannot access cookies() outside of a Server Component"

cookies() only works in Server Components, Server Actions, and Route Handlers. If you call it in a Client Component, you get this error.

'use client'

import { cookies } from 'next/headers'

// Error: cookies() cannot be used in a Client Component
export default function ClientPage() {
  const cookieStore = cookies()
  // ...
}

Fix: Move the cookie read to a Server Component and pass the value as a prop. Or use a Server Action to read the cookie and send the value to the client.

"cookies() was called outside a request scope"

This happens when you try to call cookies() in a function that is not part of the request lifecycle, like a utility function that runs outside of a component.

// This breaks if called outside a request
export async function getTheme() {
  const cookieStore = await cookies()
  return cookieStore.get('theme')?.value
}

// This is fine: called from a Server Component
export default async function Page() {
  const theme = await getTheme()
  return <p>Theme: {theme}</p>
}

// This breaks: called from a script at module load time
const defaultTheme = getTheme() // error

Fix: Make sure cookies() is only called during a request. That means inside a Server Component, a Server Action, or a Route Handler. Not at module level, not in a utility that runs outside a request.

A migration strategy

If you have a large codebase, do not try to migrate everything at once. Here is how I approach it.

Step 1: Run the codemod. It handles the easy cases. Run npx @next/codemod@canary upgrade and let it do its thing.

Step 2: Run the build. The TypeScript errors will show you every place you forgot to await. Go through them one by one.

Step 3: Search for helper functions. Grep for cookies( and headers( in your lib and utils directories. These are the ones the codemod missed.

Step 4: Make helpers async and cascade. For each helper that uses cookies or headers, make it async. Then find every caller and make it async. Then find every caller of those callers. Follow the chain until you reach a Server Component, Server Action, or Route Handler.

Step 5: Test. Click through every page that uses auth or personalization. Check that cookies are being read correctly. Check that headers are being read correctly.

The migration is tedious but mechanical. There are no conceptual changes, just async and await everywhere. Set aside a few hours, put on a podcast, and grind through it.

I fix Next.js production issues for a living. If you are stuck on the cookies/headers migration and want someone to do it for you, email me.