- Published on
Next.js 15 → 16 Migration: What the Official Codemod Misses (and How It Bites You)
- Authors

- Name
- Salman
- X
- @lamebrowndev

The codemod is not enough
The Next.js team ships a codemod with every major release. You run npx @next/codemod@canary upgrade and it does the mechanical work. It updates your imports, renames a few APIs, adjusts your config. It is genuinely useful and you should run it.
But the codemod only handles things that can be safely transformed by reading your source code. It cannot catch semantic changes. It cannot tell you that your caching behavior is now different. It cannot warn you that a function that used to throw synchronously now throws asynchronously. It does not know that your params object is now a Promise.
I migrated three production apps to Next.js 16 in the first week after release. The codemod got maybe 40 percent of the work done. The other 60 percent was me staring at broken pages and trying to figure out what changed. This post is the list of things I found so you do not have to find them the hard way.
What the codemod does handle
Before I get into what it misses, let me be fair about what it does well.
- It updates your
next.config.jsfor deprecated options - It renames
next/navigationimports where the API changed - It handles the
cookies()andheaders()async migration at the call site (addsawait) - It updates
paramsandsearchParamsaccess patterns in some cases - It removes deprecated polyfills
Run it. It saves time. Just do not trust it to finish the job.
What it misses
1. The middleware.ts to proxy.ts rename
The codemod does not rename this file. I have no idea why. It is the most obvious rename in the entire release and the codemod just skips it.
Your middleware keeps working in some setups because of backwards compatibility. In other setups it silently stops running. There is no warning. Your auth redirects just stop happening.
Fix it yourself:
mv middleware.ts proxy.ts
Then update the exported function name from middleware to proxy (or use a default export). I wrote a whole post about this one because it is that common and that dangerous.
2. params and searchParams are Promises now
The codemod tries to add await where it can detect usage. But it misses a lot of cases, especially when you destructure or pass params around.
This breaks:
// app/blog/[slug]/page.tsx
export default function Page({ params }: { params: { slug: string } }) {
return <Post slug={params.slug} />
}
It should be:
// app/blog/[slug]/page.tsx
export default async function Page({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <Post slug={slug} />
}
The codemod will catch the obvious ones where you directly access params.something in the same function. It will miss cases where you pass params to another function, store it in a variable, or destructure it in a wrapper component.
The error you get is either "params should be awaited" or your page just renders with undefined values and you spend an hour wondering why your slug is undefined.
3. cookies() and headers() async return
Same story. The codemod adds await to direct calls but misses indirect usage.
This is what the codemod fixes:
// Before
const token = cookies().get('token')
// After (codemod does this)
const token = (await cookies()).get('token')
But it does not fix this pattern:
// This breaks and the codemod will not catch it
function getAuthContext() {
const allCookies = cookies()
const token = allCookies.get('token')
const userId = allCookies.get('userId')
return { token, userId }
}
You need to await the call and the function itself needs to be async:
async function getAuthContext() {
const allCookies = await cookies()
const token = allCookies.get('token')
const userId = allCookies.get('userId')
return { token, userId }
}
And then every caller of getAuthContext needs to await it too. The codemod cannot trace that chain. You have to do it manually.
4. Caching defaults changed (again)
Next.js 16 changed the default caching behavior for fetch. In Next.js 15 they made fetch not cached by default. In 16 they introduced the use cache directive and changed how the default works depending on whether you are in a Server Component or a Route Handler.
The codemod does not touch your fetch calls. It cannot know what your intent was.
Here is the short version. If you had fetch(url) and relied on it being cached, that might not be the case anymore. If you had fetch(url, { cache: 'no-store' }) and you are now using use cache somewhere in the same file, the behavior can interact in weird ways.
Read my caching post (the one after this) for the full breakdown. The point here is that the codemod does not help with this at all. You need to audit your fetch calls yourself.
5. Turbopack ignoring your webpack config
If you had a webpack function in next.config.js, Turbopack ignores it. The codemod does not migrate it to the turbopack key. It just gets silently dropped.
// This gets ignored in Next.js 16
module.exports = {
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack']
})
return config
}
}
You need to either migrate this to the turbopack key or add --webpack to your build command. I wrote about this in detail in my Turbopack config post.
6. redirect() now throws in more contexts
The redirect() function has always thrown a special error internally to signal the redirect. In Next.js 16 the error type changed and it now throws in a few new contexts where it used to work fine.
If you have this pattern, it breaks:
try {
await someOperation()
redirect('/success')
} catch (error) {
// This now catches the redirect error too
console.log('Operation failed')
redirect('/error')
}
The redirect inside the try gets caught by the catch block. Your user never gets redirected to success. The codemod does not detect this pattern because it requires understanding the control flow.
The fix is to not wrap redirect in a try/catch, or to filter the redirect error:
import { isRedirectError } from 'next/dist/client/components/redirect-error'
try {
await someOperation()
} catch (error) {
if (isRedirectError(error)) {
throw error
}
redirect('/error')
}
redirect('/success')
7. Server Action return types are stricter
In Next.js 15 you could return almost anything from a Server Action and it would get serialized. In 16 the serialization is stricter. If you return a class instance, a Map with non-serializable values, or a Date in some edge cases, you get a runtime error.
The codemod does not check your return types. You find this one at runtime.
'use server'
export async function updateUser(formData: FormData) {
const user = await db.user.update({
where: { id: formData.get('id') },
data: { name: formData.get('name') }
})
// This might break if user has a Date field
// that does not serialize cleanly
return user
}
If you need to return data from a Server Action, return plain objects:
'use server'
export async function updateUser(formData: FormData) {
const user = await db.user.update({
where: { id: formData.get('id') },
data: { name: formData.get('name') }
})
return {
id: user.id,
name: user.name,
createdAt: user.createdAt.toISOString()
}
}
8. The generateMetadata signature changed subtly
If you have a generateMetadata function that reads params, it also needs to await them now:
// Before
export function generateMetadata({ params }: { params: { slug: string } }) {
return {
title: `Post: ${params.slug}`
}
}
// After
export async function generateMetadata({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return {
title: `Post: ${slug}`
}
}
The codemod catches some of these but not all. If your generateMetadata is in a layout file or uses a wrapper, it gets missed.
A migration checklist
Here is what I run through after the codemod finishes:
- Search for
middleware.tsand rename toproxy.ts - Search for
params:andsearchParams:in all page and layout files, make sure they are awaited - Search for
cookies(andheaders(calls, make sure every call is awaited and the containing function is async - Search for
webpack(in next.config and decide: migrate to turbopack key or use--webpackflag - Search for
redirect(inside try blocks and fix the control flow - Search for
returnin Server Action files and make sure you are returning plain objects - Search for
generateMetadataand check the params signature - Run the build, fix every error, then check the pages in the browser because some breaks are silent
The build passing does not mean you are done
The thing that got me on the first migration was that the build passed. No errors. Green checkmark. Then I opened the app and half the pages were broken. Params were undefined. Cookies were not being read. The middleware was not running.
TypeScript will not catch all of these because the types are permissive in some places and the runtime behavior changed in ways that are still technically valid TypeScript. You have to actually click through your app.
Spend 20 minutes clicking every route. Check your auth flows. Check your dynamic pages. Check your API routes. It is tedious but it is faster than getting a bug report from a user at midnight.
I fix Next.js production issues for a living. If you are migrating to 16 and want someone to review your app for the stuff the codemod missed, email me.
