← All writing

What broke when 1,769 files met Next.js 16

12 min read

We skipped a major version across a large financial application. The compiler found the obvious breaks; the dangerous ones were valid code with different meaning.

Anshu KumarFull-Stack Engineer
Share
Coco moves a large application from a tangled old system, across a careful migration bridge, into a cleaner and faster system
Left: Next.js 14 and its implicit contracts. Middle: move each boundary deliberately. Right: Next.js 16 with request timing, caching and packaging made explicit.

The first number in this migration was not a version number. It was 1,769.

That was the number of TypeScript and TSX files under our web application when we moved Multibagg from Next.js 14 to Next.js 16. The same scan found 108 pages, 19 layouts, and 197 route handlers. It was large enough that “upgrade the packages and fix the errors” was not a plan. It was a way to let the build choose the order in which we discovered our architecture.

So I started with an inventory. Not every file changed. The final migration commit touched 110, but every one of those 1,769 files was inside the behavioural blast radius.

1,769TS / TSX files108pages19layouts197route handlersinventory first · upgrade second · otherwise the build becomes the migration plan
The scan was deliberately wider than the diff. Framework migrations change contracts, including contracts in code that still compiles.

The package jump looked short:

Next.js   14.2.3  →  16.2.12
React     18.2.0  →  19.2.8
ESLint    8.x     →  9.x
NextAuth  beta.18 →  beta.30

The real jump crossed two releases' worth of rendering, request, caching, bundling, and runtime changes at once. Some broke loudly. Some kept working with a different meaning. The second group deserved more attention.

This is an App Router migration. The application uses Server Components, Vercel, Prisma, and a monorepo. The inventory method applies more widely, but the request-API and server/client examples below are specifically for App Router applications.

14old contractsync request values · cached fetches15transitionasync APIs arrive · cache defaults flip16enforcedsync access removed · proxy · Turbopack defaultone migration · two releases of changed contracts
Next.js 15 introduced most of the request and caching transition. Next.js 16 removed the remaining compatibility path and changed more of the toolchain.

Before changing a package

Next.js 16 requires Node.js 20.9 or newer and TypeScript 5.1 or newer. We also checked React-related peer dependencies, because updating React itself does not prove that charting, PDF, date-picker, or testing libraries accept React 19.

The official codemod can perform the package upgrade and offer the framework migrations:

npx @next/codemod@canary upgrade latest

The focused transforms are useful when reviewing one class of change at a time:

npx @next/codemod@canary next-async-request-api .
npx @next/codemod@canary middleware-to-proxy .
npx @next/codemod@canary next-lint-to-eslint-cli .

Codemods change syntax. They cannot decide data freshness, confirm a serverless bundle contains an external generated client, or tell whether a client-only import belongs behind a wrapper. Those remained review tasks.

The useful kind of inventory

I searched for framework boundaries rather than application features:

  • every dynamic() call with ssr: false;
  • every page, layout, and metadata function reading params or searchParams;
  • every synchronous cookies() and headers() call;
  • every server-side fetch() without an explicit cache decision;
  • every GET route relying on the old default;
  • every Next.js option that had moved, stabilised, or disappeared;
  • every package likely to care about React 19.

That produced a migration map before it produced a diff. Fifty-four files used dynamic(..., { ssr: false }); 29 did not declare a client boundary. Twenty-five files contained synchronous request-prop access. Roughly 199 calls used fetch, but only ten made caching or revalidation explicit.

Those counts did two things. They separated guaranteed build failures from behavioural risks, and they let us make changes in an order that kept each failure attributable.

Break one: the client boundary became honest

The root layout dynamically loaded PostHog page tracking and push-notification management with SSR disabled:

const PostHogPageView = dynamic(
  () => import("./PostHogPageView"),
  { ssr: false },
)

The layout itself was a Server Component. In the newer model, ssr: false is not allowed there. A Server Component cannot quietly decide that one of its imports only exists in the browser; that decision belongs to a Client Component.

The fix was a small client-only wrapper that owned the dynamic imports. The root layout imported that wrapper normally.

// ClientOnlyProviders.tsx
"use client"

const PostHogPageView = dynamic(
  () => import("../posthog-page-view/PostHogPageView"),
  { ssr: false },
)

export default function ClientOnlyProviders() {
  return <PostHogPageView />
}

Eleven calculator pages had the same guaranteed failure, and another 17 components needed their import graphs checked. The mechanical fix was easy. The useful result was architectural: browser-only behaviour became visible at a named boundary instead of hiding as an option inside a server-owned tree.

That is a recurring theme in Next.js 16. Several “breaking changes” are the framework refusing to infer something the application should state.

Break two: request data stopped pretending to be local data

In Next.js 14, a dynamic route could read its parameters immediately:

export default function StockPage({ params }: {
  params: { ticker: string }
}) {
  return <Stock ticker={params.ticker} />
}

By the time of Next.js 16, request APIs are asynchronous. Server Components await them; Client Components unwrap them with React's use().

