On This Page
Next.js params Should Be Awaited: Causes and Fixes
The Error
If you upgraded an App Router project to Next.js 15 or later and left old code untouched, you've seen this in your terminal or browser console:
Error: Route "/[lang]/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/[lang]/blog/[slug]/page.tsx:4:20)
at stringify (<anonymous>)The same message shows up for searchParams, and for cookies(), headers(), and draftMode() from next/headers:
Error: Route "/dashboard" used `searchParams.query`. `searchParams` should be awaited
before using its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apisError: Route "/api/session" used `cookies().get('token')`. `cookies()` should be awaited
before using its value. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apisIn Next.js 15.0–15.4, this is a warning: the page still renders, because Next.js hands you a backward-compatible object that both returns the value and logs the message. From Next.js 15.5 onward with cacheComponents enabled, and universally in Next.js 16, it becomes a real problem — either a build-time prerender error (Cannot access Request information synchronously with Page or Layout or Route params or Page searchParams, message id next-prerender-sync-params) or, once the compatibility shim is gone, a silent undefined because params is now a plain Promise with no .slug getter on it.
This isn't a React core error — you won't find it in the React error decoder at react.dev/errors. It's a Next.js-specific console.error plus, under cacheComponents, a thrown Error during prerendering. It applies to the App Router only; the Pages Router (pages/) never had this change.
How to Reproduce It
Scaffold a fresh App Router project on current versions:
bash
npx create-next-app@latest repro --typescript --app --no-tailwind --no-src-dir --import-alias "@/*"
cd repropackage.json dependencies after scaffolding (versions current as of this writing):
json
{
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.7.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}Create the failing route:
repro/
└── app/
└── blog/
└── [slug]/
└── page.tsxtsx
// This is the Next.js 14 pattern — params was a plain object.
type Props = {
params: { slug: string }
}
export default function Page({ params }: Props) {
// Direct, synchronous access — this is what trips the warning/error.
return <h1>Post: {params.slug}</h1>
}Run it:
bash
npm run devVisit http://localhost:3000/blog/hello-world. On Next.js 15.x you'll see the page render with the console warning above. On Next.js 16 with default settings, params.slug typically still resolves via a deprecated compatibility path in dev but is flagged; run a production build with Cache Components enabled and it fails outright:
ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigbash
npm run buildError: Route "/blog/[slug]" used `params.slug`. `params` should be awaited before
using its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apisThe trigger conditions worth knowing: it fires on any direct property read (params.slug), on spreading ({...params}), on Object.keys(params), and on iterating cookies() or headers(). It happens in page.tsx, layout.tsx, route.ts, default.tsx, generateMetadata, and the image-metadata files (opengraph-image, icon, etc.) — anywhere the App Router hands you one of these five APIs. It does not happen in the Pages Router, and it does not happen if the component was already written with await params.
Version Behavior Matrix
This is a Next.js API-surface change, not a core React behavior change, so the meaningful axis is the Next.js version, with React's role limited to providing use() as the unwrap mechanism for synchronous (Client) components.
| Next.js version | params / searchParams type | Sync access | React version required |
|---|---|---|---|
| 14.x | Plain object | Works, no warning | React 18 |
| 15.0 – 15.4 | Promise, with a compatibility proxy | Works, logs a dev warning (sync-dynamic-apis) | React 19 required |
15.5+ with cacheComponents: true | Promise | Throws during prerender (next-prerender-sync-params) | React 19 |
| 16.0+ (default) | Promise | Deprecated compatibility path still present but scheduled for removal; treat as unsupported | React 19.2 (canary channel) |
16.0+ with cacheComponents: true | Promise | Throws during prerender | React 19.2 |
React-side notes: params/searchParams becoming Promises is what made await-ing props inside an async Server Component the norm, and it's the same use() API (stable since React 19.0, December 2024) that Client Components use to unwrap them without becoming async themselves — Client Component functions can't be async. Nothing in React 19.1 (owner stacks, March 2025), 19.2 (Activity, useEffectEvent, October 1, 2025), or 19.3 (September 9, 2026 — Context renderable directly in Server Components, hydration fixes for <Activity>) changes how this specific error behaves; it's driven entirely by the Next.js version and the cacheComponents flag. As of this writing there is no React 20 announced, and Next.js 16's App Router runs on a React 19.2 canary channel rather than a pinned stable release, which is a deliberate Next.js choice for accessing in-progress features like View Transitions.
Why It Happens — Surface Level
Your component reads params.slug (or destructures { slug } = params) the moment the function runs, treating params as a plain object the way Next.js 14 always handed it to you. But starting in Next.js 15, params and searchParams (and cookies(), headers(), draftMode()) are Promises. A Promise doesn't have a .slug property — Next.js's compatibility layer intercepts that specific access pattern to still return the value while logging a warning, but that layer is deliberately temporary and is disabled entirely once cacheComponents is on, or once you're past the compatibility window in 16.x.
Why It Happens — Under the Hood
The change exists because of how the App Router decides what can be prerendered as static HTML versus what must run per-request. Route params, query strings, cookies, and headers are only known once an actual HTTP request arrives — they're request-time data. Next.js's Cache Components model (formerly Partial Prerendering) needs to statically render as much of a route's shell as possible at build time and defer only the genuinely dynamic parts to request time, wrapped in <Suspense>.
If params were a synchronously-available plain object, the renderer would have no signal for "this component needs request data, stop trying to prerender past this point." By making the value a Promise, the mechanism that resolves it becomes visible to the render pipeline: an await (or React's use()) is a checkpoint the framework can detect and use to bail out of static rendering at exactly the right boundary, rather than guessing based on which functions you call.
This is also why the fix has two different shapes depending on where you are in the tree. Inside async function Page(props), you await props.params because the function itself is already asynchronous — an async Server Component's body runs as a generator-like sequence that the renderer suspends and resumes around each await, the same primitive used for await fetch(...) in Server Components generally. Inside a synchronous Client Component, you can't sprinkle async/await into the function body (React needs to call Client Components synchronously to keep them compatible with hooks and to allow immediate re-renders), so instead you call use(props.params). use() is the primitive React added specifically to read a Promise's value during render: it either returns the resolved value immediately (if the promise settled before this render, via React's internal promise cache keyed by identity) or it throws the pending promise up to the nearest <Suspense> boundary, which catches it and shows the fallback until it resolves, then re-renders the subtree. That's the same mechanism that powers data fetching with use() more generally — reading a params Promise and reading a database-fetch Promise go through the identical suspend/resume path in the fiber tree.
The reason plain property access used to "just work" and now silently breaks (once the compatibility shim is gone) comes down to Promise having no arbitrary property getters — somePromise.slug is undefined by the ordinary rules of JavaScript, full stop. Next.js's 15.x compatibility layer wraps the Promise in a Proxy (or defines the properties directly on it) so that params.slug still resolves to the right string while also firing a console.error synchronously — that's how you get a "warning that still works" instead of an outright crash. It's a deliberately temporary hack, which is why it's gated on being called synchronously and disappears once cacheComponents is on, since that mode also builds a static/dynamic boundary graph at build time that a synchronous access into request-time data would falsify.
The Fix
Server Components and Route Handlers — await it (recommended, works everywhere):
diff
- type Props = {
- params: { slug: string }
- }
-
- export default function Page({ params }: Props) {
- return <h1>Post: {params.slug}</h1>
- }
+ type Props = {
+ params: Promise<{ slug: string }>
+ }
+
+ export default async function Page({ params }: Props) {
+ const { slug } = await params
+ return <h1>Post: {slug}</h1>
+ }Route Handlers follow the same shape — the second argument's params is a Promise too:
diff
- export async function GET(request: Request, { params }: { params: { id: string } }) {
- const id = params.id
+ export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await paramsClient Components — unwrap with use() (required, since Client Components can't be async):
diff
+ 'use client'
+ import { use } from 'react'
- export default function PostClient({ params }: { params: { slug: string } }) {
- return <h1>Post: {params.slug}</h1>
- }
+ export default function PostClient({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = use(params)
+ return <h1>Post: {slug}</h1>
+ }Fastest path for a large codebase — run the official codemod, then hand-fix what it flags:
bash
npx @next/codemod@canary next-async-request-api .The codemod rewrites the common cases automatically and leaves a @next-codemod-error comment anywhere it can't safely convert (typically when the actual page component is re-exported from a different file than page.tsx, so the codemod can't tell it needs to become async). Search for that marker and fix those by hand using the diffs above; leaving them unaddressed becomes a hard build failure once the linter enforcement kicks in.
Type-safety shortcut — let Next.js generate the Promise-aware prop types for you:
bash
npx next typegenThis gives you PageProps<'/blog/[slug]'>, LayoutProps<'/blog/[slug]'>, and RouteContext helpers with params/searchParams already typed as Promise<...>, so you stop hand-writing (and mistyping) the wrapper type:
tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
return <h1>Post: {slug}</h1>
}What not to do: the UnsafeUnwrappedCookies / UnsafeUnwrappedHeaders synchronous escape hatches Next.js documented for the 15.x transition window (cookies() as unknown as UnsafeUnwrappedCookies) are exactly that — unsafe and temporary. They still log the warning, they don't work once cacheComponents is on, and they're gone as a supported path in 16. Treat them as a stopgap for finishing a migration under deadline, not a fix.
Best Practices & The Better Design
Push the await (or use()) as close to the top of the component as possible, before any early returns or conditional logic, so the "this route needs request-time data starting here" boundary is unambiguous to both readers and the Cache Components static analysis:
tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
// Resolve request-time data first — one clear boundary.
const { slug } = await props.params
const searchParams = await props.searchParams
const post = await getPost(slug)
if (!post) notFound()
return <Article post={post} query={searchParams.q} />
}If a page has expensive static content and only a small dynamic sliver (say, a personalized greeting that needs cookies()), don't let one await cookies() at the top of the function force the whole tree to be dynamic. Push the dynamic read into a small child Server Component wrapped in <Suspense>, so Next.js can still prerender everything else:
tsx
import { Suspense } from 'react'
export default function Page() {
return (
<>
<StaticDashboardShell />
<Suspense fallback={<GreetingSkeleton />}>
<Greeting />
</Suspense>
</>
)
}
async function Greeting() {
const cookieStore = await cookies()
const name = cookieStore.get('name')?.value ?? 'friend'
return <p>Welcome back, {name}.</p>
}This is the same discipline Cache Components (cacheComponents: true) enforces for you at build time — it will error on uncached, unwrapped dynamic reads outside <Suspense> specifically to push you toward this shape. Adopting it before you flip the flag on means the migration to Cache Components later is close to a no-op.
How to Prevent It Long-Term
Run npx next typegen as part of your normal type-check step (or a postinstall/predev script) so PageProps/LayoutProps/RouteContext stay in sync with your route structure and any lingering non-Promise params type shows up as a TypeScript error immediately rather than a runtime warning. Enable @next/eslint-plugin-next's recommended rule set — it's flat-config by default in Next.js 16 — so a synchronous access pattern gets flagged at lint time in CI, before it ever reaches a browser or a build log. Keep AGENTS.md (generated via npx @next/codemod@canary agents-md) pointed at the bundled docs in node_modules/next/dist/docs/ if you or an AI coding agent are making routing changes, since pre-15 training data and blog posts will confidently suggest the old synchronous shape. In CI, run a production build with cacheComponents: true even if you haven't adopted it for the deployed app yet — treat it as a stricter lint pass that catches every remaining synchronous dynamic-API read before your users do, since a Sentry report or a support ticket about a blank slug is a much more expensive way to find the same bug. Finally, track hydration-error and build-warning counts in your CI logs or observability tool over time; a sudden spike right after a next version bump is this class of bug almost every time.
Key Takeaways
params,searchParams,cookies(),headers(), anddraftMode()becamePromises in Next.js 15 so the App Router can tell, from yourawait/use()call, exactly where static rendering has to stop and per-request rendering has to start.- The direct-access warning is a temporary compatibility shim, not a permanent feature — it disappears under
cacheComponents: trueand is being removed outright as Next.js 16 matures, turning a loud warning into a silentundefined. - Fix Server Components and Route Handlers with
await params; fix Client Components withuse(params)from React, since Client Component functions can't beasync. npx @next/codemod@canary next-async-request-api .handles most of the migration automatically — search for@next-codemod-errorfor the cases it can't.- Push dynamic reads into small
<Suspense>-wrapped child components rather than at the top of a whole page, and you'll sail through the eventual move to Cache Components instead of fighting it.
Related: this same await/use() boundary is what separates static from dynamic rendering under Cache Components, and it's a close cousin of the Server-Component-to-Client-Component serialization boundary ("use client", Only plain objects... can be passed to Client Components) — both are about the framework needing an explicit checkpoint for where request-time or non-serializable data enters the tree.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.