Important: This article describes features that are partially available in Next.js 15 canary and partially proposed for a future Next.js release. As of writing, Next.js 16 has not been released. Verify each feature’s stability status and availability before applying any changes to production. Commands, config shapes, and API surfaces described here should be confirmed against the official Next.js changelog and documentation for your installed version.
Three changes shape how Next.js 15+ projects build, render, and cache: Turbopack moving toward becoming the default production bundler, the React Compiler progressing from experimental to built-in for automatic memoization, and a restructured caching layer built on explicit APIs. Each touches build performance, runtime behavior, and the upgrade path for every existing Next.js project.
Table of Contents
Turbopack as the Default Bundler
What Changed in Next.js 15 and What Is Coming
Next.js 15 ships Turbopack for the development server via the --turbopack flag and is stabilizing it for production builds. Webpack remains the default production bundler. A future Next.js release will make Turbopack the default for both development and production builds. Webpack will stay available as a fallback, but Vercel is shifting primary development investment toward Turbopack. New projects created with create-next-app in Next.js 15 prompt the user to opt into Turbopack; a future version will likely enable it by default.
Migration Checklist: Moving from Webpack to Turbopack
The transition from webpack to Turbopack requires systematic auditing. The following checklist covers the critical migration steps:
- Audit
next.config.jsfor custom webpack configurations. Anywebpack()function overrides will not carry over to Turbopack automatically. - Identify unsupported webpack plugins and replace
webpack()function overrides with Turbopack loader configuration. Not every webpack plugin has a direct counterpart in Turbopack’s loader system. Turbopack uses arulesobject instead of webpack’s configuration object passed by reference; translate each plugin and override individually. - Update
.babelrcor Babel configuration. Turbopack uses SWC for transpilation; Babel is not supported. Custom Babel plugins need SWC equivalents or must be removed. - Verify CSS module and PostCSS compatibility. Turbopack supports CSS modules and PostCSS, but edge cases in custom PostCSS plugin chains should be tested.
- Test third-party packages that hook into webpack internals for code generation or asset handling. These often break silently under Turbopack.
- Run
next buildand capture the full output for review. Pipe the output to a log file to surface compatibility issues that might not cause hard failures but affect output. (See upgrade sequence below for the exact command.) - Benchmark build times before and after migration. Document a baseline with webpack so improvements (or regressions) are measurable.
The most common migration task involves translating custom webpack loaders and aliases into Turbopack’s configuration format. Here is a before-and-after comparison of a next.config.js that adds an SVG loader and path aliasing:
const nextConfig = {
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
});
config.resolve.alias = {
...config.resolve.alias,
'@components': './src/components',
'@lib': './src/lib',
};
return config;
},
};
module.exports = nextConfig;
const path = require('path');
const nextConfig = {
experimental: {
turbo: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
resolveAlias: {
'@components': path.resolve(__dirname, 'src/components'),
'@lib': path.resolve(__dirname, 'src/lib'),
},
},
},
};
module.exports = nextConfig;
If your project uses ESM ("type": "module" in package.json), __dirname is not available. Use this form instead:
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const nextConfig = {
experimental: {
turbo: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
resolveAlias: {
'@components': resolve(__dirname, 'src/components'),
'@lib': resolve(__dirname, 'src/lib'),
},
},
},
};
export default nextConfig;
Note: Verify that @svgr/webpack works under Turbopack’s loader compatibility layer before relying on it. As of Next.js 15, some webpack loaders are unsupported. Consult the Turbopack documentation for the supported loader list. Use path.resolve(__dirname, '...') for reliable alias resolution rather than relative string paths.
Loaders are declared under rules with glob patterns as keys, and the as property tells Turbopack how to treat the output. Path aliases move into resolveAlias as a flat object.
Falling Back to Webpack (When and How)
For projects with deep webpack plugin dependencies or monorepo tooling that has not yet been updated for Turbopack, continuing to use webpack is straightforward. In Next.js 15, webpack is the default production bundler. To use Turbopack for development only, add --turbopack to your next dev command. No config key is needed to restore webpack; simply omit the --turbopack flag.
Acceptable use cases for staying on webpack include legacy plugin dependencies that have no SWC or Turbopack equivalent, monorepo setups with custom webpack federation configurations, and projects using Babel plugins with no SWC alternative. Track Turbopack’s compatibility progress via the Turbopack roadmap and issue tracker.
Performance Benchmarks at a Glance
The following table shows hypothetical figures for a mid-size Next.js project (~200 routes, ~150 dependencies). These are not cited benchmarks:
| Metric | Webpack | Turbopack |
|---|---|---|
| Cold production build | ~120s | ~45s |
| Incremental rebuild (production) | ~18s | ~4s |
| Dev server startup | ~8s | ~1.2s |
These numbers are illustrative, not measured against a specific public benchmark. For cited benchmarks with methodology, see the Turbopack benchmark page on turbo.build. Real-world results vary depending on project size, the number of dependencies, and the complexity of custom configurations. Teams should run their own benchmarks using the baseline documentation step from the migration checklist to validate improvements in their specific context.
The React Compiler: From Experimental Toward Default
What the React Compiler Does
The React Compiler performs automatic memoization of components and hooks at build time. It analyzes component purity and dependency graphs to determine which values can be safely memoized. When the compiler cannot prove purity, it leaves the component unoptimized. For components it can verify, it inserts the equivalent of useMemo, useCallback, and React.memo calls into the compiled output, eliminating unnecessary re-renders without requiring developers to manually wrap values and callbacks. The compiler operates on the principle that React components should be pure functions of their props and state. When it verifies that a component or expression meets this contract, it applies granular memoization that would be tedious and error-prone to maintain by hand.
The React Compiler performs automatic memoization of components and hooks at build time. It analyzes component purity and dependency graphs to determine which values can be safely memoized. When the compiler cannot prove purity, it leaves the component unoptimized.
Enabling and Configuring the React Compiler
As of Next.js 15, the React Compiler is available as an experimental opt-in feature. A future release will enable it by default, based on the current RFC trajectory. The React Compiler requires React 19 (or React 17+ with the react-compiler-runtime shim). In Next.js 15, the documented configuration is:
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;
Note: The exact config shape for excluding specific paths (e.g., an exclude array) should be verified against the React Compiler documentation and your installed Next.js version’s config schema. The shape may differ from what is shown in community examples.
To disable the compiler, remove the reactCompiler key or set it to false. For individual components or functions that need to opt out, the "use no memo" directive can be placed at the top of a file or at the top of a specific function body:
"use no memo";
import { useEffect, useRef } from 'react';
export default function IdentitySensitive({ onDataPoint }) {
const chartRef = useRef(null);
useEffect(() => {
chartRef.current?.bindCallback(onDataPoint);
}, [onDataPoint]);
return <div ref={chartRef} />;
}
The "use no memo" directive tells the compiler to skip automatic memoization for the entire file (when placed at the file level) or for a specific function (when placed at the top of a function body). This is appropriate when a component depends on intentional referential identity behavior that the compiler’s memoization would break.
Refactoring Existing Code: What to Remove, What to Keep
With the React Compiler active, manual useMemo, useCallback, and React.memo wrappers will often become redundant. Verify using the compiler’s annotation output before removing them to avoid performance regressions. The compiler handles these optimizations automatically and often more granularly than hand-written memoization, but blanket removal without verification is not recommended.
Potentially safe to remove (after verification): useMemo wrapping derived values from props or state, useCallback around event handlers passed to child components, and React.memo on components that are already pure.
Keep: Memoization tied to expensive non-rendering computation, such as heavy data transformations inside event handlers or effects that are not part of the render path. The compiler optimizes rendering; it does not optimize arbitrary JavaScript computation.
Here is a before-and-after comparison:
import { useMemo, useCallback, memo } from 'react';
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
const discountedPrice = useMemo(
() => product.price * (1 - product.discount),
[product.price, product.discount]
);
const handleClick = useCallback(() => {
onAddToCart(product.id, discountedPrice);
}, [onAddToCart, product.id, discountedPrice]);
return (
<div>
<h3>{product.name}</h3>
<p>${discountedPrice.toFixed(2)}</p>
<button onClick={handleClick}>Add to Cart</button>
</div>
);
});
export default ProductCard;
export default function ProductCard({ product, onAddToCart }) {
const discountedPrice = product.price * (1 - product.discount);
const handleClick = () => {
onAddToCart(product.id, discountedPrice);
};
return (
<div>
<h3>{product.name}</h3>
<p>${discountedPrice.toFixed(2)}</p>
<button onClick={handleClick}>Add to Cart</button>
</div>
);
}
The eslint-plugin-react-compiler package can be added to a project’s ESLint configuration to flag violations of the Rules of React that would prevent the compiler from optimizing effectively.
The New Cache API
Why the Old Caching Model Is Being Replaced
Next.js 14 and early 15 releases aggressively cached fetch() results and route data by default. This led to widespread developer confusion and stale data bugs, particularly in dynamic applications where data freshness was critical. The implicit caching behavior meant that developers often could not predict whether a page was serving fresh or cached content without deep knowledge of the framework’s internal caching layers. The new direction shifts to an opt-in caching model where developers must explicitly declare caching behavior using semantic APIs.
The implicit caching behavior meant that developers often could not predict whether a page was serving fresh or cached content without deep knowledge of the framework’s internal caching layers. The new direction shifts to an opt-in caching model where developers must explicitly declare caching behavior using semantic APIs.
The “use cache” Directive and cacheLife / cacheTag APIs
Prerequisite: These APIs are experimental in Next.js 15. To enable them, add experimental: { dynamicIO: true } to your next.config.js. Confirm their stability status in your target version’s release notes before production use.
The new caching system centers on three primitives: the "use cache" directive, the cacheLife() function, and the cacheTag() function.
The "use cache" directive can be placed at the function or file level to indicate that the output should be cached. cacheLife() defines how long cached entries remain valid, accepting preset profiles like "minutes", "hours", or custom duration objects (see the canary documentation for the full list of valid presets and the custom object shape, which may require { stale, revalidate, expire } rather than { revalidate } alone). cacheTag() attaches a string tag to a cached entry, enabling targeted invalidation via revalidateTag().
import { cacheLife, cacheTag } from 'next/cache';
export default async function ProductsPage() {
'use cache';
cacheLife("hours");
cacheTag("products");
const res = await fetch('https://api.example.com/products');
if (!res.ok) {
throw new Error(`Products API error: ${res.status} ${res.statusText}`);
}
let products;
try {
products = await res.json();
} catch {
throw new Error('Products API returned malformed JSON');
}
if (!Array.isArray(products)) {
throw new Error(`Expected array from products API, got ${typeof products}`);
}
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${Number(p.price).toFixed(2)}
</li>
))}
</ul>
);
}
import { revalidateTag } from 'next/cache';
import { timingSafeEqual } from 'crypto';
function safeCompare(a, b) {
const bufA = Buffer.from(a ?? '', 'utf8');
const bufB = Buffer.from(b ?? '', 'utf8');
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
export async function POST(request) {
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
console.error('REVALIDATE_SECRET environment variable is not set.');
return new Response('Service misconfigured', { status: 500 });
}
const provided = request.headers.get('x-revalidate-secret') ?? '';
if (!safeCompare(provided, secret)) {
return new Response('Unauthorized', { status: 401 });
}
try {
await Promise.resolve(revalidateTag('products'));
return Response.json({ revalidated: true });
} catch (err) {
console.error('revalidateTag failed:', err);
return new Response('Revalidation failed', { status: 500 });
}
}
Security note: Never expose revalidation endpoints without authentication. The example above requires an x-revalidate-secret header matching the REVALIDATE_SECRET environment variable, compared using a timing-safe equality check to prevent secret enumeration. If REVALIDATE_SECRET is not set, the endpoint returns 500 to fail closed. Without these guards, any caller can invalidate your cache, enabling denial-of-service or forcing expensive recomputations.
When the /api/revalidate endpoint is called with the correct secret, all cached entries tagged with "products" are invalidated, and the next request triggers a fresh computation.
Migrating from fetch Cache Options and revalidate Config
The mapping from current caching patterns to the new cache API equivalents is direct:
| Current Pattern | New Equivalent |
|---|---|
fetch(url, { cache: 'force-cache' }) | "use cache" directive with cacheLife() at desired duration |
fetch(url, { cache: 'no-store' }) | No "use cache" directive (default is uncached) |
fetch(url, { next: { revalidate: 60 } }) | "use cache" with cacheLife({ stale: 0, revalidate: 60, expire: 3600 }) (verify exact shape against docs) |
export const revalidate = 60 (route segment) | cacheLife({ stale: 0, revalidate: 60, expire: 3600 }) inside the function body (verify exact shape against docs) |
unstable_cache(fn, keys, opts) | "use cache" directive on the function (note: "use cache" is itself experimental in Next.js 15; confirm stability before treating this as a stability upgrade) |
fetch(url, { next: { tags: ['x'] } }) | cacheTag('x') inside a "use cache" function |
Caching no longer belongs to the fetch call or route segment config. It belongs to the function that performs the work. This makes caching behavior visible and auditable at the function level rather than scattered across fetch options and file-level exports.
Putting It All Together: Upgrading a Project Step by Step
Prerequisites
- Node.js: ≥18.18.0 (required for Next.js 15)
- Package manager: The examples below use
npm. Adjust forpnpmoryarnas needed. - React: React 19 and React DOM 19 are expected for the React Compiler integration. Confirm the required React version in the release notes for your target Next.js version.
Recommended Upgrade Sequence
Perform the upgrade incrementally, addressing each pillar in sequence:
- Update
nextandreactpackages to the target versions. - Run the automated codemod to apply safe transformations.
- Address Turbopack compatibility using the migration checklist.
- Audit and simplify memoization with the React Compiler. This is also a good time to add
eslint-plugin-react-compilerand catch purity violations before they silently prevent optimizations. - Migrate the caching strategy to the new semantic APIs. Start with a single high-traffic route to validate behavior before converting the rest.
- Run the full test suite and compare build output sizes and performance against the documented baseline.
npm install next@latest react@19 react-dom@19
npx @next/codemod upgrade
npx next build 2>&1 | tee build.log
npm test
npx next build
The codemod handles mechanical transformations such as updating import paths and deprecated API signatures. It does not handle custom webpack-to-Turbopack migration, memoization removal, or cache API migration, all of which require manual review.
Key Takeaways
The Next.js trajectory from version 15 onward delivers faster builds through Turbopack, less manual memoization boilerplate through the React Compiler, and predictable caching through the new "use cache" API with cacheLife and cacheTag. Each pillar includes a fallback: webpack remains available by omitting the --turbopack flag (or by remaining on the current default), the React Compiler can be disabled per-file, per-function, or project-wide, and the cache API is opt-in by design.
Migration is incremental. Teams do not need to address all three areas simultaneously. The migration checklist above can double as a project-level tracking tool for teams planning their upgrade.
The Next.js changelog and the upgrade documentation have the version-specific details.

