What's New in Next.js 15

25. October, 2025 12 min read Develop

The release that took a default back

Frameworks rarely admit that a default was wrong. Next.js 15 does, twice. Fetches are no longer cached behind your back, GET route handlers are no longer static by accident, and the request APIs you have been calling synchronously for years now hand you a promise. It is the least glamorous kind of release and, I think, the most useful one Next.js has shipped.

Next.js 16 landed four days ago, which makes writing about 15 feel slightly odd. It is not, though: most of the codebases I touch are on some 15.x, and they will be for a good while yet. Everything below is about that line, from 15.0 through the point releases that quietly fixed half of it.

The two changes worth reading carefully are the async request APIs and the caching reversal. Both require you to do something. The rest is upside.

Turbopack, now by choice

The Rust bundler went stable for development in 15. You opt in on the dev script:

next dev --turbopack

Vercel published numbers from their own application, which is a large real-world Next.js codebase rather than a benchmark repository:

  • up to 76.7% faster local server startup
  • up to 96.3% faster code updates with Fast Refresh
  • up to 45.8% faster initial route compile, with no disk caching at that point

15.2 pushed compile times a further 57.6% down against 15.1, with roughly 30% less memory used during local development. My own experience is less dramatic than those headlines but consistently better, and the gap widens the bigger the project gets.

Turbopack takes a different bet from Vite. Vite serves modules unbundled over native ESM in dev, which starts instantly and then pays a little on every request. Turbopack bundles, but lazily, compiling only what the route you are looking at actually needs. You get fast startup without the dev/production divergence that unbundled ESM occasionally produces.

Under the hood it is SWC for JavaScript and TypeScript, Lightning CSS for stylesheets. Webpack loaders work through the turbopack.rules config (that key lived under experimental.turbo until 15.3, so check which version you are reading docs for). Webpack plugins do not work at all, and there is no compatibility layer coming. If your build depends on a plugin, that is your blocker, and it is worth finding out on day one rather than after you have rewritten your dev script.

Everything request-shaped is now a promise

This is the breaking change. These are all asynchronous:

  • cookies()
  • headers()
  • draftMode()
  • params, in layouts, pages, route handlers, default.js, generateMetadata and generateViewport
  • searchParams, in pages

So this, from 14:

import { cookies } from 'next/headers';

export default function Dashboard() {
  const cookieStore = cookies();
  const token = cookieStore.get('session');

  return <div>Dashboard for {token?.value}</div>;
}

becomes this:

import { cookies } from 'next/headers';

export default async function Dashboard() {
  const cookieStore = await cookies();
  const token = cookieStore.get('session');

  return <div>Dashboard for {token?.value}</div>;
}

Props follow the same rule:

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ query: string }>;
}) {
  const { slug } = await params;
  const { query } = await searchParams;

  return <div>Slug: {slug}, Query: {query}</div>;
}

The reasoning is worth understanding, because otherwise this looks like churn for its own sake. In traditional server rendering, nothing happens until a request shows up. But plenty of your component tree does not care about the request at all: a header, a nav, a marketing block. If the framework can tell which components need request data and which do not, it can render the second group ahead of time. Making the request APIs async is how it tells. That is the foundation for Partial Prerendering, further down.

A codemod does most of the work:

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

It handles direct call sites well and gives up on anything indirect: a helper that takes cookies() as an argument, a params object destructured three levels deep, anything behind a conditional. Expect to finish by hand. The synchronous forms still work in 15 with a deprecation warning, so you can migrate over a few pull requests rather than in one heroic commit.

Nothing is cached until you say so

Next.js 14 cached aggressively by default, and the resulting confusion was the single most common complaint I heard about the App Router. You would ship a fix, refresh, and see the old data. 15 reverses the defaults.

Fetches

// Not cached (the 15 default)
const data = await fetch('https://api.example.com/posts');

// Explicitly cached
const data = await fetch('https://api.example.com/posts', {
  cache: 'force-cache',
});

