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:
- Slower page requests (previously cached requests now fire every time)
- Increased server/database load
- Content that was cached now being fetched live (which may or may not be what you want)
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:
- Local server startup: 76% faster
- Code updates (Fast Refresh): 96% faster
- Larger projects see more pronounced gains
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>:
- Client-side navigation: submitting uses Next.js client navigation (no full page refresh), updating both URL and page content
- Prefetching: automatically prefetches the target route when the form enters the viewport
- Loading state: native integration with
useFormStatus
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:
- Access logging
- Analytics event reporting
- Sending welcome emails after registration
- Cache updates
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:
-
cookies(),headers(),draftMode()โ all calls must beawaited -
fetchcaching โ audit allfetchcalls, explicitly addcacheoptions where needed -
paramsandsearchParamsโ in Pages and Layouts these are now Promises, requiringawait
// 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:
- React 19 compatibility โ check dependency libraries for React 19 support
-
useFormStaterenamed touseActionStateโ old name still works but is deprecated - Turbopack โ test the development experience, report incompatible webpack plugins
Optional adoption:
-
after()API โ move logging/analytics code intoafter() -
<Form>component โ replace search and filter forms -
instrumentation.tsonRequestErrorโ integrate error monitoring
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.