Upgrading to Next.js 16: what actually stops the build
The Next.js docs say what changed. This page says what happens when you run it: a real app on next@14.2.35 upgraded to next@16.4.3, built four times, with the exact error text, exit codes and measurements from the built server recorded at each step. Every number below comes from that run.
The code on this page is an app that runs: 22/22 checks passed on 2026-11-02. Versions run: next@16.4.3, react@19.3.1, typescript@7.0.2, Node.js v26.0.0.
Pick the target version first
On npm today, 2 Nov 2026, next tags latest as 16.4.4; the 14 line ends at 14.2.35, the 15 line at 15.5.28. This page starts at 14.2.35 and goes straight to 16.4.3, because that is what many projects still run. On 15 the steps are the same, minus the React 19 part.
New requirements: Node.js 20.9 or later (18 is no longer supported), TypeScript 5.1 or later, Chrome/Edge/Firefox 111+ and Safari 16.4+.
Codemod first, then read every change
The codemod from the docs: npx @next/codemod@canary upgrade latest. Per the docs it moves next.config to the new turbopack key, migrates next lint to the ESLint CLI, renames middleware to proxy, drops unstable_ prefixes and removes experimental_ppr.
Do not treat the codemod as the job. Run npx next typegen to generate PageProps, LayoutProps and RouteContext (available since 15.5), then let the compiler point at every remaining synchronous params read. In the fixture the build stayed green with the old synchronous read still in place, so the sweep is manual work.
Turbopack by default: a webpack config stops the build
Next.js 16 uses Turbopack for both next dev and next build; --turbopack is no longer needed. If next.config still has a webpack key the build stops: measured exit 1 and the line custom webpack configurations may need to be migrated to Turbopack.
Three fixes from the docs: move the config to the top-level turbopack key; drop it if you do not need it; or keep webpack with next build --webpack. Measured too: passing --turbopack or --webpack explicitly also clears that error, because you chose.
The error text names a trap: the webpack config may have been added by a plugin, not by you. Look in next.config and in every plugin, not only the config file.
params and searchParams: await — and a green build does not save you
This is the change that touches the most files. Next.js 15 allowed synchronous access temporarily; 16 removes it. params (page, layout, route handler, default, metadata images), searchParams, cookies(), headers() and draftMode() are Promises.
The surprise, measured: next@16.4.3 did **not** report an error for a page still reading searchParams synchronously. The build was green, exit 0, and the page was prerendered as static (○ /) because searchParams was an empty object at build time. On the built server GET /?q=banh returned 200 with no “banh” in the HTML: the query string silently stopped working. No error, no warning.
So do not wait for the compiler. Run npx next typegen for PageProps, LayoutProps and RouteContext, then walk every searchParams.x, params.y, cookies(), headers(), draftMode() that is not awaited — and open each route with a real parameter to compare against the old version.
The short TypeScript form: export default async function Page(props: PageProps<'/blog/[slug]'>) then const { slug } = await props.params. You do not restate the shape of params by hand.
import Image from "next/image";
// Next.js 14: searchParams is a plain object, read synchronously, and the type
// checker agrees with that. Removing that sync access is the biggest code
// change on the way to 16.
export default function Page({ searchParams }: { searchParams: { q?: string } }) {
return (
<main>
<h1>Cửa hàng</h1>
<p>Đang tìm: {searchParams.q ?? "chưa nhập"}</p>
<Image src="/anh.png" alt="Một điểm ảnh" width={64} height={64} quality={90} />
</main>
);
}import Image from "next/image";
import Link from "next/link";
// searchParams is a Promise in Next.js 16 — read it with await.
export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const { q } = await searchParams;
return (
<main>
<h1>Cửa hàng</h1>
<p>Đang tìm: {q ?? "chưa nhập"}</p>
<p>
<Link href="/cua-hang/ao-thun">Áo thun</Link>
</p>
<Image src="/anh.png" alt="Một điểm ảnh" width={64} height={64} quality={90} />
<Image src="/anh.png" alt="Một điểm ảnh" width={64} height={64} quality={65} />
</main>
);
}// params is a Promise too, in pages, layouts, route handlers and metadata images.
export default async function Product({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return (
<main>
<h1>Sản phẩm</h1>
<p>Mã: {slug}</p>
</main>
);
}middleware.ts becomes proxy.ts
The middleware file is renamed proxy, and so is the exported function. Its runtime is Node.js and cannot be changed; edge is still available only through middleware.
Two states measured. One: middleware.ts alone on 16.4.3 still builds, with The "middleware" file convention is deprecated. Please use "proxy" instead. and the route list still prints Proxy (Middleware). Two: keep both files and the build stops — Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected. So a rename must delete the old file, not sit beside it.
After the rename, measured on the built server: GET /cu returns 307 /cua-hang, and every response carries x-proxy: 1, set by the proxy. Config flags were renamed with it, for example skipMiddlewareUrlNormalize to skipProxyUrlNormalize.
import { NextResponse, type NextRequest } from "next/server";
// Next.js 15: the file is middleware.ts and the export is middleware.
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set("x-proxy", "1");
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};import { NextResponse, type NextRequest } from "next/server";
// Next.js 16: middleware.ts is now proxy.ts, and the function is named proxy.
// The runtime is Node.js and cannot be changed.
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname === "/cu") {
return NextResponse.redirect(new URL("/cua-hang", request.url));
}
const response = NextResponse.next();
response.headers.set("x-proxy", "1");
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};next/image: four defaults changed
images.qualities now defaults to [75]. A quality that is not listed is coerced to the closest value, with no warning. Measured on the fixture (list [50, 75, 90, 100]): quality={90} becomes q=90, quality={65} becomes q=75.
images.minimumCacheTTL goes from 60 seconds to 4 hours. Measured on an optimised image: cache-control: public, max-age=14400, must-revalidate.
Three more: 16 is gone from the default imageSizes; a local image with a query string needs images.localPatterns.search; images.maximumRedirects now defaults to 3. And images.domains is deprecated — measured warning images.domains is deprecated in favor of images.remotePatterns; replace it with remotePatterns.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
// Next.js 16 allows only quality 75 unless you list the others. 90 is the
// value this app renders with; 65 is deliberately missing, to show coercion.
qualities: [50, 75, 90, 100],
},
};
export default nextConfig;next lint is gone
The next lint command is not in Next.js 16. Measured: running next lint on 16.4.3 prints Invalid project directory provided, no such directory: …/lint and exits 1 — Next reads lint as a project directory name.
next build no longer lints either, and the eslint key in next.config is removed. Run ESLint or Biome directly in CI; the next-lint-to-eslint-cli codemod moves the config.
Parallel route slots need default.js
The 16 docs require a default.js in every parallel route slot; the fixture adds app/@modal/default.tsx returning null. In this run that slot had no child routes — do not rely on a build error to find a missing default.js; read the slot directories in app/.
// Next.js 16: every parallel route slot needs an explicit default file, or the
// build fails. This slot has nothing to render until a child route fills it.
export default function ModalDefault() {
return null;
}What else was removed (not run here)
Removed outright: AMP (amp config, next/amp), serverRuntimeConfig/publicRuntimeConfig (use environment variables), the old devIndicators options, experimental.dynamicIO (now top-level cacheComponents), unstable_rootParams (now rootParams).
Caching APIs changed shape: revalidateTag takes an extra profile argument, and there are updateTag, refresh, cacheLife, cacheTag. The App Router uses React 19.2 (View Transitions, useEffectEvent, Activity), and the React Compiler is stable behind reactCompiler: true.
The fixture uses none of these, so this page states them from the docs and does not claim they were run.
The order to do it in, in one sitting
1. npm install next@latest react@latest react-dom@latest, plus new @types/react and @types/react-dom. 2. npx @next/codemod@canary upgrade latest, read the diff before committing. 3. Remove webpack from next.config; move to turbopack or use --webpack. 4. npx next typegen, then add await in params, searchParams, cookies, headers, draftMode. 5. Rename middleware.ts to proxy.ts — delete the old file. 6. Go through next/image: qualities, minimumCacheTTL, imageSizes, localPatterns. 7. Drop next lint from the scripts; run ESLint or Biome in CI. 8. next build green, then next start and open every route.
Do not skip step 8: a green build only proves the code compiles. In this run the page that still read searchParams synchronously built, answered 200, and ignored the query string — only opening the route shows it.
Checked
- 1. next@14.2.35 builds the app as it was:
exit 0 - 2. the same code on 16 fails to build:
exit 1 - 2. the build says why: the custom webpack config:
custom webpack configurations may need to be migrated to Turbopack. - 3. with the webpack config gone the old code builds on 16:
exit 0 - 3. but the build names the file that was renamed:
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. - 3. and the images.domains config:
⚠ `images.domains` is deprecated in favor of `images.remotePatterns`. Please update next.config.js to protect your application from malicious users. - 3. the page built this way answers:
200 - 3. the query string reaches it:
200 q=banh NOT rendered - 4. the upgraded app builds on 16.4.3:
exit 0 - 5. `next lint` was removed — next now reads `lint` as a project directory:
exit 1: Invalid project directory provided, no such directory: …/lint - /: 200 with the page:
200 - /: searchParams awaited, the default renders:
Đang tìm: chưa nhập - /: proxy.ts runs (x-proxy header set by the proxy):
1 - /: the route is dynamic because it reads searchParams:
private, no-cache, no-store, max-age=0, must-revalidate - /cua-hang?q=banh: the query string reaches the page:
Tìm: banh - /cua-hang/ao-thun: awaited params render the slug:
Mã: ao-thun - /cu: proxy.ts redirects the old path:
307 /cua-hang - /: quality={90} is served at 90 because images.qualities lists it:
90 - /: quality={65} is not listed, so it is coerced to the closest listed value (75):
75 - /_next/image: the optimized image is served:
200 - /_next/image: default minimumCacheTTL is 4 hours (14400 s):
max-age=14400 - /: page size before compression:
6536 bytes
Re-run the checks: bun scripts/guides/verify-next-16.ts
Related
- Multilingual Next.js: /vi and /en with next-intl
- What is Next.js? Frontend or backend, and when to use it
- Next.js security advisories
- Check the Next.js version of your site
Sources
- Next.js docs — Upgrading to Version 16
- Next.js docs — Codemods (the `upgrade` codemod)
- Next.js docs — proxy.js (middleware renamed in v16.0.0)
- Next.js docs — next/image
- Next.js docs — the next CLI (next lint removed, next typegen)
- Next.js 16 release notes
- React 19.2 release
- npm registry — next dist-tags
Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.