
Next.js 15 made cookies(), headers(), draftMode(), params and searchParams asynchronous, but it kept a synchronous fallback that logged a warning and carried on. Next.js 16 removed that fallback. This post is for developers moving an App Router codebase from Next.js 14 or 15 onto 16, and it covers the part of the upgrade that fails quietly. The Next.js 16 async request APIs do not always break loudly: some old code throws a 500, while other old code passes the build, returns HTTP 200, and renders undefined. Below you will find which is which, measured on a real 16.3.8 build, followed by the migration for pages, layouts, route handlers, Client Components, helpers and metadata files.
What Was Checked, and Against Which Versions
Every version claim here was read from the Next.js docs and the npm registry on the day of writing. The failure behaviour was not taken from the docs. Instead, it came from a small fixture app built and served with the commands shown in each section.
Checked 2026-10-02
Windows 11 Pro (build 26200), Node.js 24.21.0 (LTS), npm 11.19.0
next 16.3.8 (npm "latest", published 2026-09-30), react 19.3.0, react-dom 19.3.0
typescript 7.0.2, @next/codemod 16.3.8
Production server only (next build + next start). next dev was not tested.
Upgrade guide https://nextjs.org/docs/app/guides/upgrading/version-16
Codemods https://nextjs.org/docs/app/guides/upgrading/codemods
Error page https://nextjs.org/docs/messages/sync-dynamic-apis
Next scheduled re-check: 2027-01-02, or on the next minor release
The results below are status codes and log lines, not timings, so they do not vary between runs. Each request was made once per build, and every legacy route was built and served on at least two separate builds with identical results. One limitation applies throughout: only the production server was exercised, so warnings that next dev might print are not covered here.
Next.js 16.0.0 shipped on 2025-10-22, and the 15.x line still receives backports (15.5.27 was published the same day as 16.3.8). So if you are reading this on 15.5, the sync fallback still works for you today, which is exactly why the code that depends on it is still in your repo.
What Changed in the Next.js 16 Async Request APIs?
In Next.js 16, the request-time APIs can only be read asynchronously. cookies(), headers() and draftMode() return Promises, and the params and searchParams props are Promises. Next.js 15 also returned Promises but let you read them synchronously with a warning. Version 16 removed that compatibility layer entirely, so synchronous reads no longer work.
Here is where each one shows up, per the upgrade guide:
| API | Where it appears | Next.js 14 | Next.js 15 | Next.js 16 |
|---|---|---|---|---|
cookies(), headers(), draftMode() | Server Components, Server Functions, Route Handlers | Sync | Async, sync read warns | Async only |
params | page, layout, route, default, generateMetadata, generateViewport | Plain object | Promise, sync read warns | Promise only |
searchParams | page only | Plain object | Promise, sync read warns | Promise only |
params and id in image files | opengraph-image, twitter-image, icon, apple-icon | Plain values | Plain values | Promises |
id in sitemap | sitemap with generateSitemaps | Number | Number | Promise<string> |
params in generateImageMetadata | Image metadata files | Plain object | Plain object | Still a plain object |
The last three rows are new breaking changes in 16 rather than leftovers from 15. Notably, the sitemap id changes type as well as becoming a Promise, so id * 50000 needs a Number() around the awaited value.
On the other hand, a few things did not change. generateStaticParams still receives and returns plain objects. Similarly, the client hooks useParams() and useSearchParams() remain synchronous, because they read from the router rather than from the request.
What Old Code Actually Does on Next.js 16
To see the failure modes directly, the fixture contains three pages written the Next.js 14 way. The first is a JavaScript page that reads params and searchParams synchronously:
// app/legacy/[id]/page.jsx
// Next.js 14 style: synchronous params and searchParams
export default function Page({ params, searchParams }) {
console.log('legacy params.id =', params.id)
console.log('legacy searchParams.tab =', searchParams.tab)
console.log('legacy spread =', JSON.stringify({ ...params }))
return <p>id={String(params.id)} tab={String(searchParams.tab)}</p>
}
The second is the same idea in TypeScript, with the hand-written prop type that most Next.js 14 tutorials used:
// app/typed-legacy/[id]/page.tsx
// Next.js 14 style types: params as a plain object
export default function Page({ params }: { params: { id: string } }) {
return <p>id={params.id}</p>
}
The third, app/legacy-cookies/page.jsx, calls cookies().get('theme') and headers().get('user-agent') without awaiting. Alongside them sits a correctly migrated page at /fixed/[id] for comparison.
The Build Passes With All Three Broken Pages
npx next build
▲ Next.js 16.3.8 (Turbopack)
✓ Running next.config took 11ms
Creating an optimized production build ...
✓ Compiled successfully in 463ms
Running TypeScript ...
Finished TypeScript in 233ms ...
Collecting page data using 11 workers ...
✓ Generating static pages using 11 workers (6/6) in 151ms
Finalizing page optimization ...
Route (app)
├ ƒ /fixed/[id]
├ ƒ /legacy-cookies
├ ƒ /legacy/[id]
└ ƒ /typed-legacy/[id]
(Intermediate progress lines and unrelated fixture routes are removed from the output above; every remaining line is as printed.)
The JavaScript pages passing is expected, since nothing type-checks them. The TypeScript page passing is the surprise. Next.js generates a validator at .next/types/validator.ts that is supposed to check each page’s props, but it types them like this:
default: React.ComponentType<{ params: Promise<ParamMap[Route]> } & any> | ...
In TypeScript, intersecting any type with any produces any. Consequently, the validator accepts any props shape at all, including { params: { id: string } }. In practice, this means a TypeScript project that hand-annotated its page props gets no build-time warning that those annotations are now wrong.
Sync params Returns 200 and Renders undefined
Next, the server was started and each route requested with a cookie set:
npx next start -p 3000
curl -s -o /dev/null -b 'theme=dark' -w '%{http_code}\n' 'http://localhost:3000/legacy/42?tab=billing'
curl -s -o /dev/null -b 'theme=dark' -w '%{http_code}\n' 'http://localhost:3000/typed-legacy/42'
curl -s -o /dev/null -b 'theme=dark' -w '%{http_code}\n' 'http://localhost:3000/legacy-cookies'
curl -s -o /dev/null -b 'theme=dark' -w '%{http_code}\n' 'http://localhost:3000/fixed/42?tab=billing'
200
200
500
200
The server log for those four requests:
legacy params.id = undefined
legacy searchParams.tab = undefined
legacy spread = {}
⨯ TypeError: (0 , c.cookies)(...).get is not a function
at <unknown> (...\.next\server\chunks\ssr\[root-of-the-server]__1x6_b27._.js:1:269) {
digest: '589475130'
}
(The chunk path in the stack frame is shortened; the rest is as printed.)
Here is the important part. Sync params access does not throw on Next.js 16. A Promise has no id property, so params.id is undefined, the spread is {}, and the page renders with HTTP 200. There is no warning in the production log either. In a real app, that undefined flows into a database query or a fetch URL, so the visible symptom is a “not found” page or a request to /api/products/undefined, not an error pointing at the cause.
By contrast, cookies().get() fails loudly, because calling a method that does not exist on a Promise throws a TypeError. That is the easier bug, since it shows up as a 500 in your error tracker on the first request.
The Codemod’s Escape Hatch No Longer Compiles
The official codemod handles most of this, but it has a fallback for code it cannot safely make async. Here is the 16.3.8 codemod run against a helper that reads a cookie from a synchronous function:
npx @next/codemod@latest next-async-request-api app --force
-import { cookies } from 'next/headers'
+import { cookies, type UnsafeUnwrappedCookies } from 'next/headers';
// Helper called from several Server Components
export function getSessionId(): string | undefined {
- return cookies().get('session')?.value
+ return (cookies() as unknown as UnsafeUnwrappedCookies).get('session')?.value;
}
On Next.js 15, that cast compiled and the sync fallback made it work. On 16.3.8, the build stops:
Running TypeScript ...
app/lib/session.ts(1,24): error TS2305: Module '"next/headers"' has no exported member 'UnsafeUnwrappedCookies'.
Failed to type check.
That is a good failure, because it forces the fix. However, if you ran the codemod back on 15 and committed its casts, they are now build errors. Worse, if those files are JavaScript rather than TypeScript, the cast does not exist and the call fails at request time, exactly like the /legacy-cookies page above.
How to Migrate to the Next.js 16 Async Request APIs
The order below front-loads the steps that find problems, so the manual work happens on a known list rather than by grepping for symptoms later.
- Generate the route types with
npx next typegen - Run the
next-async-request-apicodemod, then grep for anything it left behind - Retype every page, layout and route handler with
PageProps,LayoutPropsandRouteContext - Make helper functions that read cookies or headers async, and await them at each call site
- Unwrap
paramsin Client Component pages withuse(), or switch touseParams() - Update image and sitemap metadata files, which changed in 16 itself
- Keep cookie writes in Server Functions and Route Handlers
- Run
next buildand request every dynamic route once before deploying
Step 1: Generate the Route Types
npx next typegen
Generating route types...
✓ Types generated successfully
This writes .next/types/routes.d.ts and makes three helpers globally available without an import: PageProps<'/route'>, LayoutProps<'/route'> and RouteContext<'/route'>. The route string is the folder path with its brackets, and the helpers derive the parameter names from it. As a result, a renamed folder becomes a type error rather than a silent undefined. next dev and next build regenerate these too, but running typegen first means your editor knows the types before you start editing.
Step 2: Run the Codemod and Find What It Left
npx @next/codemod@latest next-async-request-api .
On the fixture, the codemod converted all three page files correctly: it made the components async, replaced the destructured props with props, and added const params = await props.params. For the typed page it also rewrote the annotation to { params: Promise<{ id: string }> }. Only the synchronous helper got the cast shown earlier.
Then search for the three kinds of leftovers. These patterns were tested against the fixture’s pre-codemod files:
# Sync calls on the request APIs: cookies().get, headers().get, draftMode().isEnabled
grep -rnE "(cookies|headers|draftMode)\(\)\s*\." app src
# Destructured params or searchParams in a signature. A match typed Promise<...> is already fine.
grep -rnE "\{\s*(params|searchParams)\b[^}]*\}\s*[:)]" app src
# Codemod leftovers that must be resolved by hand
grep -rnE "UnsafeUnwrapped|@next-codemod-error" app src
On the unmigrated fixture, the first pattern found cookies().get and headers().get in the page and the helper. Meanwhile, the second found both legacy page signatures, and the third found the codemod’s cast once it had run. The docs note that @next-codemod-error comments make both dev and build fail until you resolve them or change the prefix to @next-codemod-ignore.
Step 3: Pages, Layouts and Route Handlers
For pages, the typed helper does the work. This is the product page from the fixture, which uses route params, the query string and a session cookie:
// app/shop/[category]/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getProduct } from '@/app/lib/products'
import { getSession } from '@/app/lib/session'
export async function generateMetadata(
props: PageProps<'/shop/[category]/[slug]'>
): Promise<Metadata> {
const { category, slug } = await props.params
const product = await getProduct(category, slug)
return { title: product ? product.name : 'Product not found' }
}
export default async function ProductPage(props: PageProps<'/shop/[category]/[slug]'>) {
// Start both reads together instead of awaiting them one after another
const [{ category, slug }, query, session] = await Promise.all([
props.params,
props.searchParams,
getSession(),
])
const product = await getProduct(category, slug)
if (!product) notFound()
const tab = typeof query.tab === 'string' ? query.tab : 'details'
return (
<main>
<h1>{product.name}</h1>
<p>Showing tab: {tab}</p>
{session ? <p>Signed in as {session.userId}</p> : <a href="/login">Sign in</a>}
</main>
)
}
Requesting /shop/lighting/desk-lamp?tab=reviews with a uid cookie rendered the title Desk Lamp, Showing tab: reviews and Signed in as u_17, while an unknown slug returned 404. The typeof query.tab === 'string' check matters, because searchParams values are typed string | string[] | undefined, and ?tab=a&tab=b really does produce an array.
The PageProps helper is also what catches the mistake the old annotations could not. Here is a page that uses the helper but forgets to await:
app/forgot-await/[id]/page.tsx(2,30): error TS2339: Property 'id' does not exist on type 'Promise<{ id: string; }>'.
Failed to type check.
That is the fixture’s real next build output. In other words, switching the annotation to PageProps turns the silent 200 from earlier into a build failure, which is the single most valuable change in this whole migration.
Layouts follow the same pattern with LayoutProps. Keep in mind that layouts receive params but never searchParams:
// app/shop/[category]/layout.tsx
export default async function CategoryLayout(props: LayoutProps<'/shop/[category]'>) {
const { category } = await props.params
return (
<section>
<nav aria-label="Breadcrumb">Shop / {category}</nav>
{props.children}
</section>
)
}
Route handlers get params through the second argument, typed with RouteContext:
// app/orders/[orderId]/route.ts
import type { NextRequest } from 'next/server'
import { cookies } from 'next/headers'
export async function GET(_request: NextRequest, ctx: RouteContext<'/orders/[orderId]'>) {
const { orderId } = await ctx.params
const cookieStore = await cookies()
if (!cookieStore.has('uid')) {
return Response.json({ error: 'Not signed in' }, { status: 401 })
}
return Response.json({ orderId, status: 'shipped' })
}
Without the cookie, /orders/A-1001 returned {"error":"Not signed in"} with a 401; with it, the handler returned {"orderId":"A-1001","status":"shipped"}. Inside a route handler you can also read request.cookies directly, which is synchronous because it reads the request object you already hold.
Step 4: Helpers That Read Cookies Become Async
This is the step the codemod cannot finish for you, and it is usually the largest. Any function that calls cookies() or headers() has to become async, and therefore every caller has to await it. Session helpers, feature-flag readers, locale detection and A/B bucketing are the usual suspects.
// app/lib/session.ts
import 'server-only'
import { cookies } from 'next/headers'
export type Session = { userId: string; locale: string }
// Async because cookies() is async. Every caller now has to await this.
export async function getSession(): Promise<Session | null> {
const cookieStore = await cookies()
const userId = cookieStore.get('uid')?.value
if (!userId) return null
return {
userId,
locale: cookieStore.get('locale')?.value ?? 'en',
}
}
The server-only import makes any accidental import from a Client Component a build error, which matters more now that the function signature looks like any other async data fetch. If you use Auth.js, its auth() helper is already async, so the pattern in the Next.js authentication with Auth.js guide carries over unchanged.
A common mistake here is fixing the helper and then calling it without await. In TypeScript with strict on, the fixture’s compiler caught both forms: session.userId fails, and if (session) on the unawaited call fails with TS2801, “This condition will always return true since this ‘Promise<…>’ is always defined.” In JavaScript files, however, neither is caught, and if (session) is true for every visitor, signed in or not. That is an auth bug, so in a JavaScript codebase, search every call site by hand.
Step 5: Client Component Pages
A page file marked 'use client' still receives params and searchParams as Promises. It cannot be async, so it unwraps them with React’s use():
// app/docs/[id]/page.tsx
'use client'
import { use } from 'react'
// A Client Component page still receives params as a Promise in Next.js 16
export default function DocPage(props: PageProps<'/docs/[id]'>) {
const { id } = use(props.params)
const { highlight } = use(props.searchParams)
return (
<article>
<h1>Document {id}</h1>
{typeof highlight === 'string' && <mark>{highlight}</mark>}
</article>
)
}
Requesting /docs/intro?highlight=params rendered Document intro and the highlighted term. For components deeper in the tree, however, useParams() and useSearchParams() are usually the better choice, since they stay synchronous and spare you from threading a Promise through props. The React Server Components explainer covers why the server side can await and the client side cannot.
Step 6: Image and Sitemap Metadata Files
These changed in 16 itself, so a codebase that was fully clean on 15 still breaks here. In opengraph-image, twitter-image, icon and apple-icon, the default export now receives params and id as Promises, whereas generateImageMetadata still gets plain params. Likewise, the sitemap id is now a Promise of a string:
// app/product/sitemap.ts
import type { MetadataRoute } from 'next'
const PAGE_SIZE = 50000
export async function generateSitemaps() {
return [{ id: 0 }, { id: 1 }]
}
export default async function sitemap(props: {
id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
// id arrives as a Promise of a string in Next.js 16, not a number
const page = Number(await props.id)
const start = page * PAGE_SIZE
return [{ url: `https://example.com/product/${start}`, lastModified: new Date() }]
}
The fixture served /product/sitemap/1.xml with <loc>https://example.com/product/50000</loc>. Without the Number(), multiplication happens to work because JavaScript coerces the string. Addition does not: (await props.id) + 1 produces "11" rather than 2 for the second sitemap, which is precisely the kind of bug nobody notices until a sitemap index goes wrong.
Step 7: Setting Cookies Still Needs a Server Function
The async change did not move where cookies can be written. Reading works in Server Components, but .set() and .delete() only work in a Server Function or Route Handler, because HTTP cannot add a Set-Cookie header after streaming has started:
// app/settings/actions.ts
'use server'
import { cookies } from 'next/headers'
import { refresh } from 'next/cache'
const SUPPORTED_LOCALES = new Set(['en', 'de', 'fr'])
export async function setLocale(formData: FormData) {
const locale = String(formData.get('locale') ?? '')
if (!SUPPORTED_LOCALES.has(locale)) {
throw new Error(`Unsupported locale: ${locale}`)
}
const cookieStore = await cookies()
cookieStore.set('locale', locale, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
})
refresh()
}
This action compiled and type-checked in the fixture, but it was not invoked over HTTP, so treat it as checked rather than exercised. refresh() is new in 16 and re-renders the client router after the action. For the wider pattern, see Next.js Server Actions without an API layer.
Defer the Await to Keep More of the Page Static
Async request APIs are not only a migration cost. Because cookies() returns a Promise, you can start it in a parent and unwrap it inside a <Suspense> boundary, so the rest of the page does not wait on request data:
// app/settings/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { setLocale } from './actions'
async function CurrentLocale({ cookiesPromise }: { cookiesPromise: ReturnType<typeof cookies> }) {
// Unwrapped as late as possible, inside the Suspense boundary
const locale = (await cookiesPromise).get('locale')?.value ?? 'en'
return <p>Current locale: {locale}</p>
}
export default function SettingsPage() {
return (
<main>
<h1>Settings</h1>
<Suspense fallback={<p>Loading locale...</p>}>
<CurrentLocale cookiesPromise={cookies()} />
</Suspense>
<form action={setLocale}>
<select name="locale" defaultValue="en">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
</select>
<button type="submit">Save</button>
</form>
</main>
)
}
With a locale=de cookie, the page rendered Current locale: de. This pattern matters most once you enable cacheComponents. In that mode, the cookies docs state that calling cookies() outside a <Suspense> boundary prevents the route from being prerendered. So the habit of awaiting everything at the top of the page, which the codemod produces, is the one to unlearn over time.
Real-World Scenario: Upgrading a Catalog From Next.js 14
Consider a mid-sized e-commerce storefront on the App Router, with a few dozen dynamic routes, a small team, and a codebase that skipped 15 and is going straight from 14 to 16. The team runs the upgrade codemod, sees a green build, and deploys to staging.
Most pages work, because the codemod rewrote them. However, the team’s category pages were written in plain JavaScript by a contractor, and an earlier refactor moved their param handling into a shared withCategory wrapper that the codemod did not recognise as a page entry. Those pages return 200, render an empty product grid, and log nothing. Meanwhile, the error tracker shows a handful of 500s from a feature-flag helper that calls headers().get(), and those get fixed quickly because they are loud.
The empty grids take longer, since nothing points at them. The trade-off the team faces is time against coverage: grepping and retyping every route with PageProps costs a few days across the codebase, whereas spot-checking only the routes that look wrong is faster but depends on someone noticing an empty page. Retyping wins here, because it converts the quiet failures into build errors that the CI pipeline catches on every later change, not only this one.
If your codebase predates the App Router entirely, the App Router vs Pages Router migration guide is the step before this one. Pages Router getServerSideProps is not affected by any of this.
When to Use the Codemod
- The codebase is mostly TypeScript, where the codemod rewrites the annotations it touches
- Pages and layouts read
paramsdirectly in the default export or ingenerateMetadata - You want the mechanical diff as its own commit, separate from the hand-fixes that follow
- The upgrade is being run by an agent or in CI, where
upgrade --yesaccepts every prompt
When NOT to Rely on the Codemod Alone
- Request APIs are read inside synchronous helpers, which get a cast that no longer compiles
- Pages are JavaScript, so nothing type-checks the result and failures surface at request time
- Params pass through wrappers or higher-order components the codemod does not treat as entries
- Your project hand-annotates page props, which the Next.js validator accepts regardless of shape
- You use image or sitemap metadata files, whose 16-only changes the async codemod does not mention
Common Mistakes With Next.js 16 Async Params and Cookies
- Reading
params.idwithoutawaitin JavaScript, which rendersundefinedwith a 200 - Trusting a green
next buildwhile page props still use the old{ params: { id: string } }type - Making a helper async but leaving
if (session)checks in JavaScript files, where a Promise is always truthy - Awaiting
params,searchParamsandcookies()in sequence whenPromise.allstarts them together - Treating
searchParams.tabas a string when a repeated query key makes it an array - Multiplying the sitemap
idwithoutNumber(), now that it arrives as a string - Calling
cookies().set()from a Server Component and expecting the cookie to stick - Committing
UnsafeUnwrappedCookiescasts from a 15-era codemod run and finding them at upgrade time
Next Steps After the Migration
The Next.js 16 async request APIs break in two ways: loudly for cookies() and headers(), and quietly for params and searchParams, which can return 200 with undefined and pass a TypeScript build. The fix that closes both is the same: run npx next typegen, retype every page, layout and route handler with PageProps, LayoutProps and RouteContext, and let the compiler find the rest. Start today with the three grep commands above, since they take a minute and tell you how big the job is. If you are coming from an older starter, the Next.js 14 project setup walkthrough shows the sync patterns you are now replacing.