On This Page
1. The Error
If you upgraded an App Router project to Next.js 15 and left your route props untouched, you've seen this in your terminal or browser dev overlay:
Error: Route "/blog/[slug]" used `params.slug`. `params` should be awaited
before using its properties. Learn more:
https://nextjs.org/docs/messages/sync-dynamic-apis
at Page (app/blog/[slug]/page.tsx:6:22)The same class of warning fires for searchParams, cookies(), headers(), and draftMode() — swap in whichever API you called synchronously. It's a console.error call from Next.js's App Router runtime, not a React-internal warning, so it never gets a "Minified React error #NNN" form — the component still renders in production, it just silently used the resolved value under the hood in Next 15.
If you're on TypeScript and generated route types (via next dev, next build, or next typegen) catch the mismatch first, you'll get a compile-time error instead:
Type error: Type '{ params: { slug: string; }; }' does not satisfy the
constraint 'PageProps<"/blog/[slug]">'.
Types of property 'params' are incompatible.
Type '{ slug: string; }' is missing the following properties from type
'Promise<{ slug: string; }>': then, catch, finally, [Symbol.toStringTag]And if you're on Next.js 16, where the backwards-compatible sync shim has been deleted, there's no warning pointing at the real cause at all. params is a genuine Promise, so params.slug is simply undefined, and the crash shows up wherever you next touch that value:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
at Page (.next/server/app/blog/[slug]/page.js:12:34)
at renderToReadableStream (react-dom/server.edge.js)This applies to params in layout, page, route, default, opengraph-image, twitter-image, icon, and apple-icon, plus searchParams in page. It has nothing to do with React's own render loop — it's an App Router convention — so it applies equally whether your component is a Server Component (async function Page) or a Client Component ('use client' + use()).
2. How to Reproduce It
Scaffold a fresh app on current versions:
npx create-next-app@latest repro --ts --app --eslint --no-tailwind --no-src-dir --import-alias "@/*"
cd repropackage.json dependencies after scaffolding (pin these to reproduce exactly):
{
"dependencies": {
"next": "16.3.4",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"typescript": "^7.0.2"
}
}Directory tree for the repro:
repro/
├── app/
│ ├── blog/
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── layout.tsx
│ └── page.tsx
├── package.json
└── tsconfig.jsonapp/blog/[slug]/page.tsx — the Next.js-14-style code that used to be correct:
// app/blog/[slug]/page.tsx
type Props = {
params: { slug: string }
}
export default function Page({ params }: Props) {
const heading = params.slug.toUpperCase()
return <h1>Blog post: {heading}</h1>
}npm install
npm run devVisit http://localhost:3000/blog/hello-world.
- On Next.js 15.x: the page renders
BLOG POST: HELLO-WORLDcorrectly, but the terminal prints theparams should be awaitedwarning on every request to that route — the temporary compatibility Proxy resolves.slugsynchronously for you while nagging about it. - On Next.js 16.x:
paramsis a plainPromise, soparams.slugisundefined, and.toUpperCase()throws theTypeErrorshown above. There's no dev overlay message telling you why — just a stack trace pointing at your own line. - Build-time (either version, with typed routes):
npm run buildfails during type checking with thePagePropsconstraint error shown in section 1, because the generated route types (fromnext typegen) knowparamsisPromise<{ slug: string }>and your hand-writtenPropstype says it's a plain object.
The failure triggers specifically because you're on the App Router (this doesn't exist in the pages/ directory) and specifically at the page/layout/route boundary — a helper function you write yourself that takes { slug: string } as an argument is unaffected.
3. Version Behavior Matrix
| Version | params / searchParams type | Sync access | What you see |
|---|---|---|---|
| Next.js 13.4 – 14.x (App Router, React 18) | Plain object | Works | Nothing — this is the original, correct behavior for that era |
| Next.js 15.0.0-RC – 15.x | Promise<T>, with a temporary compatibility shim | Still works, via a Proxy that resolves synchronously | Dev-only console.error: Route "..." used \params.x`. `params` should be awaited...` |
| Next.js 16.0+ | Promise<T>, shim removed | Returns undefined for any property | No direct warning; a TypeError wherever the undefined value is later used |
Framework/React pairing: Next.js 15 requires React 19 (stable since December 5, 2024, when use() first shipped as a stable API). Next.js 16 bundles the React 19.2 canary line for View Transitions, useEffectEvent, and <Activity>; the current React release as of this writing is 19.3.0 (shipped September 9, 2026), which adds Fragment refs and a stable <ViewTransition> but changes nothing about this specific behavior. The mechanism your Client Components rely on to unwrap these promises — use() — has been stable since React 19.0 and hasn't changed shape since.
This is not a React-version-neutral issue in the way a plain JavaScript bug would be: the whole failure mode is defined by which Next.js major you're on, because Next.js — not React — owns the decision to wrap params/searchParams in a Promise and to keep or drop the compatibility shim. In Next.js 16, the same "now a Promise" treatment was also extended to the id argument passed to opengraph-image/twitter-image/icon/apple-icon generation functions and to the id passed into sitemap() from generateSitemaps() — same root cause, worth checking if you use either.
4. Why It Happens — Surface Level
Code written against the Next.js 14 contract assumes params and searchParams are ordinary objects available the instant your component function runs. Next.js 15 changed the contract: these props are now Promises, and you're expected to await them (in a Server Component) or unwrap them with use() (in a Client Component) before touching their properties. Any code that skips that step is reading properties off a Promise instead of off the resolved data — which either works by accident (v15's shim) or fails silently until something downstream dereferences undefined (v16).
5. Why It Happens — Under the Hood
params and searchParams carry request-time information — which dynamic segment was matched, what's in the query string. Before Next.js 15, the router had to fully resolve that information before it could call your page function at all, which meant a page using dynamic params could never start streaming its static shell early. Cache Components and Partial Prerendering change that: Next.js wants to emit the parts of a page that don't depend on the request immediately, and only block on params/searchParams at the exact point your code actually reads them. A plain object can't express "this value exists, but don't force it to be known everywhere in the tree" — a Promise can, and it plugs directly into machinery React already has for exactly this problem.
That machinery is use(). When a component calls use() on a pending promise, React treats it like a suspended data read: the component bails out of the current render, marks the nearest <Suspense> boundary as pending, and resumes once the promise settles — the same lane-and-fiber mechanics that back React.lazy and Suspense-based data fetching, not some Next.js-specific hack layered on top. In a Server Component, await gets you the same result more directly, because the whole component function is invoked as part of an async RSC render pass; there's no fiber to suspend, just a promise the render pass awaits before producing that segment's Flight output. Either path, Next.js maintains one promise instance per request per API (params, searchParams, cookies(), etc.) in a request-scoped cache, so multiple components reading params all get the identical promise reference — necessary so use() doesn't treat each call site as a new, never-resolving suspension.
The Next.js 15 compatibility shim is the interesting bone here: since the underlying route params are actually known synchronously by the time your component runs (they're not real I/O, unlike a database call gated behind use()), Next.js could return an object that behaves like a Promise (.then works) but also exposes the resolved fields directly via a Proxy's get trap — hence params.slug working while a single console.error fires to flag the deprecated pattern. Next.js 16 deletes that Proxy and hands back an ordinary native Promise. Ordinary Promises have no .slug getter, so JavaScript's normal property-access rules apply: reading a property that doesn't exist on the object returns undefined, no exception, no warning — the exception only appears later, at whatever line first assumes that value isn't undefined. That's why the v16 stack trace is so much less helpful than the v15 warning: the actual mistake and the actual crash are now in different places.
This is the same class of "Promise as prop" boundary you'll hit again with use()-driven data fetching and uncached promises in Suspense, and it borders the Server/Client Component serialization rules — since a route's Server Component page can await params freely, but a 'use client' page receiving the same prop across the module boundary must use use() instead, because a Client Component's top-level function can't be async.
6. The Fix
Server Component page or layout — make the function async and await:
-type Props = {
- params: { slug: string }
-}
-
-export default function Page({ params }: Props) {
- const heading = params.slug.toUpperCase()
- return <h1>Blog post: {heading}</h1>
-}
+export default async function Page(props: PageProps<'/blog/[slug]'>) {
+ const { slug } = await props.params
+ return <h1>Blog post: {slug.toUpperCase()}</h1>
+}PageProps<'/blog/[slug]'> is a globally available helper generated by next dev / next build / next typegen — no import needed. It types params (and searchParams, on pages) as the exact Promise<{ slug: string }> shape for that route literal, so a hand-rolled type can never drift out of sync again.
Client Component page — unwrap with use() instead of await, since the component can't be async:
'use client'
import { use } from 'react'
export default function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = use(props.params)
return <h1>Blog post: {slug.toUpperCase()}</h1>
}generateMetadata:
export async function generateMetadata(
props: PageProps<'/blog/[slug]'>
): Promise<Metadata> {
const { slug } = await props.params
return { title: `Post: ${slug}` }
}Route Handlers:
// app/api/posts/[slug]/route.ts
export async function GET(
request: Request,
segmentData: RouteContext<'/api/posts/[slug]'>
) {
const { slug } = await segmentData.params
return Response.json({ slug })
}Automate the mechanical rewrite across a whole codebase:
npx @next/codemod@canary next-async-request-api .Run this even after npx @next/codemod@canary upgrade latest — the v16 upgrade guide is explicit that the general upgrade codemod does not chain the async-request-API codemod automatically, so leftover sync access from the Next.js 15 compatibility period survives an "upgraded" codebase until you run it separately.
What not to do long-term: Next.js 15 offered an escape hatch — casting with UnsafeUnwrappedCookies / UnsafeUnwrappedHeaders / UnsafeUnwrappedDraftMode to keep synchronous access while suppressing nothing (it still warns) — and there was never an equivalent unsafe cast for params/searchParams themselves. Treat any of these casts as a delay tactic, not a fix: Next.js 16 removes the underlying sync path entirely, so code relying on the cast breaks the moment you upgrade, in the harder-to-debug undefined-property way described above.
7. Best Practices & The Better Design
Adopt the generated PageProps<'/route'>, LayoutProps<'/route'>, and RouteContext<'/route'> helpers everywhere instead of writing params: Promise<{...}> by hand. They come from next typegen (available since Next.js 15.5, and run automatically by next dev/next build), so a future Next.js major that changes the shape again surfaces as a type error at your next build, not as a runtime undefined chase.
Delay unwrapping params/searchParams until the exact point you need the value, rather than destructuring both at the top of the function out of habit. The docs call this out directly: holding off on await/use() until the value is actually consumed lets Next.js statically render more of the page shell around it, which is the entire reason this API is async in the first place.
Here's the "do it right from the start" version, with metadata, a Server Component page, and the typed helper together:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata(
props: PageProps<'/blog/[slug]'>
): Promise<Metadata> {
const { slug } = await props.params
return { title: `Post: ${slug}` }
}
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const { preview } = await props.searchParams
return (
<article>
<h1>Blog post: {slug}</h1>
{preview === 'true' && <p>Preview mode</p>}
</article>
)
}If a route segment genuinely needs interactivity (a filter form reading searchParams, for instance), keep "use client" on the smallest leaf component that needs it and pass the already-await-ed value down as a plain prop from a Server Component parent, rather than promoting the whole page to a Client Component and reaching for use() everywhere.
8. How to Prevent It Long-Term
Run npx next typegen (or just rely on next dev/next build, which generate the same types) and switch every route to the PageProps/LayoutProps/RouteContext helpers — this converts the entire class of bug from a production TypeError into a local, pre-merge compile error.
Make the next-async-request-api codemod part of your upgrade checklist, every major version, not a one-time cleanup — re-run it whenever git log shows new route files added by a contributor who copy-pasted from an old tutorial.
Add AGENTS.md docs pointers with npx @next/codemod@canary agents-md if AI coding agents touch your routes — the generated block explicitly tells the agent "this version has breaking changes... read the docs before writing any code," which stops an assistant from regenerating the Next.js 14 synchronous pattern from stale training data.
Run next build in CI, not just next dev — the PageProps constraint error and the general TypeScript route-type validation both run during next build's type-checking pass, so a CI job that only runs next dev (or skips type-checking) will ship the broken route straight to production.
Add an end-to-end check (Playwright/Cypress) that visits at least one dynamic route per app and fails the build on any unexpected console.error in server logs or the browser console — since the Next.js 15 warning is a plain console.error, a "no console errors" gate in CI catches it before you ever get to the silent-undefined behavior of v16.
Track it in production the same way you'd track any silent-failure class: Sentry (or your error tool) grouping on Cannot read properties of undefined spikes right after a Next.js major bump is a strong signal this is the cause, especially if the stack trace bottoms out inside a page.js/layout.js/route.js file rather than your own utility code.
9. Key Takeaways
paramsandsearchParamsbecame Promises starting in Next.js 15.0.0-RC; Next.js 14 and earlier had them as plain synchronous objects, and no other Next.js API's rendering contract changed to match — this is specific to these request-time props (pluscookies(),headers(),draftMode()).- Next.js 15 keeps old, un-migrated code working via a compatibility Proxy and warns you in dev; Next.js 16 deletes that Proxy, so the same un-migrated code goes from "annoying warning" to "silent
undefinedthat crashes somewhere else." - Fix it with
await props.paramsin Server Components,use(props.params)in Client Components, and runnpx @next/codemod@canary next-async-request-api .to do the mechanical part across a codebase. - Use the generated
PageProps<'/route'>/LayoutProps<'/route'>/RouteContext<'/route'>helpers (fromnext typegen) instead of hand-written prop types — they turn a future breaking change into a build-time error instead of a production incident. - This pattern connects directly to React's
use()and Suspense (how the promise is actually unwrapped), to the Server/Client Component boundary (why Server Componentsawaitbut Client Components mustuse()), and to Cache Components / Partial Prerendering (the actual reason Next.js wants these values to be lazy in the first place).
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.