Chapter 26

Next.js 15 Complete: New Features and Upgrade Guide

From 14 to 15: A Breaking Evolution

Next.js 15 is not a simple version bump โ€” it contains several genuine breaking changes that must be understood before upgrading. At the same time, it delivers React 19 integration, a stable Turbopack, new caching defaults, and several practical APIs. This is the most significant Next.js release in years.

This chapter divides the changes into two categories: breaking changes you must address immediately, and new features you can adopt at your own pace.

Breaking Change 1: Async cookies() and headers()

This is the most commonly encountered issue during upgrades. In Next.js 14 and earlier, cookies() and headers() were synchronous:

// Next.js 14 โ€” will show deprecation warnings in Next.js 15
import { cookies, headers } from 'next/headers'

export default function Page() {
  const cookieStore = cookies() // synchronous
  const token = cookieStore.get('token')
}

In Next.js 15 these functions return Promises:

// Next.js 15
import { cookies, headers } from 'next/headers'

export default async function Page() {
  const cookieStore = await cookies() // must await
  const token = cookieStore.get('token')

  const headersList = await headers()
  const userAgent = headersList.get('user-agent')
}

Why the change? This enables Next.js's Partial Prerendering architecture. Synchronous access to request-time data blocked static optimization. By making these functions asynchronous, Next.js can precisely identify which parts of the render tree depend on request-time data and optimize at finer granularity.

Quick migration โ€” use the provided codemod:

npx @next/codemod@canary next-async-request-api .

This codemod automatically converts synchronous calls to await calls in Server Components. Client Component invocations and complex scenarios require manual review.

Breaking Change 2: The fetch Cache Default Is Inverted

This is the most important semantic change in Next.js 15 and the most likely to introduce subtle bugs:

// Next.js 14: fetch defaults to caching (equivalent to cache: 'force-cache')
fetch('https://api.example.com/data')

// Next.js 15: fetch defaults to no caching (equivalent to cache: 'no-store')
fetch('https://api.example.com/data')

Why the change? Next.js 14's default caching confused many developers โ€” they saw stale data on their pages and could not understand why. "Default caching" is efficient in some scenarios but violates the Principle of Least Surprise. Next.js 15 adopts the behavior closer to Web standards: no-store means data is fresh on every request, and caching must be declared explicitly where needed.

Migration impact: If your application relies on Next.js 14's implicit caching (very common), after upgrading you may see:

Solution: Audit every fetch call and add explicit cache strategies where needed:

// Requests that should be cached: declare explicitly
fetch('/api/config', { cache: 'force-cache' })

// Requests that should refresh periodically
fetch('/api/posts', { next: { revalidate: 3600 } }) // 1 hour

// Route-level cache control
export const revalidate = 60 // Revalidate the entire route every 60 seconds

// No cache (this is now Next.js 15's default โ€” no declaration needed)
fetch('/api/live-data')

React 19 Integration

Next.js 15 integrates React 19, which brings a suite of new capabilities:

The use() Hook

use() is a new React 19 hook that "unwraps" Promises and Contexts during rendering:

// Reading a server-passed Promise in a Client Component
'use client'

import { use } from 'react'

interface Props {
  dataPromise: Promise<{ name: string }>
}

export function UserCard({ dataPromise }: Props) {
  // use() suspends rendering until the Promise resolves โ€” pair with Suspense
  const data = use(dataPromise)
  return <div>{data.name}</div>
}
// Server Component passes a Promise (without awaiting it)
export default async function Page() {
  const dataPromise = fetchUser() // not awaited

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserCard dataPromise={dataPromise} />
    </Suspense>
  )
}

The advantage of this pattern: the server can initiate requests without waiting, the client's Suspense boundary renders automatically when data is ready, and there is no manual loading state management.

Server Actions Improvements

React 19 improves error handling for Server Actions. useActionState (formerly useFormState) now has better TypeScript support:

'use client'

import { useActionState } from 'react'
import { submitForm } from './actions'

export function Form() {
  const [state, action, isPending] = useActionState(submitForm, {
    error: null,
    success: false,
  })

  return (
    <form action={action}>
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">Submitted successfully!</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  )
}

useOptimistic Is Now Stable

React 19 officially stabilizes useOptimistic for optimistic updates โ€” reflecting changes in the UI before the server confirms them:

'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from './actions'

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, delta: number) => current + delta
  )
  const [isPending, startTransition] = useTransition()

  function handleLike() {
    startTransition(async () => {
      addOptimisticLike(1)      // Update UI immediately
      await toggleLike(postId)  // Sync with server in the background
    })
  }

  return (
    <button onClick={handleLike} disabled={isPending}>
      โ™ฅ {optimisticLikes}
    </button>
  )
}

Turbopack: Stable for Development

# In Next.js 15, next dev uses Turbopack by default
next dev --turbopack
# Or simply (Next.js 15 default behavior)
next dev

Turbopack is Next.js's Rust-based bundler. In Next.js 15, the next dev path is officially stable. Official benchmarks show:

