DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Migrating to Next.js 15: big wins, small gotchas

TL;DR

Next.js 15 delivers massive developer-velocity wins (stable Turbopack in dev, React 19 support, Partial Prerendering) but introduces two migration hotspots: async request-scoped APIs (cookies(), headers(), params, searchParams) and inverted fetch/GET caching defaults. Enable Turbopack, audit request-scoped APIs for awaits, and make caching explicit route-by-route.

Why we upgraded: measurable dev experience gains

We flipped our monorepo to Next.js 15 and felt an immediate difference in local iteration. With Turbopack enabled in dev we saw cold starts and incremental edits feel almost instantaneous — in our runs builds were 4–7x faster and HMR latencies dropped to sub‑100ms on large folders. Those numbers align with public reports of up to ~70–76% faster starts and large Fast Refresh wins in real projects.

The practical effect: less context switching, fewer lost trains of thought, and much faster feedback for experiments.

The two small-but-critical gotchas

While the DX wins are obvious, defaults moved under the hood and can silently alter runtime behavior.

1) Request-scoped APIs are async now

APIs that read request-specific data — cookies(), headers(), params, searchParams, and draftMode — return Promises. That means code that used to read them synchronously may compile and ship, then fail at runtime with subtle errors.

Common failure mode: a middleware or auth function that reads cookies() synchronously and then later throws when the Promise wasn’t awaited.

Bad example (pre-migration pattern):

// middleware.ts (problematic)
import { cookies } from 'next/headers';

export function middleware(req) {
  const session = cookies().get('session')?.value; // used synchronously
  if (!session) return Response.redirect('/login');
}
Enter fullscreen mode Exit fullscreen mode

Fix: await request-scoped helpers (and make callers async where needed):

// middleware.ts (fixed)
import { cookies } from 'next/headers';

export async function middleware(req) {
  const cookieStore = await cookies();
  const session = cookieStore.get('session')?.value;
  if (!session) return Response.redirect('/login');
}
Enter fullscreen mode Exit fullscreen mode

Also remember params/searchParams are Promises in server components and pages. Convert functions to async and await them:

// app/blog/[slug]/page.tsx
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  // fetch and render
}
Enter fullscreen mode Exit fullscreen mode

The Next.js team provides an official codemod that automates much of this migration, but manual inspection is still required for complex patterns.

2) fetch() and GET route handlers are uncached by default

Next.js 15 flips caching defaults: fetch calls and GET Route Handlers are uncached (no-store equivalent) unless you explicitly opt into caching. If your app relied on the old implicit caching you can see two bad outcomes: stale caches (if you mistakenly try to cache client-side) or sudden spikes in backend/API traffic after upgrading.

Fixes are explicit and per-call or per-route:

  • Per fetch: pass cache: 'force-cache' for static/rarely-changing data, or next: { revalidate: seconds } for ISR-like behavior.
  • Per route: export const dynamic = 'force-static' or export revalidate = 3600 in route handlers.

Example of guarding a fetch that used to be implicitly cached:

const data = await fetch(apiUrl, { cache: 'force-cache' });
// or
const data = await fetch(apiUrl, { next: { revalidate: 60 } });
Enter fullscreen mode Exit fullscreen mode

Be intentional: prefer no-store for truly user-specific data and force-cache/revalidate for shared resources.

Secondary wins: Server Components and Partial Prerendering (PPR)

We used the migration as an opportunity to push more UI into Server Components and adopt Partial Prerendering for non-interactive parts. The payoff was smaller bundle sizes (less client JS) and better p99 LCP where applied. PPR lets you ship a static shell immediately and stream dynamic pieces; combined with explicit caching it’s a strong performance lever.

Practical migration checklist (what we ran through)

  • Upgrade deps: next@15, react@19, react-dom@19. Run tests and typecheck.
  • Run the codemod: npx @next/codemod@canary next-async-request-api .
  • Audit request-scoped APIs: await cookies(), headers(), draftMode(), and await params/searchParams in pages/layouts/generateMetadata.
  • Audit every fetch() and GET route handler: decide per-call or per-route caching (cache: 'force-cache', next: { revalidate }, or export dynamic = 'force-static').
  • Enable Turbopack in dev (next dev --turbo or set turbopack flag) and validate HMR behavior.
  • Adopt Server Components / PPR where it makes sense to reduce client bundle size.
  • Smoke-test auth, personalization, and any edge/edge-runtime flows.
  • Monitor production for increased backend traffic after release; set guardrails in case of regressions.

Real-world example from our audit

A middleware file compiled and shipped, then produced rare runtime errors because cookies() calls weren’t awaited. After converting to async and adding explicit cache flags for a few hotspot fetches, LCP improved and bundle budgets shrank where we removed unneeded client JS.

Key snippet we changed:

const session = await (await cookies()).get('session')?.value;
const data = await fetch(apiUrl, { cache: 'force-cache' });
Enter fullscreen mode Exit fullscreen mode

Small guard: if you ever call request-scoped helpers, make the caller async and await them.

Final thoughts

Next.js 15 is a net win—Turbopack makes local iteration delightful, React 19 opens new optimizations, and PPR + Server Components let you finely tune what runs where. But the changed defaults are intentional: they force teams to reason about caching and request-time data, which is healthier long-term but costs an initial audit.

If you’re planning the upgrade: budget time to run the codemod, audit request-scoped APIs, and make caching explicit. That investment unlocks faster development and more predictable production behavior.

What surprised you most during a Next.js 15 or React 19 migration? I’d love to compare notes and share specific patterns that saved us time.

Top comments (1)

Collapse
 
gumbosveins profile image
Gumbo Sveins

Nice migration summary. One extra guard that helped us was adding a lint rule or code search for cookies(), headers(), and params in shared helpers, since a missed await can look like an auth regression rather than a migration error. I also like the per route caching audit, and I would pair it with a small production request rate alert so an accidental cache miss is visible quickly.