DEV Community

abhilashlr
abhilashlr

Posted on

πŸš€ Migrating a Large-Scale Monorepo from Next.js 14 to 16: A Real-World Journey

🧭 TL;DR

We migrated a large production Next.js monorepo (240+ route files, 3k+ i18n strings) from Next.js 14 β†’ 16 by preparing the codebase first, upgrading dependencies incrementally, and relying heavily on Next.js codemods + AI-assisted refactoring.

The biggest breaking change was async route params, but with a phased approach and automation, we completed the migration in ~2 weeks with minimal production risk.


Upgrading a large enterprise application with a monorepo structure from Next.js 14 to Next.js 16 isn't just about bumping version numbers. It's a carefully orchestrated process that requires understanding breaking changes, preparing your codebase, and migrating incrementally. This post shares our experience migrating a production enterprise application with 240+ files changed and lessons learned along the way.


πŸ“Š Project Context

Our setup:

  • Main Application: Next.js 14.2.35 β†’ 16.1.1
  • Monorepo: Shared component libraries and utilities (design system, data layer, icons)
  • Scale: 3,196+ internationalized strings, 240+ route files
  • Tech Stack: React 18 β†’ React 19, TypeScript 5.8, Styled Components, TanStack Query

πŸ—ΊοΈ The Migration Strategy

Phase 1: Preparation (the foundation)

Before touching Next.js itself, we prepared our codebase to be compatible with the new conventions. Think of this as cleaning your house before moving furniture - it makes everything else easier.

Step 1: Update dependencies first

We updated related packages incrementally to avoid dependency conflicts. Why? Because trying to update everything at once is like trying to solve multiple puzzles simultaneously - it's much harder to identify what broke when something goes wrong.

# Bump Next.js 14 to latest patch version
yarn add next@14.2.35

# Update React Query v4 β†’ v5
yarn add @tanstack/react-query@^5.90.12

# Update react-intl for React 19 compatibility
yarn add react-intl@8.0.6

# Update react-select with proper types
yarn add react-select@^5.8.3
Enter fullscreen mode Exit fullscreen mode

Why this matters: Updating dependencies incrementally helps isolate issues. We found that React Query v5 had breaking changes that needed fixes before tackling Next.js. Imagine debugging a broken build when you've changed 10 things at once versus just 1 - that's the difference.

Step 2: Migrate to named React imports

Next.js 16 with React 19 recommends named imports over default imports. What does this mean? Instead of importing React as a whole, we import only the specific pieces we need. We migrated 240+ files to this new pattern:

// Before
import React from 'react';
import { useState } from 'react';

// After
import { useState } from 'react';
Enter fullscreen mode Exit fullscreen mode

We also updated all test utilities and wrapper components:

// Test utilities - Before
import React from 'react';

// After
import { ReactElement, ReactNode } from 'react';
Enter fullscreen mode Exit fullscreen mode

Pro tip: Don't do this manually! We used AI coding assistants to automate this transformation across 240+ files. Think of AI as your tireless intern who never gets bored of repetitive tasks and makes fewer mistakes than humans doing copy-paste work. This saved us hours of tedious work and reduced the chance of human error.

Phase 2: Update monorepo packages

If you have a monorepo (a single repository containing multiple related packages), you need to update shared packages first.

Before migrating the main app, we updated our shared component library packages to support both React 18 and React 19.