export default async function StockPage({ params }: {
  params: Promise<{ ticker: string }>
}) {
  const { ticker } = await params
  return <Stock ticker={ticker} />
}

The same rule applied to searchParams, cookies(), and headers(). In our application it reached stock pages, ETF pages, screeners, earnings trackers, shared Iris conversations, organisation routes, metadata functions, authentication handlers, and two parameterised route handlers.

NEXT 14 · IMMEDIATE ACCESSparams.slugread during renderNEXT 16 · REQUEST BOUNDARYawait paramsserveruse(params)clientTHE PAYOFFrequest-bound work is explicit in the component treethe framework can decide what may render early, stream later, or be cached safely
The syntax change is one await. The model change is that request-bound values no longer look identical to values available before a request exists.

Why make such a small value asynchronous? Because the framework can only stream, defer, or cache work correctly when it knows which work depends on the incoming request. The Promise is not ceremony around an object. It marks a scheduling boundary.

The migration benefit was not that await params runs faster than params. It does not. The benefit is that the component tree now describes when request-specific information becomes necessary.

Client Components cannot become async just to await a prop. React's use() unwraps the same Promise:

"use client"

import { use } from "react"

export default function IrisThread({ params }: {
  params: Promise<{ thread: string }>
}) {
  const { thread } = use(params)
  return <Conversation id={thread} />
}

The pattern repeats at the other request boundaries:

import { cookies, headers } from "next/headers"

export async function generateMetadata({ params }: {
  params: Promise<{ ticker: string }>
}) {
  const { ticker } = await params
  return { title: ticker }
}

export async function GET(
  request: Request,
  { params }: { params: Promise<{ provider: string }> },
) {
  const { provider } = await params
  const cookieStore = await cookies()
  const requestHeaders = await headers()
  // use provider, cookieStore and requestHeaders
}

The dangerous break did not throw

The compiler found the invalid dynamic imports and synchronous request APIs. It could not decide how fresh our data should be.

Moving from 14 through 15 to 16 crossed a default change: server fetch requests and GET route handlers were no longer cached by default. Client router caching also became more conservative. Code could remain valid while doing more work, reaching upstream services more often, or making back navigation feel different.

This matters in a financial application. A quote and a company description have different freshness contracts. Treating both as “data fetched by a Server Component” loses the important distinction.

Our server paths already leaned on a Redis-backed data-fetcher layer, so the framework was not the only cache. That reduced the risk; it did not remove the audit. We reviewed raw fetches and routes, then made deliberate choices around no-store, force-cache, and revalidate where the framework cache actually owned freshness.

The useful audit question was not “should this be cached?” It was “which layer owns freshness?”

DataFramework decisionReason
Live quoteno-storeCurrent upstream value
Company descriptionrevalidate: 3600An hour is acceptable
Versioned referenceforce-cacheURL changes with content
Redis-backed fetcherno-storeRedis owns the TTL
const quote = await fetch(quoteUrl, { cache: "no-store" })

const description = await fetch(companyUrl, {
  next: { revalidate: 3600 },
})

GET route handlers required the same decision. In Next.js 15 and later they are not cached by default; routes that intentionally publish durable content should opt into static behaviour or revalidation rather than relying on the Next.js 14 default.

If an upgrade changes a default, valid code deserves more suspicion than broken code. Broken code stops. Valid code can quietly change cost, latency, and freshness.

We did not turn on Cache Components during this migration. Next.js 16 made them available, and another application in the monorepo already used them, but enabling a second caching model while auditing the first would have combined two migrations. “New” is not the same as “belongs in this diff.”

Turbopack was the default, not the deadline

Next.js 16 made Turbopack the default bundler for development and production builds. That was attractive on an application of this size: incremental compilation is exactly where a large module graph should benefit.

But our deployment constraint was not compilation alone. Vercel's serverless functions also had to include a Prisma 7 client generated outside the web application's directory in the monorepo.

We moved stable configuration out of experimental, added the monorepo root explicitly, and preserved tracing rules for the generated client:

const path = require("path")

const nextConfig = {
  outputFileTracingRoot: path.join(__dirname, "../../"),
  serverExternalPackages: [
    "pino",
    "@prisma/client",
    "@prisma/adapter-pg",
    "pg",
  ],
  outputFileTracingIncludes: {
    "/api/**": ["../../packages/db/src/generated/prisma/client/**"],
  },
}

Production packaging still needed the known path, so the build script temporarily became:

"build": "next build --webpack"

That was not a rejection of Turbopack. It was a separation of concerns. We could take the framework and React upgrade, use the new development default, and keep the deployment artifact on the bundler whose monorepo packaging behaviour we already understood. A migration is safer when the rollback lever remains obvious.

This was also why outputFileTracingRoot mattered more than its one line suggests. In a monorepo, a successful compile does not prove the serverless function contains a generated dependency living two directories away.

Middleware became proxy

Our middleware.ts was 243 lines of authentication, bot classification, redirects, referral handling, content negotiation, and request logging. Renaming it was the easy part.