Important limitation: next build still uses webpack. Turbopack's production build support was not yet stable at the Next.js 15 release (expected in a subsequent version). This means development and production use different bundlers โ€” theoretically possible to have subtle differences, though rarely encountered in practice.

The <Form> Component

Next.js 15 introduces a <Form> component that enhances the native HTML <form>:

import Form from 'next/form'

export default function SearchPage() {
  return (
    <Form action="/search">
      <input name="q" placeholder="Search..." />
      <button type="submit">Search</button>
    </Form>
  )
}

How <Form> differs from a plain <form>:

Best suited for search boxes and filter forms โ€” "navigation forms" where submission changes the URL.

The after() API

after() allows you to run code after the response has been sent to the client, without blocking the response:

import { after } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function GET(request: NextRequest) {
  const data = await fetchData()

  // The response is sent immediately; after() runs asynchronously once the response completes
  after(async () => {
    // Log the access without affecting response latency
    await prisma.accessLog.create({
      data: {
        path: request.nextUrl.pathname,
        timestamp: new Date(),
        userAgent: request.headers.get('user-agent'),
      },
    })
  })

  return NextResponse.json(data)
}

Typical after() use cases:

Key behavior: errors in after() do not affect the already-sent response. On Vercel, after() tasks continue running after the response completes and are not subject to the function timeout.

instrumentation.ts Improvements

instrumentation.ts is Next.js's observability hook file. Next.js 15 adds lifecycle hooks including a new error handler:

// instrumentation.ts
import { registerOTel } from '@vercel/otel'

export function register() {
  // Executes once at application startup (server-side)
  registerOTel({ serviceName: 'my-nextjs-app' })

  console.log('Application started, instrumentation registered')
}

export async function onRequestError(
  error: Error,
  request: { path: string; method: string },
  context: { routeType: string }
) {
  // Captures all unhandled request errors (new in Next.js 15)
  await sendToErrorTracker({
    error,
    path: request.path,
    routeType: context.routeType,
  })
}

async function sendToErrorTracker(data: object) {
  await fetch(process.env.ERROR_TRACKER_ENDPOINT!, {
    method: 'POST',
    body: JSON.stringify(data),
  })
}

onRequestError is a new Next.js 15 hook that captures unhandled errors from Server Components, Server Actions, and Route Handlers in a single place. This is the officially recommended integration point for Sentry and other error monitoring services.

Pages Router to App Router Migration Strategy

Next.js supports Pages Router and App Router coexisting, which is the foundation for gradual migration:

app/
  layout.tsx        # App Router
  dashboard/
    page.tsx        # App Router page
pages/
  index.tsx         # Pages Router still works
  about.tsx         # Pages Router still works

Gradual Migration Approach

Step 1: Build new features in App Router

Do not attempt to migrate all routes at once. Use App Router for new features while existing routes continue in Pages Router.

Step 2: Migrate the layout layer

// app/layout.tsx โ€” replaces pages/_app.tsx and pages/_document.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* Global Providers, navigation, etc. */}
        {children}
      </body>
    </html>
  )
}

Step 3: Migrate routes one by one

Prioritize by independence or importance. Migrate first: pages with no dependencies on other Pages Router routes, simple static pages, new feature pages.

Common Migration Pitfalls

Pitfall 1: getServerSideProps / getStaticProps do not exist in App Router

// Pages Router
export async function getServerSideProps() {
  const data = await fetchData()
  return { props: { data } }
}

// App Router equivalent (fetch directly in the async Server Component)
export default async function Page() {
  const data = await fetchData()
  return <div>{JSON.stringify(data)}</div>
}

Pitfall 2: useRouter behaves differently

// Pages Router
import { useRouter } from 'next/router' // from next/router

// App Router
import { useRouter } from 'next/navigation' // from next/navigation
// Note: API is not identical โ€” useRouter in App Router has more limited functionality

Pitfall 3: Middleware is shared

Middleware runs on both Pages Router and App Router requests. Make sure your matcher configuration is correct.

Pitfall 4: Server Components cannot use Context

React Context is unavailable in Server Components. To share state between Server and Client Components, pass it via props or use Context below the Client Component boundary.

Next.js 14 to 15: Breaking Changes Checklist

Verify each item before upgrading:

Must address:

// Next.js 15: params is also async
export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ id: string }>
  searchParams: Promise<{ q: string }>
}) {
  const { id } = await params
  const { q } = await searchParams
  // ...
}

Recommended:

Optional adoption:

Run the upgrade:

npx @next/upgrade

This command automatically upgrades next, react, and react-dom to the latest versions and runs the recommended codemods.

The core signal of Next.js 15 is clear: align with standard Web APIs (async request APIs, fetch caching semantics matching the web standard), embrace React 19's concurrent features (use(), useOptimistic), and invest in the toolchain (Turbopack). These changes carry short-term migration costs but are the right long-term direction.

Rate this chapter
4.6  / 5  (4 ratings)

๐Ÿ’ฌ Comments