// Shared packages - peer dependencies
{
  "peerDependencies": {
    "react": "^18.2.0 || ^19.0.0",
    "react-dom": "^18.2.0 || ^19.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

This flexible version range (^18.2.0 || ^19.0.0) means "accept React 18.2.0 or higher, OR React 19.0.0 or higher". This allowed us to:

  1. Test packages independently with React 19
  2. Keep the main app on React 18 until ready
  3. Avoid breaking other projects using the monorepo

Why this matters: It's like having a power adapter that works with both 110V and 220V - you can plug it in anywhere without breaking things.

Phase 3: TypeScript configuration updates

Next.js 16 requires a specific TypeScript setting. We updated tsconfig.json (the TypeScript configuration file):

{
  "compilerOptions": {
    "jsx": "react-jsx", // Changed from "preserve"
    "lib": ["dom", "dom.iterable", "esnext"]
  }
}
Enter fullscreen mode Exit fullscreen mode

The jsx: "react-jsx" setting enables the new JSX transform. Translation: you no longer need to write import React from 'react' at the top of every file. The build system handles it automatically.

Phase 4: Route parameters type migration

This was the biggest change. Next.js 16 made routes type-aware of their parameters - meaning TypeScript now knows exactly which parameters exist for each route, making your code safer and catching errors at compile time instead of runtime.

What are route parameters? They're the dynamic parts in URLs like /products/[id] where [id] could be any product ID.

The challenge: Type-safe route parameters

Previously in Next.js 14, route parameters weren't type-safe at the framework level. We could manually define types, but Next.js didn't enforce them:

// Before - Next.js 14
type Params = {
  id: number;
  categoryId: number;
  view: ViewType; // enum
};

export default function Page({ params }: { params: Params }) {
  const { id, categoryId, view } = params;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The solution: String parameters with runtime casting

Next.js 16 says "everything from the URL is a string" (because URLs are just text!). So now we convert them to the types we need inside our code:

// After - Next.js 16
type Params = {
  id: string;
  categoryId: string;
  view: string;
};

export default async function Page(props: { params: Promise<Params> }) {
  const params = await props.params;
  const id = Number(params.id);
  const categoryId = Number(params.categoryId);
  const view = params.view as ViewType;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Key changes explained:

  1. All params are now strings (text) - because that's what URLs actually are
  2. Page props contain Promise<Params> - you must await them (like waiting for a package to arrive before opening it)
  3. Convert numeric IDs with Number() - turning the text "123" into the actual number 123
  4. Cast enum values with as ViewType - telling TypeScript "trust me, this string is one of the valid options"

This affected 120+ route files (yes, that many!). The Next.js codemod handled most of this transformation automatically, creating this consistent pattern across all our routes:

// Layout pattern (created by codemod)
type Params = { id: string };

export default async function Layout(props: {
  params: Promise<Params>;
  children: React.ReactNode;
}) {
  const { id } = await props.params;
  const numericId = Number(id);

  return <LayoutComponent id={numericId}>{children}</LayoutComponent>;
}

// Page pattern (created by codemod)
export default async function Page(props: { params: Promise<Params> }) {
  const params = await props.params;
  // Use params...
}
Enter fullscreen mode Exit fullscreen mode

After the codemod ran, we used AI assistants to handle the parts it couldn't automate - like converting string IDs to numbers and casting enum types.

Phase 5: The main migration

With all the preparation work done (think of it as laying the groundwork), we finally performed the actual Next.js upgrade. This is like changing the engine of a car - you want to make sure everything else is ready before you do it:

# Update Next.js and React
yarn add next@16.1.1 react@18.3.1 react-dom@18.3.1

# Update Next.js tooling
yarn add -D @next/bundle-analyzer@16.1.1 eslint-config-next@16.1.1

# Update React types
yarn add -D @types/react@19.2.7 @types/react-dom@19.2.3
Enter fullscreen mode Exit fullscreen mode

Handling breaking changes

Breaking changes means features that no longer work the way they used to.

1. Removed Next.js config options

Some configuration options no longer exist in Next.js 16:

// next.config.js - Removed
module.exports = {
  // These were removed in Next.js 16
  // distDir: '.next',
  // target: 'serverless',
};
Enter fullscreen mode Exit fullscreen mode

2. Image configuration changes

// next.config.js
module.exports = {
  images: {
    domains: ['...'], // Still works but remotePatterns is the new preferred way
    unoptimized: true,
  },
};
Enter fullscreen mode Exit fullscreen mode

3. Middleware updates

Middleware is code that runs before your pages load. NextJS Codemod should automatically migrate your existing middleware.ts to proxy.ts.

Phase 6: Testing and validation

After the migration, we systematically tested everything.

  1. Type checking: yarn lint
  2. Build: yarn build
  3. Tests: yarn test
  4. Runtime testing: Manual testing of critical paths

We caught and fixed issues like:

// Issue: Async server components not awaited
export default async function Page(props) {
  const params = props.params; // ❌ Missing await

  // Fixed
  const params = await props.params; // βœ…
}
Enter fullscreen mode Exit fullscreen mode

πŸ€– What about codemods?

What are codemods? They're automated tools that rewrite your code following specific patterns. Think of them as find-and-replace on steroids - they understand code structure, not just text.

Next.js provides official codemods for many migrations, and we absolutely used them first! Codemods automated a significant portion of our migration work - like having a robot do the boring repetitive parts.

Codemods we used successfully

# Migrate metadata and generateMetadata exports
npx @next/codemod@latest metadata-to-viewport-export .

# Update Link components
npx @next/codemod@latest new-link .

# Convert async request APIs
npx @next/codemod@latest async-request-api .
Enter fullscreen mode Exit fullscreen mode

These codemods handled:

  • Converting metadata exports to the new format
  • Transforming basic async patterns on page/layout files

AI assistants for everything else

After running codemods (which handled the straightforward, pattern-based changes), we used AI coding assistants to handle all the complex stuff that requires understanding context:

  1. Route parameter type conversions - Converting numeric IDs and enums to strings across 120+ route files
  2. Monorepo coordination - Updating shared types across packages with proper context
  3. Named React imports - Transforming 240+ files to use named imports
  4. Context-aware refactoring - Understanding component relationships and dependencies

The AI assistant understood our codebase context and applied patterns consistently, catching edge cases that simple find-and-replace would have missed.

Our recommendation

Use codemods + AI assistants for maximum efficiency!

  1. Run all applicable Next.js codemods first - they'll handle standard Next.js patterns (80-85% of changes)
  2. Review and commit the codemod changes
  3. Use AI coding assistants for everything else - they understand context and can handle complex transformations
  4. Final manual review of critical business logic

This approach completed our migration in 2 weeks instead of what would have taken months manually. The combination of automated codemods and intelligent AI assistance meant we could focus our time on testing and validation rather than mechanical code changes.

The AI-human-CI/CD loop

Our migration followed a continuous improvement cycle that combined AI automation with human oversight:

1. AI makes changes (codemods + Claude Code + AntiGravity)
   ↓
2. Run tests locally (`yarn test`, `yarn lint`)
   ↓
3. Commit and push to GitHub
   ↓
4. GitHub Actions runs CI/CD pipeline
   - Type checking
   - Linting
   - Unit tests
   - Build verification
   ↓
5. Automated code review (CodeRabbit AI)
   - Identifies potential issues
   - Suggests improvements
   - Flags edge cases
   ↓
6. Human code review
   - Verify business logic
   - Check critical paths
   - Approve or request changes
   ↓
7. Fix issues β†’ Repeat from step 1
Enter fullscreen mode Exit fullscreen mode

Key insight: This loop ran dozens of times throughout our phased migration. AI handled the mechanical transformations (80-90% of changes), while humans focused on validating correctness, catching edge cases, and ensuring business logic integrity. Automated tools (CI/CD + CodeRabbit) acted as safety nets, catching issues before they reached production.

The cycle typically took 30-60 minutes per iteration, allowing us to make rapid progress while maintaining code quality.


βœ… Migration checklist

Use this checklist for your own migration. Print it out and check off items as you go!

Pre-migration

  • Update to latest Next.js 14.x patch
  • Update related dependencies (React Query, react-intl, etc.)
  • Move non-route files out of /pages/api and /app
  • Migrate to named React imports
  • Update monorepo packages for React 19 compatibility
  • Update TypeScript to 5.x

During migration

  • Update all route parameters to string types (remember: everything from URLs is text!)
  • Update Next.js to 16.x
  • Convert page components to async and await params (add the await keyword)
  • Update React to 18.3.x (or 19.x if you're feeling brave)
  • Update @types/react and @types/react-dom (the TypeScript definitions)
  • Remove deprecated Next.js config options

Post-migration (the victory lap!)

  • Run type checking (tsc --noEmit)
  • Run tests
  • Test build locally
  • Test development server
  • Manual testing of critical user paths
  • Deploy to staging environment
  • Monitor for runtime errors

⚑ Performance impact

After migration, we measured the actual impact on our application. Here's what we found:

  • Build time: Extraordinary results! (~50-60% faster with Next.js 16 turbopack improvements). Build time is how long it takes to compile your code for production.
Before

After

  • Bundle size: Slightly smaller (-3% due to better tree-shaking). Bundle size is the total size of JavaScript your users download - smaller is better!

  • Development: Faster with Turbopack (we were already using this in Next.js 14).


⚠️ Common pitfalls and solutions

These are the mistakes we made so you don't have to! Learn from our pain.

1. Forgetting to await params

// ❌ Wrong
export default async function Page(props: { params: Promise<Params> }) {
  const { id } = props.params; // Missing await!
}

// βœ… Correct
export default async function Page(props: { params: Promise<Params> }) {
  const { id } = await props.params;
}
Enter fullscreen mode Exit fullscreen mode

2. Type mismatches in nested components

Remember: route params come in as strings, but your components might expect numbers. Always convert!

// Route param is string, but component expects number
const numericId = Number(params.id); // Convert first!
Enter fullscreen mode Exit fullscreen mode

3. Build errors with dynamic routes

generateMetadata is a special function that generates page titles and meta tags. It also needs to await params:

export async function generateMetadata(props: { params: Promise<Params> }) {
  const params = await props.params;
  return { title: `Item ${params.id}` };
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key takeaways

The most important lessons we learned:

  1. Prepare incrementally: Don't upgrade everything at once - you do it one thing at a time. Update dependencies (and test), fix code patterns (and test), then upgrade Next.js.

  2. Test continuously: Run type checking and tests after each major change. Catching bugs early is way easier than debugging a mountain of changes.

  3. Understand the changes: Read the Next.js 15 and Next.js 16 release notes thoroughly. I know it's tempting to skip documentation, but trust us - 30 minutes of reading saves hours of debugging.

  4. Async params are non-negotiable: This is the biggest breaking change but thanks to Codemods from Next.js, we were able to automate most of the work.

  5. Monorepo coordination: If you have shared packages (a monorepo), update them first with flexible peer dependencies.

  6. Consider React 19: Next.js 16 works with React 18.3+ but is optimized for React 19. Decide if you want to upgrade React at the same time or separately.


πŸ“š Resources


🎯 Conclusion

Migrating a large-scale application from Next.js 14 to 16 is a significant undertaking, but with proper preparation and incremental changes, it's manageable. Our migration touched 240+ files and took about 2 weeks of focused work, including testing and validation.

The key is to:

  • Break the migration into phases
  • Prepare your codebase first
  • Update dependencies incrementally
  • Test thoroughly at each step
  • Document patterns for consistency

Have you migrated to Next.js 16? What challenges did you face? Share your experience in the comments!


Special thanks to the Next.js team for excellent documentation and the community for sharing their migration experiences.

Top comments (2)

Collapse
 
haribalaji_b profile image
hari balaji

This is nice.

During this activity did you face a problem where one of your dependencies does not work in your target version ?

Collapse
 
abhilashlr profile image
abhilashlr

Yes absolutely.

In one case, we depended on a library that was no longer maintained and was tied to an older React ecosystem. Instead of blocking on the framework upgrade, we forked the repository, modernized the codebase to align with the latest React standards, released our own version, and updated the application to consume it.

This allowed us to continue shipping to production independently, without waiting for the full Next.js 16 migration to land. In practice, we decoupled delivery from the framework upgrade and kept production moving throughout the transition.