This metrics tool terrifies bad developers

Start free trial
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

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:

  1. Audit next.config.js for custom webpack configurations. Any webpack() function overrides will not carry over to Turbopack automatically.
  2. 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 a rules object instead of webpack's configuration object passed by reference; translate each plugin and override individually.
  3. Update .babelrc or Babel configuration. Turbopack uses SWC for transpilation; Babel is not supported. Custom Babel plugins need SWC equivalents or must be removed.
  4. Verify CSS module and PostCSS compatibility. Turbopack supports CSS modules and PostCSS, but edge cases in custom PostCSS plugin chains should be tested.
  5. Test third-party packages that hook into webpack internals for code generation or asset handling. These often break silently under Turbopack.
  6. Run next build and 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.)
  7. 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:

// next.config.js — BEFORE (Next.js 15 with webpack)
/** @type {import('next').NextConfig} */
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;
// next.config.js — AFTER (Turbopack configuration)
// In Next.js 15, this config lives under experimental.turbo.
// A future release may promote it to a top-level turbopack key.
// Verify the correct key for your installed version.
//
// NOTE: If your package.json includes "type": "module", __dirname is not
// available. In that case, use the ESM-safe form shown below this example.
const path = require('path');

/** @type {import('next').NextConfig} */
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:

// next.config.js — AFTER (Turbopack configuration, ESM-safe)
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

/** @type {import('next').NextConfig} */
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:

MetricWebpackTurbopack
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:

// next.config.js — React Compiler configuration (Next.js 15)
/** @type {import('next').NextConfig} */
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:

// src/components/IdentitySensitive.jsx
"use no memo";

import { useEffect, useRef } from 'react';

// This component relies on referential identity of the callback
// for integration with an external charting library that performs
// its own equality checks on callback references.
export default function IdentitySensitive({ onDataPoint }) {
  const chartRef = useRef(null);

  useEffect(() => {
    // External library stores and compares callback references internally
    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:

// BEFORE — Manual memoization (Next.js 14/15)
import { useMemo, useCallback, memo } from 'react';

const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
  // Compiler handles this automatically — safe to remove useMemo
  const discountedPrice = useMemo(
    () => product.price * (1 - product.discount),
    [product.price, product.discount]
  );

  // Compiler handles this automatically — safe to remove useCallback
  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;
// AFTER — React Compiler handles memoization
// The compiler automatically memoizes discountedPrice, handleClick,
// and the component itself based on dependency analysis.
// Verify compiler output confirms these optimizations before
// removing manual memoization in your codebase.
// NOTE: Without the React Compiler active, the inline arrow function
// creates a new reference on every render. Only remove useCallback
// after confirming the compiler is enabled and optimizing this component.

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().

// src/app/products/page.jsx
// Verify that these import paths match your installed Next.js version's exports.
// cacheLife and cacheTag are canary-only exports as of Next.js 15 —
// confirm they resolve cleanly in your installed version before use.
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>
  );
}
// src/app/api/revalidate/route.js
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) {
  // Fail closed: if the secret is not configured, reject all requests.
  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 PatternNew 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 for pnpm or yarn as 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:

  1. Update next and react packages to the target versions.
  2. Run the automated codemod to apply safe transformations.
  3. Address Turbopack compatibility using the migration checklist.
  4. Audit and simplify memoization with the React Compiler. This is also a good time to add eslint-plugin-react-compiler and catch purity violations before they silently prevent optimizations.
  5. Migrate the caching strategy to the new semantic APIs. Start with a single high-traffic route to validate behavior before converting the rest.
  6. Run the full test suite and compare build output sizes and performance against the documented baseline.
# Step 1: Update packages
# Replace 'latest' with the specific target version number (e.g., next@15.x.x)
npm install next@latest react@19 react-dom@19

# Step 2: Run the automated codemod
# Preview changes first with --dry-run before destructive execution:
#   npx @next/codemod upgrade --dry-run
# Then apply:
npx @next/codemod upgrade

# Step 3: Build and capture full output for review
npx next build 2>&1 | tee build.log

# Step 4-6: Manual audit, then verify
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.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.