Back to Blog
Upgrading a Large Next.js App from 14 to 16 — The Real Migration Path
frontendnextjsreactmigration

Upgrading a Large Next.js App from 14 to 16 — The Real Migration Path

How we migrated a large App Router codebase from Next.js 14 to 16 using the canary codemod, and the errors we had to fix after.

Upgrading from Next.js 14 to 16 is not a simple version bump. You're crossing several compatibility boundaries at once — the Next.js 15 request API changes, Next.js 16 framework tightening, the React 18 to 19 shift, and stricter hydration and accessibility enforcement.

We did this migration on a large ERP-style product with an App Router codebase that had already been separated from its Node backend. This guide covers what actually happened: the command that got us there, the errors we hit, and how we fixed them.

Before You Start

Prerequisites:

  • Node.js 20+ — safest baseline for Next.js 16 across local, CI, and Docker.
  • Upgrade React together — bump react, react-dom, @types/react, and @types/react-dom as a set.
  • Commit your lockfile before the upgrade so you can diff dependency changes cleanly.
  • Test widely — Next.js 16 exposes issues across hydration, accessibility, metadata, and runtime boundaries. One-page smoke testing won't cut it.

Step 1 — Run the Canary Codemod

This is where the migration starts. Next.js provides an official upgrade codemod that handles the heavy lifting — dependency bumps, config transforms, and automated code changes:

npx @next/codemod@canary upgrade latest

This single command took our codebase from Next.js 14 all the way to 16. It updated dependencies, applied framework codemods, and transformed what it could detect automatically — async request APIs, config changes, lint setup, the works.

It did not fix everything. But it gave us the correct baseline. After this, the real work was fixing the runtime errors the codemod couldn't catch.


Step 2 — Fix the Errors

After the codemod, we started the app and hit a wall of errors. Instead of fixing them one by one per page (which turns into endless whack-a-mole), we grouped them by error class and fixed each class across the entire codebase in one pass.

Here's every class of error we hit and how we fixed them.

Invalid DOM Nesting (Hydration Errors)

The single biggest category. React 19 + Next.js 16 now strictly enforces valid HTML nesting, and what used to silently pass now breaks hydration.

<a> inside <a> — Happened when next/link was wrapped inside components that already rendered an anchor tag (breadcrumbs, custom link wrappers). Fix: ensure only one <a> exists in the composition.

<button> inside <button> — Radix DialogTrigger or SheetTrigger wrapping a Button component without asChild, producing nested buttons. Fix: use asChild correctly so the trigger delegates to the child element instead of wrapping it.

<tr> inside <div> — Table rows rendered inside tooltip or popover content containers that output <div>s. Fix: either wrap in a proper <table> or convert to non-table markup.

Async params, searchParams, cookies(), and headers()

This was one of the biggest API changes coming from Next.js 14. In Next.js 15+, all of these became asynchronous — they return Promises now, so every usage needs await.

Before:

export default function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  // ...
}

After:

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  // ...
}

Same for searchParams in pages, and cookies() / headers() from next/headers:

const cookieStore = await cookies();
const headersList = await headers();

The codemod caught a lot of these automatically, but not all — especially in helper functions, custom hooks, and deeply nested components where params or searchParams were passed down as props. We had to go through those manually and add await + update the type to Promise<...> everywhere.

findDOMNode is Not a Function

react_dom_1.default.findDOMNode is not a function

This came from a rich text editor (Quill) using an old integration pattern that relied on deprecated React DOM internals. React 19 removed findDOMNode entirely.

Fix: We switched to a ref-based Quill integration. If a library still depends on legacy React internals, you have three options — upgrade it, replace it, or isolate it behind a client-only wrapper.

Missing Dialog/Sheet Titles (Radix Accessibility)

DialogContent requires a DialogTitle

Next.js 16 didn't create this issue, but it made the warnings impossible to ignore.

Fix: Add DialogTitle to every DialogContent and SheetTitle to every sheet. Use visually hidden titles where the design doesn't need a visible heading. This is the right fix — it improves accessibility and component structure.

Null/Undefined Crashes on API Data

Cannot read properties of undefined (reading 'map')
Cannot read properties of undefined (reading 'replace')

After backend separation, the frontend still had stale assumptions — calling .map() on arrays that might not exist, calling .replace() on strings that might be undefined, hitting endpoints that had moved or changed shape.

Fix: Add null guards before every array/string transform. Default to "" or [] before operations. Re-verify endpoint paths and response shapes against the actual backend.

Missing List Keys

Each child in a list should have a unique "key" prop

In data-heavy ERP tables, this showed up everywhere — map() calls returning fragments without keys, table cell renderers with mixed fragment structures, dynamic row content without stable keys.

Fix: Move the key to the top-level returned element. Avoid anonymous fragments in mapped output unless they're keyed.

Manifest / PWA Metadata

Browser console errors from invalid manifest.json syntax and icons with no valid purpose. Easy to miss since they don't block navigation.

Fix: Validate manifest JSON, fix icon entries, use correct purpose values. Framework upgrades are a good time to clean this up.

Stale API Errors

AxiosError: Request failed with status code 404 / 400 / 500

Endpoint paths had drifted after backend separation — old search endpoints returning 404, filter endpoints returning 400, stale query params.

Fix: Verify endpoint ownership and URL shapes against the current backend. Make the frontend defensive against missing or changed payload branches.

Lint Debt

Once runtime errors were gone, the next wave was lint warnings — react-hooks/exhaustive-deps, no-img-element, missing alt text, and other framework-specific rules.

We temporarily kept some rules disabled because the codebase was too large to absorb everything at once. But we cleaned them in batches by module — not one file at a time.


Step 3 — Validate

After fixing all error classes:

npm run lint
npx tsc --noEmit
npm run build

We also did a full screen sweep across every major module — procurement, inventory, machinery, vendor management — testing dialog-heavy workflows, data grids, editor flows, and authenticated routes.


Migration Flow Summary

  1. Run npx @next/codemod@canary upgrade latest — handles dependency bumps and automated transforms from 14 → 16.
  2. Review what the codemod changed — dependency versions, framework config, lint setup, request API transforms.
  3. Start the app and sweep — open every major screen, collect all errors.
  4. Fix by error class, not by page — nested DOM, missing titles, null crashes, missing keys, stale APIs.
  5. Validate — lint, typecheck, build, full screen sweep.
  6. Clean up lint debt in batches by module after the app is stable.

Rollback Plan

  • Keep the pre-upgrade branch so you can redeploy the last stable Next.js 14 version.
  • Don't mix the framework upgrade with feature work in the same release.
  • Keep endpoint changes isolated from UI migration changes.

Rollback should be a normal deployment action, not an emergency.


What Actually Improved

The improvement wasn't "the app is magically faster." It was operational:

  • Hydration issues surfaced early instead of silently leaking into production.
  • Shared components got cleaner — nested links, nested buttons, and missing titles had to be fixed properly.
  • Backend separation became more honest — stale frontend assumptions were easier to spot.
  • Accessibility improved as a direct result of fixing Radix warnings correctly.
  • Future upgrades got cheaper — once shared primitives are fixed, the app stops fighting the framework.

The Takeaway

Don't let a major Next.js upgrade turn into "build, fix one page, rebuild, repeat." Run the codemod, identify the error classes, fix each class across the entire codebase, then validate.

The canary codemod got us from 14 to 16. The rest was fixing the errors it couldn't catch. That's always the real migration work.

References

Design & Developed by Dhruv Vasisht
© 2026. All rights reserved.