// Cached, revalidated hourly
const data = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 },
});

GET route handlers

In 14, a GET handler that used no dynamic function was rendered once at build time. In 15 it is dynamic unless you say otherwise:

export const dynamic = 'force-static';

export async function GET() {
  return Response.json({ data: 'cached' });
}

Metadata files are the exception. sitemap.ts, opengraph-image.tsx, icon.tsx and friends stay static by default unless they reach for something dynamic.

The client router cache

Page segments now have a staleTime of 0, so navigating to a route fetches its data again. Layouts still are not refetched, which keeps partial rendering intact, and back/forward navigation still restores from cache so the browser can put your scroll position back. loading.js stays cached for five minutes.

If you liked the old behaviour:

// next.config.js
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,
    },
  },
};

I would not reach for that config, personally. Stale data is a horrible class of bug to debug because it never reproduces on your machine, and you spend the first hour convinced the problem is in your query. Choosing correctness first and adding caching deliberately where the profile says it matters is simply the better order to work in.

React 19 underneath

15.0 shipped the App Router on React 19 RC; 15.1 moved to React 19 stable, and the Pages Router kept working with React 18 for anyone not ready. In practice that means everything from the React 19 release is available: Server Actions as real form handlers, useActionState, useFormStatus, useOptimistic, and use for reading a promise during render.

React 19 also added sibling pre-warming for Suspense. When one boundary suspends, React begins work on its siblings instead of sitting idle, which shows up as less staggered loading on pages with several independent data sources.

after()

Some work has no business blocking a response. Logging, analytics, syncing a row to some other system. On a serverless platform you cannot simply fire a promise and walk away, because the function is torn down the moment the response closes.

import { after } from 'next/server';

export default function Page() {
  after(() => {
    analytics.track('page_view', { page: '/dashboard' });
  });

  return <Dashboard />;
}

It arrived as unstable_after behind an experimental flag in 15.0 and went stable in 15.1, dropping the prefix. It works in Server Components, Server Actions, route handlers and middleware. The important difference from a bare waitUntil is that the framework owns the lifecycle, including on self-hosted servers, so the callback still runs when the client disconnects mid-stream.

The <Form> component

next/form is a small thing that removes a nuisance:

import Form from 'next/form';

export default function SearchForm() {
  return (
    <Form action="/search">
      <input name="query" placeholder="Search…" />
      <button type="submit">Search</button>
    </Form>
  );
}

It prefetches the target layout and loading UI once the form scrolls into view, navigates client-side on submit so shared layouts and state survive, and falls back to a plain full-page form post if JavaScript has not loaded yet. Getting all three by hand meant a useRouter, an onSubmit that reassembles FormData into a query string, and a router.prefetch in an effect. I have written that component more than once. I would rather not write it again.

Worth being clear about the scope: this is for forms that navigate, like search. Forms that mutate still want a Server Action.

instrumentation.ts

Stable in 15, and the experimental.instrumentationHook flag can come out of your config. Put the file at the project root:

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { NodeSDK } = await import('@opentelemetry/sdk-node');
    new NodeSDK().start();
  }
}

export async function onRequestError(
  err: Error,
  request: { path: string; method: string },
  context: { routerKind: string; routeType: string }
) {
  await errorTracker.captureException(err, {
    path: request.path,
    routeType: context.routeType,
  });
}

onRequestError was designed with Sentry, and the context it hands you is the part that matters: which router, and whether the error came from a Server Component, a Server Action, a route handler or middleware. Stack traces from a server render are otherwise not much help in telling you where you were when it blew up.

Errors you can actually read

Spread across the point releases, and easy to overlook in a changelog:

  • 15.0: hydration errors now show the offending source with a suggested fix, instead of the old diff-of-two-trees message that told you nothing.
  • 15.1: source maps use the ignoreList property, so frames from your dependencies collapse out of the way in both the browser overlay and the terminal. Terminal output was reformatted to match the browser. Ignored frames also disappear from browser profiles, which makes profiling a Next.js app far less noisy.
  • 15.2: a redesigned overlay built on React’s captureOwnerStack, which points at the subcomponent that actually created the bad element rather than the intermediate that passed it along. A new dev indicator shows rendering mode, Turbopack compile status and active errors in one place.

