Next.js has continued its rapid evolution, with versions 15 and 16 bringing transformative features that change how we build web applications. From the stable Turbopack bundler delivering 10x faster builds to Partial Prerendering (PPR) enabling the best of static and dynamic rendering, these releases represent the most significant updates since the App Router introduction. This guide covers everything you need to know to migrate your applications and take advantage of these powerful new capabilities. See also our guides on React Server Components, TypeScript 5.x, and frontend testing strategies.
What's New in Next.js 15 & 16
- Turbopack Stable: The Rust-based bundler is now production-ready with 10x faster cold starts
- Partial Prerendering (PPR): Combine static shells with dynamic content in a single route
- React 19 Support: Full support for React 19 features including Actions and use()
- Improved Caching: New caching semantics with better defaults and explicit control
- Enhanced Metadata API: Dynamic OG images and improved SEO capabilities
- Server Actions Enhancements: Better error handling and progressive enhancement
- Instrumentation Hook: Built-in observability and monitoring support
- Static Route Indicator: Visual feedback during development for route rendering types
Turbopack: Production-Ready Performance
Turbopack, the Rust-based successor to Webpack, is now stable for both development and production builds. The performance improvements are dramatic: cold starts are up to 10x faster, and hot module replacement (HMR) updates in milliseconds regardless of application size.
# Enable Turbopack in development
next dev --turbo
# Enable Turbopack for production builds
next build --turbo
# Or configure in next.config.js// next.config.js - Turbopack Configuration
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
turbo: {
// Custom loader rules (replacing webpack loaders)
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
// Resolve aliases
resolveAlias: {
underscore: 'lodash',
mocha: { browser: 'mocha/browser-entry.js' },
},
// Resolve extensions
resolveExtensions: [
'.mdx',
'.tsx',
'.ts',
'.jsx',
'.js',
'.mjs',
'.json',
],
},
},
};
module.exports = nextConfig;While Turbopack supports most webpack features, some advanced configurations may need adjustments. The most common migration issues involve custom loaders and plugins that need Turbopack equivalents or workarounds.
Partial Prerendering (PPR)
Partial Prerendering is a groundbreaking rendering strategy that combines the benefits of static and dynamic rendering in a single route. The static shell is served instantly from the edge, while dynamic content streams in as it becomes available. This eliminates the traditional tradeoff between static performance and dynamic personalization.
// app/dashboard/page.tsx - Partial Prerendering Example
import { Suspense } from 'react';
import { unstable_noStore as noStore } from 'next/cache';
// This component renders statically (part of the shell)
function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard">
<nav className="sidebar">
<h1>Dashboard</h1>
<ul>
<li><a href="/dashboard">Overview</a></li>
<li><a href="/dashboard/analytics">Analytics</a></li>
<li><a href="/dashboard/settings">Settings</a></li>
</ul>
</nav>
<main className="content">{children}</main>
</div>
);
}
// This component is dynamic - fetches user-specific data
async function UserStats() {
noStore(); // Opt out of caching - makes this dynamic
const stats = await fetch('https://api.example.com/user/stats', {
headers: { Authorization: `Bearer ${cookies().get('token')?.value}` }
}).then(r => r.json());
return (
<div className="stats-grid">
<div className="stat">
<span className="label">Total Revenue</span>
<span className="value">${stats.revenue.toLocaleString()}</span>
</div>
<div className="stat">
<span className="label">Active Users</span>
<span className="value">{stats.activeUsers.toLocaleString()}</span>
</div>
</div>
);
}
// This component is also dynamic
async function RecentActivity() {
noStore();
const activity = await fetch('https://api.example.com/user/activity').then(r => r.json());
return (
<ul className="activity-feed">
{activity.map((item: any) => (
<li key={item.id}>
<span>{item.action}</span>
<time>{new Date(item.timestamp).toLocaleString()}</time>
</li>
))}
</ul>
);
}
// The page combines static shell with dynamic content
export default function DashboardPage() {
return (
<DashboardLayout>
{/* Static content */}
<h2>Welcome back!</h2>
<p>Here's what's happening with your account.</p>
{/* Dynamic content wrapped in Suspense */}
<Suspense fallback={<div className="skeleton stats-skeleton" />}>
<UserStats />
</Suspense>
<h3>Recent Activity</h3>
<Suspense fallback={<div className="skeleton activity-skeleton" />}>
<RecentActivity />
</Suspense>
</DashboardLayout>
);
}To enable PPR, add the configuration to your next.config.js. You can enable it globally or per-route using the experimental_ppr route segment config.
// next.config.js - Enable PPR
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true, // Enable globally
// Or use 'incremental' to enable per-route
// ppr: 'incremental',
},
};
module.exports = nextConfig;
// Per-route opt-in (when using incremental mode)
// app/dashboard/page.tsx
export const experimental_ppr = true;React 19 Integration
Next.js 15+ fully supports React 19's new features, including Actions, the use() hook, and improved Suspense handling. These features work seamlessly with Server Components and Server Actions.
// app/components/SearchForm.tsx - React 19 Actions with useActionState
'use client';
import { useActionState } from 'react';
import { searchProducts } from '@/app/actions';
export function SearchForm() {
// useActionState provides pending state and form action binding
const [state, formAction, isPending] = useActionState(searchProducts, {
results: [],
error: null,
});
return (
<div>
<form action={formAction}>
<input
name="query"
type="search"
placeholder="Search products..."
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
{state.error && (
<div className="error">{state.error}</div>
)}
{state.results.length > 0 && (
<ul className="results">
{state.results.map((product: any) => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
)}
</div>
);
}
// app/actions.ts - Server Action
'use server';
export async function searchProducts(prevState: any, formData: FormData) {
const query = formData.get('query') as string;
if (!query || query.length < 2) {
return { results: [], error: 'Please enter at least 2 characters' };
}
try {
const response = await fetch(
`https://api.example.com/products?search=${encodeURIComponent(query)}`
);
const results = await response.json();
return { results, error: null };
} catch (error) {
return { results: [], error: 'Search failed. Please try again.' };
}
}// React 19 use() hook for reading resources
import { use, Suspense } from 'react';
// Promise that will be read with use()
const dataPromise = fetch('https://api.example.com/data').then(r => r.json());
function DataDisplay() {
// use() suspends until the promise resolves
const data = use(dataPromise);
return (
<div>
<h2>{data.title}</h2>
<p>{data.description}</p>
</div>
);
}
// Reading context with use()
import { createContext, use } from 'react';
const ThemeContext = createContext('light');
function ThemedButton() {
// use() can read context (alternative to useContext)
const theme = use(ThemeContext);
return (
<button className={`btn-${theme}`}>
Click me
</button>
);
}
export default function Page() {
return (
<ThemeContext.Provider value="dark">
<Suspense fallback={<div>Loading...</div>}>
<DataDisplay />
</Suspense>
<ThemedButton />
</ThemeContext.Provider>
);
}New Caching Semantics
Next.js 15 introduced significant changes to caching behavior. fetch() requests and route handlers are no longer cached by default, giving developers explicit control over caching strategies. This change addresses common confusion about when data was fresh versus stale.
// New caching behavior in Next.js 15+
// NOT cached by default (dynamic)
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
// Explicitly cached (static)
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'force-cache', // Explicitly cache
});
return res.json();
}
// Time-based revalidation
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // Revalidate every hour
});
return res.json();
}
// Tag-based revalidation
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { tags: ['posts', `post-${slug}`] },
});
return res.json();
}
// Revalidate by tag (in a Server Action)
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePost(slug: string, data: any) {
await fetch(`https://api.example.com/posts/${slug}`, {
method: 'PUT',
body: JSON.stringify(data),
});
// Revalidate this specific post and the posts list
revalidateTag(`post-${slug}`);
revalidateTag('posts');
}// Route Handler caching (app/api/data/route.ts)
import { NextRequest, NextResponse } from 'next/server';
// Dynamic by default in Next.js 15+
export async function GET(request: NextRequest) {
const data = await fetchData();
return NextResponse.json(data);
}
// Opt into static generation
export const dynamic = 'force-static';
// Or with revalidation
export const revalidate = 3600; // Revalidate every hour
// Dynamic route handlers remain dynamic
export async function POST(request: NextRequest) {
const body = await request.json();
const result = await createData(body);
return NextResponse.json(result);
}Server Actions Enhancements
Server Actions have received significant improvements including better error boundaries, progressive enhancement, and security hardening. The new useActionState hook (replacing useFormState) provides better integration with React 19.
// app/actions/auth.ts - Enhanced Server Actions
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { z } from 'zod';
// Validation schema
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export type LoginState = {
errors?: {
email?: string[];
password?: string[];
_form?: string[];
};
success?: boolean;
};
export async function login(
prevState: LoginState,
formData: FormData
): Promise<LoginState> {
// Validate input
const validatedFields = loginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
const { email, password } = validatedFields.data;
try {
// Authenticate user
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
return {
errors: {
_form: [error.message || 'Invalid credentials'],
},
};
}
const { token, user } = await response.json();
// Set secure cookie
cookies().set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
});
} catch (error) {
return {
errors: {
_form: ['An unexpected error occurred. Please try again.'],
},
};
}
// Redirect on success (must be outside try/catch)
redirect('/dashboard');
}
// Client component using the action
// app/components/LoginForm.tsx
'use client';
import { useActionState } from 'react';
import { login, type LoginState } from '@/app/actions/auth';
const initialState: LoginState = {};
export function LoginForm() {
const [state, formAction, isPending] = useActionState(login, initialState);
return (
<form action={formAction} className="space-y-4">
{state.errors?._form && (
<div className="bg-red-50 text-red-600 p-3 rounded">
{state.errors._form.join(', ')}
</div>
)}
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
className={state.errors?.email ? 'border-red-500' : ''}
/>
{state.errors?.email && (
<p className="text-red-500 text-sm">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
required
className={state.errors?.password ? 'border-red-500' : ''}
/>
{state.errors?.password && (
<p className="text-red-500 text-sm">{state.errors.password[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
>
{isPending ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
}Migration Guide: From Next.js 14 to 15/16
Migrating to Next.js 15 and 16 requires attention to breaking changes, particularly around caching behavior and React 19 compatibility. Here's a step-by-step migration approach.
Step 1: Update Dependencies
# Update Next.js and React
npm install next@latest react@latest react-dom@latest
# Update TypeScript types
npm install -D @types/react@latest @types/react-dom@latest
# Run the codemod for automatic updates
npx @next/codemod@latest upgrade latestStep 2: Address Caching Changes
// Before (Next.js 14) - fetch was cached by default
async function getData() {
const res = await fetch('https://api.example.com/data');
return res.json();
}
// After (Next.js 15+) - explicitly cache if needed
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // Add explicit caching
});
return res.json();
}
// Or use the unstable_cache for function-level caching
import { unstable_cache } from 'next/cache';
const getCachedData = unstable_cache(
async () => {
const res = await fetch('https://api.example.com/data');
return res.json();
},
['data-cache-key'],
{ revalidate: 3600, tags: ['data'] }
);Step 3: Update React 19 APIs
// Before (React 18) - useFormState
import { useFormState } from 'react-dom';
function Form() {
const [state, formAction] = useFormState(submitAction, initialState);
// ...
}
// After (React 19) - useActionState
import { useActionState } from 'react';
function Form() {
const [state, formAction, isPending] = useActionState(submitAction, initialState);
// isPending is now built-in!
// ...
}Step 4: Update next.config.js
// next.config.js - Updated configuration
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable new features
experimental: {
ppr: 'incremental', // Partial Prerendering
turbo: {
// Turbopack config if needed
},
},
// Update image configuration
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.example.com',
},
],
},
// bundlePagesRouterDependencies is now default true
// serverExternalPackages replaces serverComponentsExternalPackages
serverExternalPackages: ['some-package'],
};
module.exports = nextConfig;Performance Optimization Tips
- Enable Turbopack: Use --turbo for significantly faster development builds
- Implement PPR: Combine static shells with dynamic content for optimal loading
- Strategic Caching: Explicitly cache data that doesn't change frequently
- Parallel Data Fetching: Use Promise.all() for independent data requests
- Image Optimization: Leverage next/image with proper sizing and formats
- Bundle Analysis: Use @next/bundle-analyzer to identify large dependencies
- Dynamic Imports: Code-split heavy components with next/dynamic
// Performance optimization example
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// Dynamic import for heavy component
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div className="skeleton h-64" />,
ssr: false, // Skip SSR for client-only components
});
// Parallel data fetching
async function DashboardPage() {
// Fetch all data in parallel
const [user, stats, notifications] = await Promise.all([
getUser(),
getStats(),
getNotifications(),
]);
return (
<div>
<UserHeader user={user} />
<StatsOverview stats={stats} />
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart data={stats.chartData} />
</Suspense>
<NotificationList notifications={notifications} />
</div>
);
}Frequently Asked Questions
Frequently Asked Questions
Should I upgrade to Next.js 15 or wait for 16?
If you're starting a new project, use the latest stable version (15.x). For existing projects, Next.js 15 is stable and recommended. Next.js 16 features are being incrementally released, so you can adopt them as they become stable.
Is Turbopack ready for production?
Turbopack is production-ready for development builds in Next.js 15. For production builds, it's still being optimized but most teams report significant improvements. Use --turbo flag to enable it.
What is Partial Prerendering (PPR) and when should I use it?
PPR allows you to combine static and dynamic rendering in a single page. The static shell loads instantly while dynamic parts stream in. Use it for pages with mostly static content but some personalized or real-time elements.
How do I migrate from Pages Router to App Router?
Migrate incrementally - both routers can coexist. Start by moving simpler pages, then gradually migrate complex ones. Key changes include using React Server Components by default and the new file-based routing conventions.
Will my existing Next.js 13/14 app work with Next.js 15?
Most apps will work with minimal changes. Key breaking changes include async request APIs, new caching defaults, and updated image component. Review the migration guide and test thoroughly before deploying.
Conclusion
Next.js 15 and 16 represent a significant leap forward in React framework capabilities. Turbopack delivers the performance developers have been waiting for, Partial Prerendering eliminates the static/dynamic tradeoff, and React 19 integration brings powerful new patterns for building interactive applications. While the migration requires attention to breaking changes, the benefits in developer experience and application performance make the upgrade worthwhile.
Start by enabling Turbopack in development to experience the performance improvements immediately. Then gradually adopt PPR for pages that benefit from the hybrid rendering approach. With careful migration planning, you can take full advantage of these powerful new capabilities.
Need help migrating your Next.js application or building new features with the latest capabilities? Contact Jishu Labs for expert frontend development services. Our team has extensive experience with Next.js and can help you leverage these new features effectively.
About Emily Rodriguez
Emily Rodriguez is a Senior Frontend Engineer at Jishu Labs specializing in React and Next.js applications. She has built high-performance web applications for Fortune 500 companies and contributes to open-source projects.