Most of the Next.js 16 upgrade is mechanical. A codemod handles the renames, and the rest is a list of defaults that changed underneath you. The work is in knowing which items apply to your app, so this post goes through them in the order we hit them on real projects.
Do the upgrade on its own branch, and resist the urge to adopt new features at the same time. The official checklist says it plainly: React Compiler and Cache Components should not be enabled merely to complete the upgrade.
Check the minimums first#
Next.js 16 raises the floor for Node.js, TypeScript and browsers:
| Requirement | Next.js 16 |
|---|---|
| Node.js | 20.9.0 or later (LTS). Node.js 18 is no longer supported |
| TypeScript | 5.1.0 or later |
| Browsers | Chrome 111+, Edge 111+, Firefox 111+, Safari 16.4+ |
Check your CI images, Dockerfiles and hosting runtime settings, not just your laptop. A pinned Node 18 in a GitHub Actions workflow is the most common way an upgrade passes locally and fails in the pipeline.
Run the upgrade#
Run the upgrade codemod
The
upgradecodemod bumps the packages and applies the version 16 transforms in one pass.pnpm dlx @next/codemod@canary upgrade latestnpx @next/codemod@canary upgrade latestyarn dlx @next/codemod@canary upgrade latestbunx @next/codemod@canary upgrade latestAccording to the docs, it can update
next.config.jsto the newturbopackconfiguration, migrate fromnext lintto the ESLint CLI, migrate the deprecatedmiddlewareconvention toproxy, remove theunstable_prefix from stabilized APIs, and remove theexperimental_pprRoute Segment Config from pages and layouts.Run the async Request APIs codemod if you still use sync access
The
upgradecodemod does not run every migration. If your code still readsparams,searchParams,cookies(),headers()ordraftMode()synchronously, run this one too:npx @next/codemod@canary next-async-request-api .Or install the packages by hand
If you would rather skip the codemod, install the latest versions directly and work through the changes below yourself:
npm install next@latest react@latest react-dom@latestOn TypeScript projects, also update
@types/reactand@types/react-domto their latest versions.Work through the breaking changes
The rest of this post. Run your build, your tests and the app itself after each section, because a green build alone does not prove runtime behaviour is intact.
Turbopack is now the default#
Turbopack is stable in Next.js 16 and is used by default for both next dev and next build. The --turbopack and --turbo flags you added in 15 are no longer needed.
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start"
}
}{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}What happens to a custom webpack config#
If your project has a custom webpack function in next.config and you run next build, the build now fails on purpose, so a webpack-only setup is not silently ignored. You have three options:
- Use Turbopack anyway: run
next build --turbopack, which builds with Turbopack and ignores thewebpackconfig. - Migrate: move your webpack customizations to Turbopack-compatible options.
- Keep webpack: opt out with the
--webpackflag.
If you never wrote a webpack function but the build still complains about one, a plugin in your config is probably adding it.
A reasonable middle ground while you migrate is Turbopack in development and webpack for production builds:
{
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start"
}
}Move the config to the top level#
experimental.turbopack is now a top-level turbopack key. The codemod handles this, but check the result:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
turbopack: {
// options
},
},
};
export default nextConfig;import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
// options
},
};
export default nextConfig;Two webpack habits need translating. Where you used resolve.fallback to silence Can't resolve 'fs' in client bundles, Turbopack offers turbopack.resolveAlias (though the docs recommend fixing the import instead). And Turbopack does not support the legacy ~ prefix for Sass imports from node_modules, so @import '~bootstrap/...' becomes @import 'bootstrap/...'.
Rename middleware to proxy#
The middleware file convention is deprecated and renamed to proxy. Three things change:
- The file:
middleware.tsbecomesproxy.ts, in the same location (project root, orsrcif you use it). - The export: the named export
middlewareis deprecated. Rename the function toproxy. The docs recommend the new name even if you use a default export. - Config flags: options with
middlewarein the name are renamed, for exampleskipMiddlewareUrlNormalizebecomesskipProxyUrlNormalize.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL("/home", request.url));
}
export const config = {
matcher: "/about/:path*",
};import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
return NextResponse.redirect(new URL("/home", request.url));
}
export const config = {
matcher: "/about/:path*",
};The matcher config itself is unchanged. If you want to run just this migration, the dedicated codemod is npx @next/codemod@canary middleware-to-proxy .. Besides the file and function, it renames experimental.middlewarePrefetch, experimental.middlewareClientMaxBodySize and experimental.externalMiddlewareRewritesResolve to their proxy equivalents, along with skipMiddlewareUrlNormalize.
Async Request APIs: sync access is gone#
Next.js 15 made these APIs async but kept a temporary synchronous fallback. In Next.js 16 that fallback is removed, and the following can only be accessed asynchronously:
cookies(),headers()anddraftMode()paramsinlayout,page,route,default,opengraph-image,twitter-image,iconandapple-iconsearchParamsinpage
Pages and layouts#
export default function Page({
params,
}: {
params: { slug: string };
}) {
return <h1>{params.slug}</h1>;
}export default async function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { slug } = await params;
const query = await searchParams;
const sort = typeof query.sort === "string" ? query.sort : "newest";
return (
<h1>
{slug} sorted by {sort}
</h1>
);
}Writing those Promise types by hand gets old. Running npx next typegen (available since 15.5) generates global PageProps, LayoutProps and RouteContext helpers typed from your actual routes:
export default async function Page(props: PageProps<"/blog/[slug]">) {
const { slug } = await props.params;
return <h1>Blog Post: {slug}</h1>;
}Client Components#
A Client Component cannot be async. Unwrap the promise with React's use instead:
"use client";
import { use } from "react";
export default function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = use(params);
return <h1>{slug}</h1>;
}cookies, headers and draftMode#
import { cookies, draftMode, headers } from "next/headers";
export default async function Page() {
const theme = (await cookies()).get("theme")?.value ?? "light";
const agent = (await headers()).get("user-agent") ?? "unknown";
const { isEnabled } = await draftMode();
return (
<p>
{theme} / {agent} / draft mode {isEnabled ? "on" : "off"}
</p>
);
}When the codemod cannot work out a safe fix, it leaves an @next-codemod-error comment and sometimes a temporary UnsafeUnwrapped* cast. Search for both before you call the upgrade done. They compile, but they are markers for unfinished work.
Image and sitemap functions#
Two related changes are easy to miss. The default export of opengraph-image, twitter-image, icon and apple-icon now receives params and id as promises (generateImageMetadata still gets synchronous params). And the sitemap function now receives the id from generateSitemaps as a promise, which resolves to a string, so convert it before doing arithmetic:
import type { MetadataRoute } from "next";
export async function generateSitemaps() {
return [{ id: 0 }, { id: 1 }, { id: 2 }];
}
export default async function sitemap({
id,
}: {
id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
const start = Number(await id) * 50000;
return [{ url: `https://example.com/product/${start}` }];
}next/image changes#
priority is now preload#
The priority prop is deprecated in favour of preload, which says what it does: insert a <link> in the <head> so the image starts loading early.
import Image from "next/image";
export function Hero() {
return <Image src="/hero.jpg" alt="Studio" width={1200} height={600} priority />;
}import Image from "next/image";
export function Hero() {
return <Image src="/hero.jpg" alt="Studio" width={1200} height={600} preload />;
}Do not swap every priority for preload blindly. The image docs say to reserve preload for a single above-the-fold LCP image, not to combine it with loading or fetchPriority, and that in most cases loading="eager" or fetchPriority="high" is the better choice.
Changed defaults#
These are breaking because nothing in your code changes, only the behaviour:
| Setting | Next.js 15 | Next.js 16 |
|---|---|---|
images.minimumCacheTTL | 60 seconds | 14400 seconds (4 hours) |
images.imageSizes | includes 16 | 16 removed |
images.qualities | all qualities | [75] |
images.maximumRedirects | unlimited | 3 |
| Optimizing images from local IPs | allowed | blocked unless images.dangerouslyAllowLocalIP is true |
Local src with a query string | allowed | needs images.localPatterns.search |
The qualities change catches the most people. A quality prop outside the list is coerced to the closest allowed value, and direct /_next/image requests with an unsupported q return 400. If your designs use more than one quality, list them:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
qualities: [50, 75, 100],
localPatterns: [{ pathname: "/assets/**", search: "?v=1" }],
},
};
export default nextConfig;Two deprecations come along with this: next/legacy/image (import from next/image instead) and the images.domains config (use images.remotePatterns).
Removed features#
These were deprecated before and are now gone.
- AMP: the
ampconfig,next/ampanduseAmp, andexport const config = { amp: true }are all removed. next lint: the command is removed, andnext buildno longer runs linting. Use ESLint or Biome directly. Theeslintoption innext.configis removed too. The codemod isnpx @next/codemod@canary next-lint-to-eslint-cli ., which rewrites yourlintscript toeslint .and creates aneslint.config.mjs. Update CI jobs that callnext lint.- Runtime configuration:
serverRuntimeConfigandpublicRuntimeConfigare removed, so code that reads them throughgetConfig()fromnext/configneeds replacing. Readprocess.envdirectly on the server, use theNEXT_PUBLIC_prefix for values the client needs, and callawait connection()fromnext/serverbefore readingprocess.envwhen a value must be read at request time rather than inlined at build. devIndicatorsoptions:appIsrStatus,buildActivityandbuildActivityPositionare removed. The indicator itself stays.experimental.dynamicIOandexperimental.useCache: removed. Move to top-levelcacheComponents: trueonly if you were actually using them.unstable_rootParams: removed in favour ofnext/root-params.- Experimental PPR: the
experimental.pprflag and theexperimental_pprsegment config are removed. PPR now comes throughcacheComponents.
A few smaller behaviour changes are also worth a look: every parallel route slot now needs an explicit default.js or the build fails, next dev writes to .next/dev so it can run alongside next build, @next/eslint-plugin-next defaults to flat config, Next.js no longer overrides scroll-behavior: smooth during navigation unless you add data-scroll-behavior="smooth" to <html>, and next build no longer prints the size and First Load JS columns.
Caching API changes#
The caching APIs got a short list of signature changes:
revalidateTagnow takes acacheLifeprofile as its second argument, for examplerevalidateTag("posts", "max"). The single-argument form is deprecated and is a TypeScript error.updateTagis new. It is Server Actions only and gives read-your-writes semantics, expiring and refreshing data in the same request.refreshis new, for refreshing the client router from a Server Action.cacheLifeandcacheTagare stable, sounstable_cacheLifeandunstable_cacheTagimports becomecacheLifeandcacheTag.
"use server";
import { revalidateTag } from "next/cache";
export async function publishPost(slug: string) {
revalidateTag(`post-${slug}`, "max");
}We cover how these fit together with "use cache" and Cache Components in Caching in Next.js 16, and the Suspense side of it in Streaming with Suspense.
Before you merge#
Our final pass, adapted from the checklist at the end of the official guide:
- Node.js, TypeScript and React versions meet the minimums in CI and production.
devandbuildscripts match the bundler you actually intend to use, including plugins that injectwebpackconfig and any~Sass imports.- Edge middleware was not blindly renamed to
proxy. - No
@next-codemod-errorcomments orUnsafeUnwrappedcasts remain. - Metadata image and sitemap functions await their parameters, and the generated URLs work.
- Image requests succeed under the new
qualities,localPatternsand redirect defaults. revalidateTagcalls pass a profile.- Nothing still uses AMP,
next lint, runtime config or the removed flags. - Parallel route slots have a
default.js.
If you are starting fresh rather than upgrading, our Next.js guide covers a new Next.js 16 project from the start.
