π§ 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
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';
We also updated all test utilities and wrapper components:
// Test utilities - Before
import React from 'react';
// After
import { ReactElement, ReactNode } from 'react';
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"
}
}
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:
- Test packages independently with React 19
- Keep the main app on React 18 until ready
- 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"]
}
}
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;
// ...
}
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;
// ...
}
Key changes explained:
- All params are now strings (text) - because that's what URLs actually are
- Page props contain
Promise<Params>- you mustawaitthem (like waiting for a package to arrive before opening it) - Convert numeric IDs with
Number()- turning the text "123" into the actual number 123 - 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...
}
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
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',
};
2. Image configuration changes
// next.config.js
module.exports = {
images: {
domains: ['...'], // Still works but remotePatterns is the new preferred way
unoptimized: true,
},
};
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.
-
Type checking:
yarn lint -
Build:
yarn build -
Tests:
yarn test - 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; // β
}
π€ 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 .
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:
- Route parameter type conversions - Converting numeric IDs and enums to strings across 120+ route files
- Monorepo coordination - Updating shared types across packages with proper context
- Named React imports - Transforming 240+ files to use named imports
- 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!
- Run all applicable Next.js codemods first - they'll handle standard Next.js patterns (80-85% of changes)
- Review and commit the codemod changes
- Use AI coding assistants for everything else - they understand context and can handle complex transformations
- 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
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/apiand/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
awaitkeyword) - 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;
}
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!
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}` };
}
π‘ Key takeaways
The most important lessons we learned:
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.
Test continuously: Run type checking and tests after each major change. Catching bugs early is way easier than debugging a mountain of changes.
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.
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.
Monorepo coordination: If you have shared packages (a monorepo), update them first with flexible peer dependencies.
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)
This is nice.
During this activity did you face a problem where one of your dependencies does not work in your target version ?
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.