
Next.js Upgrading to version 15
This is the middle jump of the three major upgrades covered in this series, and it's the one where the ground genuinely shifts under a lot of existing code — specifically, the async Request APIs change, which is the single most consequential breaking change covered across versions 14, 15, and 16 combined. If you've ever wondered why so much modern Next.js code awaits cookies(), headers(), and params where older tutorials show them accessed synchronously, this version is where that pattern started, with a deliberately generous compatibility window to ease the transition.
Worth noting upfront: this project itself runs Next.js 16.3, where the temporary compatibility this guide describes has since been fully removed. If you're upgrading a live app currently on 14, this guide is exactly what you need; if you're already past 15, the "temporary synchronous usage" sections below no longer apply to you at all, and you should be looking at the version 16 guide instead for what happens when that compatibility window closes.
The upgrade command
The recommended path is the automated codemod, which handles the version bump for you:
# npm
npx @next/codemod@canary upgrade latest
# yarn
yarn dlx @next/codemod@canary upgrade latest
# pnpm
pnpm dlx @next/codemod@canary upgrade latest
# bun
bunx @next/codemod@canary upgrade latest
Or manually, if you prefer direct control over the process:
# npm
npm install next@latest react@latest react-dom@latest eslint-config-next@latest
# yarn
yarn add next@latest react@latest react-dom@latest eslint-config-next@latest
# pnpm
pnpm add next@latest react@latest react-dom@latest eslint-config-next@latest
# bun
bun add next@latest react@latest react-dom@latest eslint-config-next@latest
If you hit a peer-dependency warning during this install, it's expected during this specific transition period — you can update react/react-dom to the versions npm suggests, or pass --force / --legacy-peer-deps to push through it. This becomes unnecessary once both Next.js 15 and React 19 have fully stabilized as a combination.
React 19
The minimum supported react and react-dom version becomes 19. Two hook-level changes come along with that:
useFormState is replaced by useActionState. The old hook still technically works in React 19 — it's deprecated, not removed yet — but useActionState is the recommended replacement, and it includes additional capability the old hook didn't expose directly, like reading the pending state without a separate useFormStatus call.
useFormStatus gained additional fields — data, method, and action alongside the pending key that existed before. If you're not actually on React 19 yet for some reason, only pending is available; the extra fields are specifically a React 19 addition, not something Next.js 15 grants independently of your React version.
If you're using TypeScript, bump @types/react and @types/react-dom alongside the runtime packages — skipping this produces type errors that look unrelated to the actual React version mismatch causing them.
The async Request APIs — the change that actually matters here
This is the section worth reading carefully, because it affects far more real-world code than any other change in this specific version. Several previously-synchronous APIs, all of which read information tied to the current request, became asynchronous:
cookies()headers()draftMode()paramsinlayout.js,page.js,route.js,default.js, and the image-metadata files (opengraph-image,twitter-image,icon,apple-icon)searchParamsinpage.js
The recommended fix: actually await them
import { cookies } from "next/headers";
// Before
const cookieStore = cookies();
const token = cookieStore.get("token");
// After
const cookieStore = await cookies();
const token = cookieStore.get("token");
The same pattern applies identically to headers() and draftMode() — add await, and the function you're calling it in needs to be async if it isn't already.
The temporary escape hatch — synchronous access still (barely) works
To ease migration, this version doesn't hard-break synchronous access immediately — it logs a development warning instead, using a typecast to suppress the resulting TypeScript error:
import { cookies, type UnsafeUnwrappedCookies } from "next/headers";
const cookieStore = cookies() as unknown as UnsafeUnwrappedCookies;
// logs a warning in dev, still works for now
const token = cookieStore.get("token");
The type itself is named UnsafeUnwrappedCookies (and the equivalent UnsafeUnwrappedHeaders, UnsafeUnwrappedDraftMode) — not subtly, and not by accident. That naming is a deliberate signal that this is a stopgap for migrating incrementally, not a pattern to adopt as a long-term style choice. As covered in the version 16 guide, this compatibility path is removed entirely later — code relying on it will need to be revisited regardless, so treat the warning as a todo list, not background noise to suppress.
params and searchParams — the pattern differs by component type
For an async layout or page (the common case, since most already fetch data):
// Before
type Params = { slug: string };
export default async function Page({ params }: { params: Params }) {
const { slug } = params;
}
// After
type Params = Promise<{ slug: string }>;
export default async function Page({ params }: { params: Params }) {
const { slug } = await params;
}
For a synchronous component (common in Client Components, which can't be async functions themselves), unwrap the promise with React's use() instead of await:
"use client";
import { use } from "react";
type Params = Promise<{ slug: string }>;
export default function Page(props: { params: Params }) {
const params = use(props.params);
const slug = params.slug;
}
This distinction — await for async functions, use() for synchronous ones — is worth internalizing rather than memorizing case by case, because it recurs identically for generateMetadata, Route Handlers, and every other place params/searchParams shows up.
Let the codemod do the mechanical part
Given how much boilerplate this specific change touches across a typical app, running the dedicated codemod rather than hand-editing every file is worth doing even if you're comfortable with the pattern:
npx @next/codemod@latest next-async-request-api .
Where it can fully automate a fix, it does so silently. Where it can't confidently determine the right transformation, it inserts a comment or typecast flagging the spot — and your build will actually error until those flagged comments are explicitly resolved, a deliberate guardrail against a half-migrated codebase shipping quietly.
runtime configuration — experimental-edge is now just an error
The route-segment runtime config previously accepted 'experimental-edge' as a synonym for 'edge' — same behavior, two spellings. That redundancy is gone; using the old value now errors.
// Before
export const runtime = "experimental-edge";
// After
export const runtime = "edge";
A codemod (app-dir-runtime-config-experimental-edge) automates this one-line rename if you'd rather not grep for it manually.
fetch requests are no longer cached by default
This is a genuinely significant default flip, not a cosmetic one, and it's exactly the kind of change that produces zero build errors while silently altering runtime behavior. Previously, fetch calls inside Server Components were cached by default; as of this version, they aren't.
To opt a specific request back into caching:
export default async function RootLayout() {
const a = await fetch("https://..."); // Not cached
const b = await fetch("https://...", { cache: "force-cache" }); // Cached
}
To opt every fetch call within a layout or page into caching by default, without annotating each call individually:
export const fetchCache = "default-cache";
export default async function RootLayout() {
const a = await fetch("https://..."); // Cached, via the segment config
const b = await fetch("https://...", { cache: "no-store" }); // Explicitly opted out
}
If your app leans on data staying "fresh by default" without every fetch explicitly opting in, audit this carefully after upgrading — this is precisely the category of change worth writing a runtime smoke test for, not just trusting a green build.
Route Handlers — GET also stopped being cached by default
The same philosophy extends to Route Handlers: GET functions are no longer cached by default. To restore the old caching behavior for a specific handler:
export const dynamic = "force-static";
export async function GET() {}
Client-side navigation caching also changed
Page segments navigated to via <Link> or useRouter are no longer automatically reused from the client-side cache the way they were before — though shared layouts and loading states still are, and browser back/forward navigation still reuses cached segments as before. If you want pages to opt back into that caching behavior, staleTimes gives you explicit control:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
staleTimes: {
dynamic: 30,
static: 180,
},
},
};
module.exports = nextConfig;
Smaller renames worth a quick search-and-check
@next/font is fully removed (it was already deprecated as of version 13.2) — if any file still imports from it rather than the built-in next/font, that codemod from the version 14 guide still applies here.
experimental.bundlePagesExternals became the stable, top-level bundlePagesRouterDependencies.
experimental.serverComponentsExternalPackages became the stable, top-level serverExternalPackages.
Automatic Speed Insights instrumentation was removed. If you relied on it working without any setup, follow Vercel's own Speed Insights Quickstart guide to wire it up explicitly going forward.
NextRequest's geo and ip properties were removed, since those values are actually provided by your hosting provider rather than genuinely being Next.js's to expose. A codemod (next-request-geo-ip) handles this; on Vercel specifically, the @vercel/functions package's geolocation and ipAddress functions are the direct replacement.
Common mistakes
Fixing the async APIs with the UnsafeUnwrapped* typecast and stopping there. That escape hatch is explicitly temporary — it buys you migration time, not a permanent pattern. Treat every instance of it in your codebase as a tracked follow-up, not a resolved issue.
Missing that fetch caching flipped, because nothing failed to build. This is the quintessential "changed default, no error" breaking change — it requires you to actually go looking for it, since there's no compiler or linter that will flag "this code now behaves differently at runtime" the way it flags a type error.
Running the async-request codemod and ignoring its inserted review comments. As with any codemod that flags ambiguous spots, deleting the comment to unblock a build without addressing the underlying code defeats the entire point of the flag.
Key Takeaways
| Question | Answer |
|---|---|
| What's the single most impactful change in this version? | Request-time APIs (cookies, headers, draftMode, params, searchParams) becoming async |
| Is synchronous access to those APIs fully gone in v15? | Not yet — a deprecated, warning-logging compatibility path exists (removed later, in v16) |
Are fetch requests still cached by default? | No — opt in explicitly with cache: 'force-cache' or the fetchCache segment config |
What replaced useFormState? | useActionState, with additional capability like reading pending directly |
| Is there a codemod for the async APIs change? | Yes — next-async-request-api, which flags ambiguous spots for manual review |
Should I use await or use() for params? | await in async functions; React.use() in synchronous ones (e.g. Client Components) |
This is the upgrade where "the build passed" tells you the least about whether your app actually still behaves correctly — both the async API change and the fetch-caching default flip are the kind of thing that requires deliberately going and checking, not waiting for an error to surface it for you.


