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.

Human Ventures Engineering··3 min read

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:

  • params in pages and layouts
  • searchParams in pages
  • cookies() and headers() 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!