Next.js 16 renamed the convention to proxy.ts and made its intent narrower: code at the network boundary, before a route renders. Proxy also runs on the Node.js runtime rather than the old Edge-runtime assumption.

For us, that removed an awkward mismatch. The file already wanted normal logging and authentication dependencies, and proxy's fixed Node.js runtime matched those dependencies better. But it also meant treating the rename as a runtime change, not housekeeping. A file that executes on every matched request deserves latency and behaviour checks even when its contents barely move.

The name is better, too. “Middleware” sounds like a general extension point. “Proxy” warns that this is an expensive place to accumulate application logic.

The smaller breaks still mattered

Several changes were individually dull and collectively important:

  • next lint no longer existed, so linting moved to the ESLint CLI and flat configuration.
  • serverComponentsExternalPackages became the stable top-level serverExternalPackages option.
  • typedRoutes moved out of experimental.
  • Next Image narrowed its default quality allow-list, so we declared the qualities the product used.
  • React and its type packages had to move together to 19.2, including testing-library compatibility.
  • Components reading router state needed Suspense boundaries where prerendering required them.

None deserved a dramatic postmortem. Together they are why framework upgrades should be reviewed as system changes rather than dependency chores.

BREAKCHANGEGAINdynamic ssr:falseclient wrapperhonest boundarysync request APIsawait / use()explicit timingimplicit cacheaudit each fetchknown freshnessmiddlewareproxyNode runtimenext lintESLint CLItool-owned lintTurbopack buildwebpack escape hatchdeploy firstthe upgrade paid in fewer implicit contracts, not in a benchmark screenshot
Coco's accounting: each failure paid for one clearer contract. The webpack row is intentionally different because the gain was a controlled deployment, not novelty.

What we actually gained

I would love to end with one enormous build-time percentage. I do not have a measurement I trust enough to print, and the production build deliberately stayed on webpack, so that would be a dishonest ending.

The gains belong in different columns. Mixing realised improvements with newly available features makes an upgrade sound more successful than the evidence supports.

Realised in the migration

The scope became measurable. We scanned 1,769 source files and changed 110 files in the migration commit. The inventory converted an open-ended upgrade into named classes of work.

The server/client line is inspectable. Browser-only code now lives behind explicit client wrappers. A future reader does not have to discover the boundary inside a dynamic-import option.

Request timing is visible. Parameters, headers, and cookies announce that they depend on a request. That gives both the framework and the engineer a more truthful rendering model.

Freshness became a decision. The caching audit forced us to name which layer owned freshness instead of inheriting a framework default accidentally.

The proxy runtime matches the boundary. Authentication and request logging now run in proxy's Node.js runtime instead of pretending they are Edge-compatible concerns.

Deployment has an explicit escape hatch. Turbopack can improve independently while a single --webpack flag preserves the known production path.

The application is on the current React foundation. React 19 features are available for later product work without mixing their adoption into the migration itself.

Operationally available, not yet proven

  • Turbopack is now the development default, but the production build remains on webpack and no performance percentage is claimed.
  • Cache Components and Partial Prerendering are available, but were deliberately not enabled.
  • React Compiler and React 19 interaction APIs are possible follow-ups, not migration results.
  • Proxy's Node runtime permits a simpler dependency model, but that does not make code at the request boundary free.

The important word in the realised gains is explicit. Next.js 16 did not make the application good by changing a version number. It forced several hidden assumptions to acquire names.

The order that kept this understandable

The migration landed in layers:

  1. Move invalid ssr: false usage behind client wrappers while still on Next.js 14.
  2. Upgrade packages and run focused codemods.
  3. Repair async request APIs manually where server and client treatment differed.
  4. Rewrite configuration and make the monorepo tracing root explicit.
  5. Audit cache behaviour and high-value navigation paths.
  6. Rename middleware to proxy as a runtime change.
  7. Keep Cache Components, React Compiler, and other new capabilities for separate decisions.

This order mattered. A 110-file diff was already large; it did not need speculative optimisation mixed into it.

The final verification loop covered more than compilation:

pnpm --filter @multibagg/web check-types
pnpm --filter @multibagg/web lint
pnpm turbo build --filter=@multibagg/web

The runtime checklist then covers representative stock, screener, authentication, and payment paths; proxy behaviour; and deployment tracing for the generated Prisma client. For caching changes, route behaviour and upstream request volume matter more than a green type check.

The lesson I kept

Framework migrations have two failure modes.

The first is a red build. It is noisy, finite, and usually searchable. The second is a green build around an assumption whose meaning changed. That one can alter freshness, packaging, runtime, or latency without offering a useful error message.

The answer is not to fear upgrades. It is to inventory contracts before touching versions, separate required migration work from newly available features, and verify the deployment artifact rather than just the compiler output.

The compiler found our syntax. The migration plan had to find our assumptions.


The implementation followed the official Next.js 16 upgrade guide, async request API migration, lazy-loading rules, proxy convention, and Turbopack documentation.

  • Next.js
  • React
  • Migration
  • Monorepo