On This Page
1. The Error
If you've just upgraded a Next.js App Router project and a dynamic route stopped rendering, this is almost certainly what you're staring at.
Next.js 15 (development console + terminal, warning):
Error: Route "/articles/category/[category]" used `params.category`. `params` is now a Promise
and should be unwrapped with `React.use()` before accessing properties of the underlying params
object. In this version of Next.js direct access to param properties is still supported to
facilitate migration but in a future version you will be required to unwrap `params` with
`React.use()`.
at Page (app/articles/category/[category]/page.tsx:6:20)Next.js 16, with Cache Components enabled (cacheComponents: true), hard build/runtime error:
Error: Route "/articles/category/[category]" used `params.category`. `params` is a Promise and
must be unwrapped with `await` or `React.use()` before accessing its properties.TypeScript (any Next.js 15+ project with typed props):
Type error: Type 'Props' does not satisfy the constraint 'PageProps'.
Types of property 'params' are incompatible.
Type '{ category: string }' is missing the following properties from type
'Promise<{ category: string }>': then, catch, finally, [Symbol.toStringTag]The silent variant — no error at all, just wrong output (this is the one that burns people in production): on Next.js 15 with the codemod not run, or a hand-rolled type that widens params to any, the build succeeds and the dev server shows nothing red. In production the route renders with category as undefined, because you read params.category off a Promise object instead of its resolved value, and Promise.category is, unsurprisingly, undefined.
This applies to params in page.js/page.tsx, layout.js, route.js, default.js, generateMetadata, and generateViewport, and to searchParams in page.js. The same pattern — and the same fix — applies to cookies(), headers(), and draftMode() from next/headers, which became async in the same release.
2. How to Reproduce It
Scaffold a fresh App Router project on the current stable line:
npx create-next-app@latest repro --typescript --app --src-dir=false --no-tailwind --eslint
cd repropackage.json dependency block (pinned to the versions this article was verified against):
{
"dependencies": {
"next": "16.3.4",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "7.0.2",
"@types/react": "19.3.x",
"@types/node": "22.x"
}
}Directory tree:
repro/
app/
articles/
category/
[category]/
page.tsxapp/articles/category/[category]/page.tsx — written the Next.js 14 way, which is exactly how a lift-and-shift migration tends to look:
// app/articles/category/[category]/page.tsx
type Props = {
params: { category: string };
searchParams: { page?: string };
};
export default function CategoryPage({ params, searchParams }: Props) {
const page = searchParams.page ? Number(searchParams.page) : 1;
return (
<p>
Category: {params.category} — Page {page}
</p>
);
}npm install
npm run devVisit /articles/category/react. On Next.js 15 you get the console warning above and the page still renders (for now). On Next.js 16 with cacheComponents: true in next.config.ts, next build fails outright with the hard error, and without that flag you still get a TypeScript compile error the moment @types/react/@types/next treat params as Promise<{ category: string }> — which is the default typing since the Next.js 15 type definitions shipped.
next.config.ts used to reproduce the hard-error variant:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;Triggers to note: this fires identically in dev and in next build && next start — it is not a dev-only warning once Cache Components is on. It fires for every dynamic segment and for generateMetadata/generateImageMetadata separately from the page component, so fixing the page but not generateMetadata leaves a second, easy-to-miss instance. It does not fire in the Pages Router — getServerSideProps/getStaticProps params are unaffected; this is App Router only.
3. Version Behavior Matrix
This is a framework-level change, not a React-core one — React's contribution is that React.use() (stable since React 19.0, December 2024) is the mechanism Next.js reused to unwrap the Promise in Client Components. The table below tracks Next.js, with a note on the React requirement.
| Next.js version | params/searchParams behavior |
|---|---|
| 14.x and earlier | Plain synchronous objects. No warning, no Promise. |
| 15.0 (Oct 21, 2024) | Converted to Promise. Sync property access still works but logs a dev and production console warning. next-async-request-api codemod available. |
| 15.1 – 15.5 | Same as 15.0; sync access is progressively also flagged by @next/eslint-plugin-next and by the TypeScript types (PageProps constraint) if you use the generated route types. |
| 16.0 (Oct 21, 2025) | Sync access is listed as a removal: the codemod's @next-codemod-error markers become build-blocking if unaddressed. With cacheComponents: true, sync access is a hard error, both at build and at runtime. |
| 16.3.4 (current, verified for this article) | Same as 16.0; no further change to this specific behavior. |
React side-note: unwrapping params in a Client Component requires React.use(), which needs React ≥ 19.0. use() did not exist in React 18, so a Pages-Router-style app still on React 18 cannot adopt this pattern — which is one more reason Next.js 15 required React 19 (RC at the time) for the App Router while keeping React 18 support for the Pages Router. React 19.3 (Sept 9, 2026, the release current as of this article) makes no changes to use() or to this behavior — it's unrelated to this bug.
4. Why It Happens — Surface Level
Next.js used to hand your Server Component params and searchParams as plain objects computed before your component ran. Starting in Next.js 15, both are wrapped in a Promise instead, so the framework can start rendering parts of the route before it knows the exact request — the segment that doesn't depend on params can be prepared while the part that does is still resolving. Your old code reads params.category straight off that object. In JavaScript, reading an arbitrary property off a Promise doesn't throw — it just returns undefined, because Promise instances don't have a category property. That's why the failure mode is so often silent instead of a crash.
5. Why It Happens — Under the Hood
The motivation is Partial Prerendering, and in Next.js 16, Cache Components (cacheComponents: true) built on top of it. Next.js wants to split a route into a static shell that can be served instantly from cache or the edge, and a dynamic hole that depends on the actual incoming request. params and searchParams are exactly the kind of request-specific data that make a segment "dynamic" — the moment your component code touches them, Next.js has to bail out of static generation for that subtree and wait for the request. Making them Promise-typed forces that boundary to be explicit and lets the compiler start rendering the static parts of the tree immediately, resolving the request-dependent Promise only when a component actually awaits or use()s it. That's the "Good to know" note in the Next.js docs: delaying the unwrap as long as possible statically renders more of your page.
Under Cache Components, this is enforced by the prerenderer: it walks the Server Component tree, and the instant it sees a dynamic API read outside of a Suspense boundary or without being properly awaited, it throws rather than silently making the whole route dynamic — because Next.js can no longer tell whether that access was intentional or a leftover synchronous read from pre-15 code. This is conceptually similar to how React itself handles use() on the client: use() is not a hook in the traditional sense — it can be called conditionally and in loops — because it works by throwing the pending Promise up to the nearest Suspense boundary during render (the same mechanism Suspense-for-data-fetching uses), then re-rendering the component once the Promise settles. On the server, Next.js's own request-scoped Promises for params/searchParams plug into that identical use() machinery, which is why the fix in a Client Component is literally React.use(params) and not some Next.js-specific API.
Evidence you can pull yourself: run next build with cacheComponents: true on unmigrated code and the build output names the exact route and the exact property accessed — used \params.category`— because the prerenderer instruments the Promise with a proxy that records property access and reports it before returningundefined`, rather than failing silently.
6. The Fix
Quick fix — automated codemod (covers most call sites):
npx @next/codemod@canary next-async-request-api .This rewrites Server Component signatures to async and inserts await params / await searchParams, and leaves an @next-codemod-error comment anywhere it couldn't safely rewrite (commonly: the actual page component re-exported from a different file than page.tsx, as in the Next.js docs example). Search your diff for @next-codemod-error and finish those by hand — an unresolved marker becomes a build error in Next.js 16, which is a feature: it stops half-migrated code from shipping silently.
Manual fix — Server Component (the common case):
- type Props = {
- params: { category: string };
- searchParams: { page?: string };
- };
-
- export default function CategoryPage({ params, searchParams }: Props) {
- const page = searchParams.page ? Number(searchParams.page) : 1;
- return (
- <p>
- Category: {params.category} — Page {page}
- </p>
- );
- }
+ type Props = {
+ params: Promise<{ category: string }>;
+ searchParams: Promise<{ page?: string }>;
+ };
+
+ export default async function CategoryPage({ params, searchParams }: Props) {
+ const { category } = await params;
+ const { page: pageParam } = await searchParams;
+ const page = pageParam ? Number(pageParam) : 1;
+ return (
+ <p>
+ Category: {category} — Page {page}
+ </p>
+ );
+ }Manual fix — Client Component (you can't make a Client Component's default export async, so await isn't available; unwrap with use() instead):
"use client";
import { use } from "react";
type Props = {
params: Promise<{ category: string }>;
};
export default function CategoryFilters({ params }: Props) {
const { category } = use(params);
return <span>Filtering: {category}</span>;
}generateMetadata needs the same treatment — it's a separate function with its own params argument, and the codemod handles it, but double-check it if you migrated by hand:
- export function generateMetadata({ params }: Props): Metadata {
- return { title: `Category: ${params.category}` };
- }
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
+ const { category } = await params;
+ return { title: `Category: ${category}` };
+ }What's a real fix versus a stopgap: awaiting/use()-ing at the point of consumption is the correct fix, not a suppression — there's no // eslint-disable equivalent here, and there shouldn't be one, since the underlying data genuinely isn't available synchronously anymore. The one thing to watch is over-eager awaiting: const resolvedParams = await params; at the very top of a large component, before any conditional or Suspense-gated branch, reintroduces the exact bottleneck Next.js is trying to remove — it forces the whole subtree to wait on the dynamic data even for branches that don't need it. Await it as close as possible to where you actually use the value.
7. Best Practices & The Better Design
Push the await params / use(params) call down to the smallest component that actually needs the value, and keep everything above it — the layout chrome, the static parts of the page — outside the dynamic dependency entirely. Concretely:
// app/articles/category/[category]/page.tsx
import { Suspense } from "react";
type Props = { params: Promise<{ category: string }> };
export default function CategoryPage({ params }: Props) {
return (
<section>
<h1>Articles</h1>
{/* Static shell renders immediately; only this subtree waits on params */}
<Suspense fallback={<p>Loading category…</p>}>
<CategoryBody params={params} />
</Suspense>
</section>
);
}
async function CategoryBody({ params }: Props) {
const { category } = await params;
const posts = await getPostsByCategory(category);
return <PostList posts={posts} />;
}This is the pattern Cache Components is explicitly designed around: the static shell (<h1>Articles</h1>) can be served from cache or prerendered, and only the Suspense-wrapped subtree pays the cost of waiting for the request. It also composes cleanly with TypeScript: model Props with Promise<...> explicitly rather than casting, so a stale synchronous read fails the type check locally instead of surfacing as undefined in production. If you're touching route params from many places in a large tree, resolve them once at the top of the smallest ancestor that legitimately needs them and pass the resolved, plain value down as a normal prop — don't thread the Promise itself through five component layers on the theory that "it'll get awaited eventually."
8. How to Prevent It Long-Term
Run npx @next/codemod@canary upgrade latest for every major Next.js bump instead of hand-editing package.json — it pulls in the matching codemods automatically and flags what it couldn't fix. Keep @next/eslint-plugin-next current in your ESLint config; recent versions flag synchronous params/searchParams access as a lint error, not just a runtime warning, which catches it in CI before a PR merges. Turn on TypeScript strict mode specifically so a typo'd params: { category: string } interface produces the PageProps constraint error at compile time instead of at request time — this is the single highest-leverage guard for this exact bug, since it converts a silent-undefined production bug into a build failure. If you're on Next.js 16, enable cacheComponents: true in a staging environment before you enable it in production; it turns every remaining sync-access site into a hard, addressable error rather than a slow trickle of "why is this field blank" bug reports. Add a Playwright or Cypress check that fails the build on any console error during a smoke-test crawl of your dynamic routes — that catches the Next.js 15-era warning (which doesn't fail the build on its own) before it ships. Finally, grep your codebase for @next-codemod-error before every deploy during a migration window; it's the framework's own todo-list for this exact issue.
9. Key Takeaways
paramsandsearchParams(andcookies(),headers(),draftMode()) becamePromise-based in Next.js 15 to support Partial Prerendering; Next.js 16 removes synchronous access outright, and Cache Components (cacheComponents: true) turns it into a hard build/runtime error.- The dangerous version of this bug isn't the error — it's the silent one: unmigrated code that types
paramsloosely can build clean and serveundefinedin production. - Fix it with
npx @next/codemod@canary next-async-request-api ., then hand-finish anything marked@next-codemod-error;await paramsin Server Components,React.use(params)(React ≥ 19) in Client Components. - Type
params/searchParamsasPromise<T>explicitly so TypeScript'sPagePropsconstraint check catches regressions at compile time instead of at request time. - Await as close to the point of use as possible and wrap the dynamic subtree in
Suspense— that's what lets the rest of the route stay static, which is the entire point of the change.
Related: this connects directly to Suspense-driven data fetching and use() with uncached promises (category 10), and to the "use client"/serialization boundary — since params is a Server Component concept, passing it into a Client Component the way the example above does relies on the same Server→Client props-serialization rules that govern any other prop crossing that boundary.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.