Type something to search...
Next.js Upgrading to version 16

Next.js Upgrading to version 16

This is the most relevant of the three upgrade guides in this series for anyone reading this blog specifically, since this project itself runs Next.js 16.3.0 — everything below reflects the version this codebase is actually on, not a historical jump we're documenting for completeness. If you've read the AGENTS.md note this project ships with warning that "this is NOT the Next.js you know," this upgrade guide is exactly the reason that warning exists: version 16 changes more surface area than 14 and 15 combined, and several of those changes are things a coding assistant's training data will confidently get wrong unless it's actually reading version-matched docs.

The recommended path: have an AI agent do the mechanical work

Next.js's own guide leads with this option first, which is worth taking seriously rather than treating as a footnote. An AI coding agent can run the codemod, inspect the resulting diff, fix follow-up breakages the codemod couldn't automate, and verify the app afterward — and the guide provides an actual prompt template designed for this:

Upgrade this app to Next.js 16.

Before editing code, make sure AGENTS.md points at version-matched Next.js docs. If it is missing or outdated, follow "Set up AI agent docs", then read AGENTS.md.

Then follow the Next.js 16 upgrade guide as the source of truth for the migration. Use the codemod when you're ready to run the mechanical upgrade.

Briefly explain the upgrade plan in user-facing language before making broad changes. Follow the documented defaults and keep moving unless the guide requires a project-specific decision, the change is destructive, credentials or environment setup are missing, or the correct migration is ambiguous. Keep the migration scoped to the upgrade, inspect the diff, run the relevant checks, and fix remaining breaking changes.

After the app is upgraded, use the runtime verification flow from the AI Coding Agents guide to confirm it still works. Prefer the `next-dev-loop` skill when it is available; otherwise fall back to the best available `next dev`, browser, and build checks. Open the key interactive UI states and check the Next dev indicator plus browser and server logs. Summarize what changed, what was verified, and what could not be verified.

Before finishing, repeat the post-upgrade check in "Set up AI agent docs" so the project is ready for future agent work.

The instruction to check AGENTS.md before editing anything is the load-bearing detail here — an agent whose docs setup points at stale or missing documentation is working from training data that predates this exact version's breaking changes, which is precisely the trap this project's own AGENTS.md file exists to prevent.

Or upgrade manually — the four-step sequence

  1. Set up AI agent docs, if you use an assistant now or want future agent work grounded in accurate, version-matched documentation.
  2. Run the upgrade codemod.
  3. Or install packages manually, if you'd rather skip the codemod.
  4. Work through the remaining breaking changes below, running checks and fixing issues as you go.

Setting up AI agent docs

npx @next/codemod@canary agents-md

After upgrading, verify AGENTS.md still points at the docs for whatever version you actually have installed. On Next.js 16.2 and later, that should be the bundled docs shipped directly in node_modules/next/dist/docs/ — the same location this project's own AGENTS.md points to. If your pre-upgrade setup instead downloaded docs into a separate .next-docs/ folder (an older pattern), update the reference to point at the bundled docs and remove .next-docs/ once nothing references it anymore.

The managed block should look exactly like this:

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->

If that block looks exactly familiar, it's because it's the identical block sitting in this project's own AGENTS.md right now — this isn't a hypothetical example, it's literally what's driving how any coding agent working on this specific project is instructed to behave.

Running the codemod

# npm
npx @next/codemod@canary upgrade latest

# yarn
yarn dlx @next/codemod@canary upgrade latest

# pnpm
pnpm dlx @next/codemod@canary upgrade latest

# bun
bunx @next/codemod@canary upgrade latest

It handles: updating next.config.js to the new top-level turbopack key, migrating off next lint onto the ESLint CLI, renaming middleware to proxy, stripping the unstable_ prefix from now-stable APIs, and removing experimental_ppr route-segment config.

It does not run every possible migration codemod automatically. If your app still uses synchronous access to params, searchParams, cookies(), headers(), or draftMode() left over from the version 15 compatibility window, run that codemod explicitly as a separate step:

npx @next/codemod@canary next-async-request-api .

Installing manually

# npm
npm install next@latest react@latest react-dom@latest

# yarn
yarn add next@latest react@latest react-dom@latest

# pnpm
pnpm add next@latest react@latest react-dom@latest

# bun
bun add next@latest react@latest react-dom@latest

Bump @types/react and @types/react-dom too if you're on TypeScript.

Node.js, TypeScript, and browser requirements all moved up

