Last Tuesday I ran npx @next/codemod@canary upgrade latest on a content site I had been sitting on for months at version 14.2. The upgrade itself finished in about ninety seconds. The build that followed printed 47 type errors in red, all of them the same shape: Type '{ slug: string; }' is missing the following properties from type 'Promise<{ slug: string; }>': then, catch, finally.... Every dynamic route in the project, every generateMetadata, every page.tsx that touched params or searchParams was suddenly wrong. If you have just hit the same wall after upgrading to Next.js 15 or 16, this is the playbook I used to get the site green again, and the handful of edge cases the official codemod silently skipped.
Next.js 15 params Is Now a Promise: My 47-Page Migration Fix
A practical walkthrough of migrating a real Next.js App Router project when params and searchParams became Promises, with the official codemod, manual fixes, and the client-component gotchas I hit.

The change landed in Next.js 15.0 in October 2024 and was tightened up through the 15.x line before becoming the only supported shape in 16.x. The motivation, as the Next.js team explained in their 15.0 release post, is that params, searchParams, cookies(), headers(), and draftMode() are all request-scoped values. Treating them as synchronous forced the framework to block the entire route render until those values were known, even for parts of the tree that did not need them. Making them asynchronous lets Next.js start rendering the static shell of a page, stream it, and only suspend the subtree that actually awaits the dynamic value.
In practice that means a layout that does not read params can be served instantly while the page underneath suspends on await params. The win is most visible on routes that use partial prerendering, but every dynamic route gets a small streaming improvement for free. The trade-off is the migration tax we are about to pay.
As of May 2026 the codemod is shipped, stable, and covers about 90 percent of the cases in a typical App Router project. The remaining 10 percent is what eats the afternoon.
The symptom: every dynamic route fails to build
If you upgraded blind, the first thing you will see is a wall of TypeScript errors. A page that used to look like this:
// app/blog/[slug]/page.tsx — old, Next.js 14
type Props = {
params: { slug: string };
searchParams: { preview?: string };
};
export default function ArticlePage({ params, searchParams }: Props) {
const article = getArticle(params.slug);
const isPreview = searchParams.preview === '1';
return <Article data={article} preview={isPreview} />;
}
export async function generateMetadata({ params }: Props) {
const article = await getArticle(params.slug);
return { title: article.title };
}
...will throw three distinct errors after the upgrade. The Props type is wrong, the destructuring is wrong, and params.slug is wrong because params is no longer an object — it is a thenable that resolves to one. At runtime, in development mode, Next.js will also log a console warning: A param property was accessed directly with 'params.slug'. 'params' should be awaited before accessing its properties.
That warning is the diagnostic gold. If you see it for a page that does already await something, it means a child component is the culprit, not the page file itself. I lost twenty minutes on one route before realising the offender was a breadcrumb component three levels deep that I had forgotten about.
The fix: run the codemod, then clean up what it missed
The codemod is the right starting point. Do not write a regex. The Next.js team has shipped a real AST transform that handles destructuring, type annotations, and the two flavours of component (server and client). From the repo root:
npx @next/codemod@canary next-async-request-api .
That command rewrites every page.tsx, layout.tsx, route.ts, and generateMetadata export it can find under the working directory. After it ran on my project, a typical server-component page looked like this:
// app/blog/[slug]/page.tsx — after codemod
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ preview?: string }>;
};
export default async function ArticlePage(props: Props) {
const params = await props.params;
const searchParams = await props.searchParams;
const article = await getArticle(params.slug);
const isPreview = searchParams.preview === '1';
return <Article data={article} preview={isPreview} />;
}
export async function generateMetadata(props: Props) {
const params = await props.params;
const article = await getArticle(params.slug);
return { title: article.title };
}
Two things to notice. First, the page component is now async — that is fine, App Router server components have always supported async. Second, the codemod intentionally awaits the whole object up front rather than awaiting props.params inline at each usage site. That keeps the call site readable and avoids accidentally awaiting the same Promise twice, which would deopt streaming.
For most projects, that is 80 to 90 percent of the work. What follows is the residue.
Client components need the use hook, not await
The codemod handles client components, but the result trips a lot of people up because it uses a React hook that did not exist in stable React until 19. If your page has 'use client' at the top, it cannot be async — that is a hard React rule. Instead, the codemod rewrites it to use React's use hook, which unwraps a Promise inside a synchronous render:
'use client';
import { use } from 'react';
type Props = {
params: Promise<{ id: string }>;
};
export default function Dashboard(props: Props) {
const params = use(props.params);
return <div>Dashboard for {params.id}</div>;
}
The React docs for use are worth reading once if you have not seen the hook before. It is the only hook that is allowed to be called conditionally and inside loops, and it suspends the component until the Promise settles, integrating with the nearest Suspense boundary. If your client component does not sit under a <Suspense> in the route tree, add one in the parent server component or the page will hard-suspend with no fallback.
Custom Props types and shared interfaces
The codemod only rewrites types it can statically resolve from the page file. If you had a shared RouteProps interface imported from @/types/routing.ts, the codemod will leave the import alone and your build will still fail. I had a tidy little shared type that broke 12 pages at once. The fix is to update the source:
// types/routing.ts
export type RouteParams<T extends Record<string, string | string[]>> = {
params: Promise<T>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
Once that single file is updated, every consumer compiles again. This is the case where TypeScript actually saves time — the errors point you straight at the shared type rather than at each of the 12 pages.
Route handlers and middleware
API route handlers in app/api/**/route.ts got the same treatment. The second argument's params field is now a Promise. The codemod catches the common case, but if you do anything fancy with the handler signature — say, wrapping it in a higher-order function for auth — you will have to update the wrapper too. Here is the shape:
// app/api/posts/[id]/route.ts
type Context = { params: Promise<{ id: string }>};
export async function GET(_req: Request, ctx: Context) {
const { id } = await ctx.params;
const post = await db.post.findUnique({ where: { id } });
return Response.json(post);
}
Middleware is unaffected — it never had access to typed params in the first place, only the request URL. cookies() and headers(), however, are now async everywhere they are used (server components, server actions, route handlers, middleware). The codemod handles those too, but double-check any utility module that calls them outside of a request handler — for example a logging helper that grabs the user agent — because those modules are easy to miss.
The caveats the docs do not shout about
Three things bit me after the migration looked clean.
Awaiting params twice in the same render deopts streaming. If you destructure once at the top and then pass the resolved object down as a prop, you are fine. If you pass the raw Promise down and a child also awaits it, React will wait for both before flushing, which negates the streaming win. The pattern I settled on: page components own the await, child components receive plain resolved values.
generateStaticParams still returns a synchronous array. This is the one place in the App Router where params-shaped data is not a Promise. The function itself can be async (it usually is, since you are fetching slugs from a CMS), but the returned array entries are plain objects:
export async function generateStaticParams() {
const slugs = await fetchAllSlugs();
return slugs.map((slug) => ({ slug })); // not a Promise
}
Direct property access still works in dev — for now. Next.js 15 ships a temporary compatibility layer that lets params.slug work synchronously while logging a warning. As of Next.js 16, this is gone and the access throws. The official upgrade guide is explicit that the shim is removed in 16, so do not lean on it. Treat every dev warning as a future hard error.
Closing thoughts after living with it for a month
The pain of this migration is front-loaded. Once the codemod has run and the residue is cleaned up, day-to-day work is genuinely nicer. The mental model — "request-scoped values are async, await them when you need them" — is more honest than the synchronous fiction that came before. And the streaming improvements are real on routes with heavy below-the-fold content. The site I migrated saw a 180ms improvement on time-to-first-byte for its article pages, mostly because the layout no longer blocks on the slug lookup.
If you are sitting on a Next.js 14 project and putting off the upgrade because you have heard horror stories, the honest answer is: budget half a day for a medium-sized App Router project, run the codemod first, fix shared types second, and verify your 'use client' pages are wrapped in Suspense third. If you want to keep going, I have written up the related cookies and headers async migration separately, and the Suspense patterns that actually stream piece covers the boundary placement question in more depth.
FAQ
Do I have to migrate if I am still on Next.js 14?
Not immediately. Next.js 14 will keep working and 15 ships the sync-access shim. But the shim is removed in 16, which is the current stable as of mid-2026, so any new feature work you want is gated on the migration. Do it once, intentionally, rather than under deadline pressure later.
Can I use await in a client component?
No. The React rules forbid async client components because the render function must be synchronous. Use the use hook from React 19 instead, and make sure the component renders under a Suspense boundary. If you cannot place a boundary, lift the data fetch to the parent server component and pass the resolved value down as a prop.
Why does the codemod skip my custom Props type?
The codemod only rewrites type annotations it can resolve inside the page file. Imported types from another module are left alone because the codemod does not follow imports. Update the shared type by hand — it is usually a one-line change that fixes many pages at once.
Does this affect Pages Router projects?
No. Pages Router uses getServerSideProps and getStaticProps, which have always been async and have their own context object. The Promise-based params change is App Router only. If you are mid-migration from Pages to App Router, do the framework upgrade first and the router migration second — fighting both at once is miserable.
Article changelog (1)
- — Expanded with TL;DR, table of contents, or additional sections


