· nextjs · react · webdev · —
Next.js 16 Made Request Data Async. Here's the Mental Model.
In Next.js 16, params, searchParams, cookies(), and headers() all return promises. It's a one-line syntax change with a real idea behind it, and the idea makes the whole migration obvious.
Upgrade to Next.js 16 and the first thing that breaks is almost always the same. You reach for params.slug and TypeScript tells you slug doesn't exist on Promise<...>. Request data went async: params, searchParams, cookies(), headers(), and draftMode() all hand back promises now, and you await them.
It looks like busywork. It isn't. There's a real idea underneath, and once you see it the migration stops feeling like a chore and starts feeling like the framework saying out loud what was always true.
Why request data can't be known ahead of time
Here's the model. Next wants to do as much work as possible before a request arrives: prerender the static shell, then stream the dynamic parts in as they resolve. But params, searchParams, cookies, and headers only exist because someone made a specific request. They're inherently late-binding.
Keeping them synchronous forced Next to pretend it knew request-time data at plan time. Making them async tells the truth: this value depends on the request, so await it, and everything up to that await can be prepared in advance. That's what makes partial prerendering possible, because the static parts no longer have to block on the dynamic ones.
So the rule of thumb is short. If a value can only be known once a real request exists, it's now a promise.
Awaiting dynamic route params
The most common edit. Before:
// Next.js 15 and earlier
export default function Page({ params }: { params: { slug: string } }) {
return <Article slug={params.slug} />
}After:
// Next.js 16
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <Article slug={slug} />
}Two things moved: the type became Promise<...>, and the component became async. generateMetadata takes the same shape:
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return { title: post.title }
}searchParams, and why it's loosely typed
Same pattern, one wrinkle. searchParams is loosely typed because query strings are whatever the user typed into the URL:
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { q } = await searchParams
const query = typeof q === "string" ? q : ""
const results = await search(query)
return <Results items={results} />
}Reading searchParams is what marks a route as dynamic, so await it only where you actually need it instead of reflexively at the top of every page.
cookies() and headers() are request data too
Both come from next/headers, and both are async now:
import { cookies, headers } from "next/headers"
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
const headerList = await headers()
const userAgent = headerList.get("user-agent")
return Response.json({ token, userAgent })
}The model holds: a cookie is request data, so it's a promise. draftMode() moved the same way.
The three errors you'll actually hit
Almost every migration failure falls into one of these buckets:
Property 'x' does not exist on type 'Promise<...>'. You forgot theawait. Add it, and mark the enclosing functionasyncif it wasn't already.- A Client Component reading
params. Client components can'tawaitin the body, so pass the resolved value down as a prop from a Server Component, or unwrap the promise on the client with React'suse()hook. - A helper that expected the old synchronous object. Update its signature to take the resolved type, and await at the call site before you pass it in.
You don't have to grind through this by hand. The official codemod clears the mechanical bulk:
npx @next/codemod@latest next-async-request-api .It won't catch every hand-rolled helper, but it handles the boilerplate and leaves you the judgment calls.
One more thing: Turbopack is now the default
Next.js 16 also promotes Turbopack to the default bundler for both dev and build. Most of the time you'll notice it only as faster cold starts. If you were relying on custom webpack configuration, that's the other thing to check on your way through, and it's a separate migration from the async APIs, so keep the two mentally distinct.
The async change is small in diff and large in intent. It's the framework being honest about when data actually exists. Internalize that, and you stop memorizing which functions to await. You just know.
For the full list of changes and codemods, the Next.js documentation walks through each edge case.