None of this is a feature you would put on a slide. All of it is time you get back.

Metadata stopped blocking

In 14, an async generateMetadata had to resolve before any HTML went out, because the <title> belongs in the <head> and the head comes first. One slow CMS call and your entire page waited on it.

From 15.2 the initial UI streams immediately and metadata is streamed into the head when it resolves. Bots and crawlers still get the complete document up front, detected by user agent, and you can adjust which agents get that treatment with htmlLimitedBots in next.config.

next.config.ts

TypeScript config, with a NextConfig type:

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: true,
  },
  turbopack: {
    rules: {
      '*.svg': {
        loaders: ['@svgr/webpack'],
        as: '*.js',
      },
    },
  },
};

export default nextConfig;

reactCompiler still lives under experimental in the 15 line. It runs through Babel, so builds get slower when you turn it on; there is more on the trade-off in my post on the React Compiler.

Server Actions got quieter

Two changes, both automatic. Unused Server Actions are removed by dead code elimination during the build, so an exported action nobody calls no longer sits there as a live public endpoint. Actions that are used get non-deterministic IDs, recalculated between builds, which means you cannot guess an action URL and you cannot replay one from a previous deployment.

That is a genuine improvement, and it is not a security model. A Server Action is still a public HTTP endpoint that anyone can call with any payload. Validate the session and the input inside the action itself, every time: the same discipline I described in the post on Server Actions.

Self-hosting

Vercel gets accused of neglecting the self-hosted path. 15 pushes back on that a little:

  • Custom Cache-Control headers are no longer overwritten with framework defaults, so your CDN configuration finally survives contact with the app.
  • The stale-while-revalidate window is configurable as expireTime in next.config, replacing the old experimental.swrDelta. Its default moved to one year, which is what most CDNs need to apply SWR semantics properly.
  • sharp no longer needs installing by hand. next start and standalone output use it automatically, which removes a deployment gotcha that bit people for years.

Partial Prerendering, still experimental

PPR is what the async request APIs are for. A page gets split into a static shell and dynamic holes:

import { Suspense } from 'react';

export const experimental_ppr = true;

export default function Page() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Sidebar />
      <Suspense fallback={<Skeleton />}>
        <DynamicContent />
      </Suspense>
    </main>
  );
}

The heading and sidebar are prerendered and served from the edge instantly. DynamicContent renders per request and streams into its slot. You enable it per route with that export, having first switched it on in the config:

const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};

It is still experimental, and I would not put it on a client project yet. But it explains the whole shape of the release: without knowing which components need the request, the framework cannot decide what to prerender.

Upgrading from 14

npx @next/codemod@canary upgrade latest

That updates dependencies and offers the relevant codemods. Four things to check afterwards:

  1. Async APIs. The codemod gets the direct cases. Hunt for helpers that pass cookies() or params around.
  2. Caching. Anything that was fast because it was accidentally cached is now hitting your database on every request. Add force-cache or revalidate deliberately, and watch your query counts after deploy.
  3. Config renames. serverComponentsExternalPackages is now serverExternalPackages, bundlePagesExternals is now bundlePagesRouterDependencies.
  4. Node.js. Minimum is 18.18.0.

The migration I would budget real time for is the second one. Type errors announce themselves; a fetch that used to be free and now is not will only show up as a slightly unhappy database.

What I’d do

If you are on 14 and staying put, do the async request API migration anyway. It is the one piece of work that carries forward, and 16 has already removed the synchronous fallbacks.

The thing still keeping Turbopack out of older projects is the webpack plugin gap, and no amount of startup-time percentage fixes that. Check your plugin list before you promise anyone a faster dev server

‘Till next time!