RequirementChange
Node.js 20.9+New minimum (LTS); Node 18 is no longer supported at all
TypeScript 5+New minimum, specifically 5.1.0
BrowsersChrome 111+, Edge 111+, Firefox 111+, Safari 16.4+

Check your actual deployment runtime's Node version before anything else — a CI pipeline or hosting platform still pinned to Node 18 will fail this upgrade outright, and that failure often surfaces as a confusing, unrelated-looking error rather than a clear "unsupported Node version" message.

Turbopack is now the default — for both dev and build

This is arguably the single most visible change in this version. next dev and next build both use Turbopack by default now — the --turbopack flag you may have been passing explicitly is no longer necessary:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

The consequence that actually bites people: if your project has a custom webpack config and you run next build, the build fails outright, specifically to prevent a misconfigured project from silently ignoring settings it was relying on. You have three ways to handle this:

  • Build with Turbopack anyway: next build --turbopack, accepting that your webpack config is ignored.
  • Migrate your webpack config to Turbopack-compatible options and go all-in on Turbopack.
  • Keep webpack for builds specifically, using next build --webpack, while still using Turbopack for dev.
{
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start"
  }
}

Worth knowing: if you see a build failing due to a "webpack configuration was found" error but you don't recall configuring webpack yourself, check whether a plugin in your dependency tree is silently adding one — this is a genuinely common false alarm.

The turbopack config key also moved out of experimental, up to the top level:

// Next.js 15
const nextConfig: NextConfig = {
  experimental: { turbopack: {/* options */} },
};

// Next.js 16
const nextConfig: NextConfig = {
  turbopack: {/* options */},
};

Two smaller Turbopack-specific gotchas worth flagging: Turbopack's resolve.fallback equivalent is turbopack.resolveAlias, useful if client code accidentally imports a Node-native module (fs, most commonly) and you need to silence the resulting error rather than fully refactoring the import away immediately. And Turbopack does not support the legacy tilde prefix for Sass node_modules imports — @import '~bootstrap/...' needs to become @import 'bootstrap/...' (or use resolveAlias with a '~*': '*' mapping if rewriting every import isn't immediately practical).

The async Request APIs compatibility window is now fully closed

This is the direct continuation of the version 15 change, and it's the one most likely to bite an app that's been coasting on the "temporary" synchronous compatibility path. Synchronous access to cookies(), headers(), draftMode(), params, and searchParams is completely removed now — not deprecated-with-a-warning anymore, just gone. If your codebase still has any UnsafeUnwrappedCookies-style casts left over from the version 15 transition, this is where they finally stop working.

Run npx next typegen to generate globally-available, type-safe helpers (PageProps, LayoutProps, RouteContext) that make the async migration considerably less error-prone to hand-write:

export default async function Page(props: PageProps<"/blog/[slug]">) {
  const { slug } = await props.params;
  const query = await props.searchParams;
  return <h1>Blog Post: {slug}</h1>;
}

Image and OG-image generation functions also became async

Less commonly hit than the main async-APIs change, but easy to miss if you're specifically generating dynamic OG images or icons: the props passed into opengraph-image, twitter-image, icon, and apple-icon generating functions are now Promises too. generateImageMetadata itself still receives synchronous params — it's specifically the image-generating function's params and id that changed:

export async function generateImageMetadata({ params }) {
  const { slug } = params; // still synchronous here
  return [{ id: "1" }, { id: "2" }];
}

export default async function Image({ params, id }) {
  const { slug } = await params; // now async
  const imageId = await id; // now Promise<string>
}

The same pattern extends to generateSitemaps — the id values it returns arrive as a Promise in the corresponding sitemap generating function now, not a plain number.

React 19.2 and its headline features

The App Router runs on a React canary release that includes React 19.2's newly-stabilized-or-stabilizing features: View Transitions (declarative element animation across a Transition or navigation, covered in depth in this blog's dedicated view-transitions article), useEffectEvent (extracting non-reactive logic out of Effects into reusable functions), and Activity (rendering "background" UI hidden with display: none while preserving its state and running effect cleanup correctly).

React Compiler support is now stable — but off by default

The reactCompiler config option is promoted out of experimental, following the React Compiler's own 1.0 release. It's not enabled by default, deliberately, while more build-performance data accumulates across different kinds of applications:

const nextConfig: NextConfig = {
  reactCompiler: true,
};

Install its Babel plugin dependency:

