Migrating to Next.js 15: A Practical Guide
Lessons learned from migrating our applications to Next.js 15 with the App Router, including async params handling and new caching behaviors.
Next.js 15 brings significant changes that improve developer experience and performance. Here's what we learned migrating our applications.
Key Changes in Next.js 15
1. Async Request APIs
The biggest breaking change is that params, searchParams, and other request-specific APIs are now asynchronous:
// Before (Next.js 14)
export default function Page({ params }: { params: { slug: string } }) {
return <div>Slug: {params.slug}</div>;
}
// After (Next.js 15)
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <div>Slug: {slug}</div>;
}
This change affects:
paramsin pages and layoutssearchParamsin pagescookies()andheaders()in Server Components
2. New Caching Defaults
Next.js 15 changes default caching behavior to be more conservative:
fetch()requests are no longer cached by default- Route Handlers use
dynamic = 'force-dynamic'by default - Client Router Cache no longer caches page components
To opt into caching:
// Explicit cache control
fetch(url, { cache: "force-cache" });
// Or at the route level
export const dynamic = "force-static";
3. Turbopack Stability
Turbopack is now stable for development. Enable it with:
next dev --turbopack
Our builds went from ~15 seconds to ~3 seconds with Turbopack.
Migration Strategy
We followed a phased approach:
Phase 1: Update Dependencies
npm install next@15 react@19 react-dom@19
Phase 2: Fix Breaking Changes
We used the codemod to automatically fix async params:
npx @next/codemod@canary upgrade latest
This handled most cases, but we had to manually fix:
- Custom hooks that consumed params
- Client components that passed params as props
- Test files that mocked params
Phase 3: Audit Caching
We reviewed every fetch() call and route to ensure appropriate caching:
// Static data - cache forever
const config = await fetch("/api/config", { cache: "force-cache" });
// Dynamic data - no cache
const user = await fetch(`/api/users/${userId}`, { cache: "no-store" });
// Revalidate periodically
const posts = await fetch("/api/posts", { next: { revalidate: 60 } });
Phase 4: Enable Turbopack
After confirming everything worked, we enabled Turbopack for development:
{
"scripts": {
"dev": "next dev --turbopack"
}
}
Common Pitfalls
1. Forgetting to Await Params
TypeScript will catch this, but it's easy to miss in JavaScript:
// This will fail silently
const slug = params.slug; // undefined - params is a Promise!
2. Over-Caching
With the new defaults, you might be tempted to add force-cache everywhere. Don't! Evaluate each endpoint:
- User-specific data →
no-store - Frequently changing data →
revalidate: N - Static configuration →
force-cache
3. Middleware Issues
Middleware now runs before static files. If you have heavy middleware, it can impact performance on static asset requests.
Performance Improvements
After migration:
| Metric | Before | After | |--------|--------|-------| | Dev startup | 15s | 3s | | Page load (P50) | 1.2s | 0.9s | | Build time | 120s | 95s |
The biggest win was Turbopack for development - our iteration speed improved dramatically.
Conclusion
Next.js 15 is a significant upgrade that requires careful migration, especially around async params and caching. Take the time to audit your caching strategy, and enjoy the improved developer experience with Turbopack.
Need help with your Next.js migration? Feel free to reach out!