npm install -D babel-plugin-react-compiler

Set realistic expectations before flipping this on: compile times in both dev and production builds go up when this is enabled, since the compiler relies on Babel — this is a genuine trade-off (automatic memoization, fewer manual useMemo/useCallback calls needed) against build-time cost, not a free upgrade.

Caching API changes worth knowing even if you don't touch them directly

revalidateTag now requires a second argument — a cacheLife profile — and the old single-argument call is a TypeScript error now, not just a soft deprecation:

// Before
revalidateTag("posts");

// After
revalidateTag("posts", "max");

updateTag is new, and specifically Server-Actions-only — it gives you read-your-writes semantics (the user's own change appears immediately, rather than the stale-then-revalidate pattern revalidateTag implies) by expiring and refreshing data within the same request:

"use server";

import { updateTag } from "next/cache";

export async function updateUserProfile(userId: string, profile: Profile) {
  await db.users.update(userId, profile);
  updateTag(`user-${userId}`); // user sees their own change immediately
}

Use revalidateTag where a brief delay before fresh data appears is acceptable (blog posts, product catalogs); use updateTag where the user needs to see their own change reflected right away (profile settings, any form submission the user expects to "just work").

refresh() is new too, letting you refresh the client router directly from within a Server Action — useful for things like a notification counter in a header that needs updating after a background action, without a full navigation.

cacheLife and cacheTag are stable now, dropping the unstable_ prefix — the codemod covered above handles this rename automatically if you have existing aliased imports.

middleware is now proxy

The middleware.ts/middleware.js filename convention is deprecated in favor of proxy.ts/proxy.js — a rename specifically meant to clarify that this file is about network boundary and routing concerns, not an arbitrary "middleware" catch-all.

mv middleware.ts proxy.ts
export function proxy(request: Request) {}

One functional restriction worth knowing, not just a naming change: the edge runtime is not supported in proxy — it's fixed to nodejs and can't be configured otherwise. If your existing middleware.ts specifically relies on the edge runtime, keep using middleware for now rather than renaming it, since renaming to proxy would silently change your runtime guarantees, not just your filename. Related config flags renamed alongside this (skipMiddlewareUrlNormalizeskipProxyUrlNormalize, and similar) are handled by the same version-16 codemod.

next/image changed in several small, breaking ways

Local images with query strings now require explicit images.localPatterns.search configuration, specifically to prevent enumeration attacks:

const nextConfig: NextConfig = {
  images: {
    localPatterns: [{ pathname: "/assets/**", search: "?v=1" }],
  },
};

minimumCacheTTL's default jumped from 60 seconds to 4 hours. This was a deliberate cost-reduction change — images missing a cache-control header from their upstream source were revalidating every 60 seconds by default, which added up to real CPU cost for very little practical benefit, since most images don't change that often. Restore the old value explicitly if you genuinely need it: images: { minimumCacheTTL: 60 }.

16 was dropped from the default imageSizes array, based on usage data showing very few real requests for 16px-wide images (retina displays typically request 32px instead, given devicePixelRatio: 2). Add it back explicitly if your app is an exception to that pattern.

qualities's default narrowed from "all qualities" to just [75]. A quality prop outside your configured list gets coerced to the nearest allowed value now, rather than passed through as-is — if you rely on multiple specific quality levels, list them explicitly: images: { qualities: [50, 75, 100] }.

Local IP optimization is blocked by default, a security restriction you can lift with images.dangerouslyAllowLocalIP: true — but only do this for genuinely private networks, and only once you understand you're accepting an SSRF-adjacent risk by doing so.

maximumRedirects defaults to 3 rather than unlimited — adjust with images.maximumRedirects if your image source legitimately needs more, or set it to 0 to disable redirect-following entirely.

next/legacy/image and images.domains are both deprecated (not yet removed, but on notice) — migrate to plain next/image and images.remotePatterns respectively when you touch this part of the codebase next.

Parallel routes now require an explicit default.js

Every parallel-route slot needs its own default.js now — builds fail without one, where previously an implicit fallback existed. To preserve the old behavior exactly:

import { notFound } from "next/navigation";

export default function Default() {
  notFound();
}

Or, if returning nothing is the correct behavior for that slot instead:

export default function Default() {
  return null;
}

Several things were removed outright, not merely deprecated

AMP support is completely gone — the config, next/amp's useAmp hook, and the page-level config = { amp: true } convention. If your app still genuinely depends on AMP, this version is a hard stop until you remove that dependency, since there's no compatibility flag to fall back on.

next lint is removed. Use ESLint (or Biome) directly instead — next build no longer runs linting as a side effect either. The next-lint-to-eslint-cli codemod automates the migration, including generating a working flat-config eslint.config.mjs.

serverRuntimeConfig and publicRuntimeConfig are both removed. Environment variables are the replacement — server-only values read directly via process.env in Server Components (paired with the taint API if you want to actively prevent an accidental leak to a Client Component), and client-exposed values via the NEXT_PUBLIC_ prefix convention. If you need a value read fresh at request time rather than baked in at build time, pair it with connection() before reading process.env.

Several devIndicators sub-options are gone (appIsrStatus, buildActivity, buildActivityPosition) — the indicator itself remains, just with a smaller configuration surface.

experimental.dynamicIO and experimental.useCache are both removed. If you were actively using either, migrate to the stable, top-level cacheComponents flag instead — but know that this isn't a rename-only change; enabling cacheComponents can surface new build errors for uncached data sitting outside a <Suspense> boundary, and requires genuinely adopting the Cache Components mental model, not just flipping a flag. This blog's dedicated Cache Components migration article covers that transition specifically.

unstable_rootParams is removed — use next/root-params instead.

Smaller behavioral changes worth a mention

Partial Prerendering's standalone experimental flag is gone, replaced by the cacheComponents config option — and PPR itself now works differently under Cache Components than it did in the Next.js 15 canary releases some teams were already running. If you were on 15's canary PPR, read the dedicated Cache Components migration guide rather than assuming a direct equivalence.

next dev and next build now use separate output directories (next dev writes to .next/dev), which enables running both concurrently — something a lockfile mechanism now actively prevents you from doing accidentally with two instances of the same command simultaneously.

Scroll-behavior override during navigation is now opt-in, not automatic. Previously, Next.js would temporarily disable a global scroll-behavior: smooth during route transitions to keep navigation feeling instant, then restore it — that override no longer happens by default. If you want the old behavior back, add data-scroll-behavior="smooth" to your <html> element explicitly.

Build output dropped the size / First Load JS metrics. They were found to be unreliable specifically in Server-Component-driven architectures, where Turbopack and webpack disagreed about how to account for Client Component payload size. Use Chrome Lighthouse or a real analytics tool for actual route performance measurement going forward, rather than looking for these numbers in your build log.

next.config.js is read only once now during next dev, not twice as before — a side effect worth knowing if any plugin's config file checks process.argv.includes('dev') expecting true; that check now returns false during dev specifically because of this optimization. Check process.env.NODE_ENV === 'development' instead if you need that same signal.

Common mistakes

Assuming a custom webpack config "just works" after upgrading. It causes an outright build failure by design — pick one of the three explicit paths (Turbopack anyway, migrate the config, or --webpack for builds specifically) rather than being surprised by the failure.

Leaving UnsafeUnwrapped*-cast code from a version 15 migration untouched. That compatibility path is fully gone now, not just discouraged — any lingering instance is a hard error, not a warning, in this version.

Not noticing the next/image default changes because nothing failed to build. minimumCacheTTL, imageSizes, and qualities are exactly the kind of silent-default-change category covered elsewhere in this series — worth deliberately auditing rather than trusting a clean build to have caught them.

Renaming middleware.ts to proxy.ts without checking for an edge-runtime dependency first. If the original file specified runtime: 'edge', renaming it changes your actual runtime guarantee, not just the filename — verify this before running the codemod blindly.

Key Takeaways

QuestionAnswer
What's the biggest single change in this version?Turbopack as the default bundler for both dev and build
Is synchronous access to cookies()/params/etc. still possible?No — the version 15 compatibility window is fully closed
What replaced middleware.ts?proxy.ts — but note the edge runtime isn't supported there
Does my webpack config still work with next build?Only with --webpack explicitly, or after migrating to Turbopack config
Is the React Compiler on by default?No — opt in via reactCompiler: true in next.config.ts
What should I check first, before anything else?Your actual deployment Node.js version — 20.9+ is now required

This is genuinely the largest of the three upgrades covered in this series, and it's the one most worth having version-matched AI agent docs set up for before you start — several of these changes (Turbopack defaults, the proxy rename, the closed async-API compatibility window) are exactly the kind of thing a coding assistant's training data predates, which is the entire reason this project's own AGENTS.md carries the warning it does.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper