把一个 App Router 项目从 Next.js 14 升到 15,最先跳出来的往往不是报错,是数据库的监控曲线。原本靠 fetch 自动缓存扛住的列表接口,升级后每次请求都真的打到了后端,QPS 直接翻几倍。紧接着才是编辑器里那一片红,cookies() 拿不到值了,params.slug 变成了 undefined。
这两件事都不是 bug,是 Next.js 15 有意为之的行为变更。这篇把 15 的核心改动逐条拆开,讲清楚改法、背后的原因,以及哪些可以先放着不动。读完你会有一份能直接排期的迁移清单,而不是只知道「要加 await」。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 升级前的环境要求和官方 codemod 能覆盖的范围
- React 19 带来的
useActionState、useFormStatus增强和 Actions - 异步 Request API 的完整清单,以及 layout 里怎么用
use保持同步 fetch默认不缓存这一刀砍下去,你的项目该怎么补- 客户端路由缓存调整和
staleTimes配置 @next/font、experimental-edge、NextRequest.geo等一批 API 变更- 一套渐进式迁移的节奏建议
# 一、升级前的准备工作
Next.js 15 的环境要求比 14 高一档,但没到 16 那种「Node 18 直接不给跑」的程度:
- Node.js 18.17.0 或更高
- 用 TypeScript 的话,
@types/react和@types/react-dom要跟着升 - 包管理器建议 pnpm 8+、npm 10+ 或 yarn 1.22+
类型包这条别跳过。React 19 的类型定义变化不小,老版本 @types/react 配新版 React 会在 JSX 返回值、ref 类型这些地方报一堆看起来毫无道理的错,找半天原因才发现是类型包没动。
# 一键升级
官方 codemod 能吃掉大部分机械劳动:
# 使用 pnpm
pnpm dlx @next/codemod@canary upgrade latest
# 使用 npm
npx @next/codemod@canary upgrade latest
# 使用 yarn
yarn dlx @next/codemod@canary upgrade latest
# 使用 bun
bunx @next/codemod@canary upgrade latest
它主要处理三类事,异步 API 的 await 补全、@next/font 到 next/font 的导入替换、以及几个配置项的重命名。剩下的业务逻辑调整还是得人工过。
# 手动升级
# pnpm
pnpm add next@latest react@latest react-dom@latest eslint-config-next@latest
# 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
注意:升级过程中如果遇到 peer dependencies 报警,可能需要手动指定 React 版本,或者临时用
--force/--legacy-peer-deps绕过。生态里的第三方库跟上 React 19 需要时间,这类警告在过渡期很常见。
# 二、React 19 全面支持
Next.js 15 把 React 和 ReactDOM 的最低版本抬到了 19,所以 19 的新能力可以直接用。对日常业务影响最大的是表单这一块。
# useFormState 换成 useActionState
useFormState 被 useActionState 取代,前者还能用但已经标记废弃。新 API 最实用的改动是把 pending 状态直接返回出来了:
import { useActionState } from 'react'
async function submitForm(prevState, formData) {
const result = await fetch('/api/submit', {
body: formData
})
return result.json()
}
export default function Form() {
const [state, submitAction, isPending] = useActionState(submitForm, null)
return (
<form action={submitAction}>
{/* 表单字段 */}
<button type="submit" disabled={isPending}>
{isPending ? '提交中...' : '提交'}
</button>
</form>
)
}
以前拿 pending 得往下再套一层组件用 useFormStatus,因为那个 Hook 必须在 form 的子树里才生效。现在同一层就能拿到,为了一个 loading 态而拆组件的日子结束了。
这个改动看着小,实际上它改变了表单组件的组织方式。
# useFormStatus 拿到了更多信息
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending, data, method, action } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
)
}
多出来的 data、method、action 用在做通用提交按钮组件时很方便,比如根据 method 决定按钮文案,或者从 data 里读出正在提交的条目 ID 做局部高亮。
提示:如果还没升到 React 19,
useFormStatus依然只有pending一个属性。
React 19 本身还有 use、ref 作为 prop、文档元数据托管这些改动,我在 React 19 新特性 那篇里单独写过。
# 三、异步 Request API
这是 15 里最影响面积的改动。原本同步的动态 API 全部改成异步。
# cookies
import { cookies } from 'next/headers'
// Next.js 14,同步
const cookieStore = cookies()
const token = cookieStore.get('token')
// Next.js 15,异步
const cookieStore = await cookies()
const token = cookieStore.get('token')
如果一时改不完,官方给了一个过渡用的类型转换:
import { cookies, type UnsafeUnwrappedCookies } from 'next/headers'
// 临时同步用法,开发模式会打警告
const cookieStore = cookies() as unknown as UnsafeUnwrappedCookies
const token = cookieStore.get('token')
名字里带 Unsafe 就是在提醒你这是个临时口子。Next.js 16 已经把它彻底移除了,所以别把它当长期方案,升级到 15 的同时就该把这些点排进计划。
# headers 和 draftMode
import { headers } from 'next/headers'
// Next.js 14
const headersList = headers()
const userAgent = headersList.get('user-agent')
// Next.js 15
const headersList = await headers()
const userAgent = headersList.get('user-agent')
import { draftMode } from 'next/headers'
// Next.js 14
const { isEnabled } = draftMode()
// Next.js 15
const { isEnabled } = await draftMode()
那为什么非要改成异步呢?这几个 API 读的都是当次请求才存在的信息。同步读取意味着渲染必须停在这里等,整棵组件树被一个 UA 判断卡住。改成 Promise 之后,React 可以在等待请求信息落定的同时先去渲染不依赖它的分支,并发渲染和后来的 PPR 都建立在这个前提上。
# params 和 searchParams
layout 和 page 组件里的 params、searchParams 也变成了 Promise:
// Next.js 14
type Params = { slug: string }
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function Page({
params,
searchParams,
}: {
params: Params
searchParams: SearchParams
}) {
const { slug } = params
const { query } = searchParams
}
// Next.js 15
type Params = Promise<{ slug: string }>
type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>
export default async function Page(props: {
params: Params
searchParams: SearchParams
}) {
const params = await props.params
const searchParams = await props.searchParams
const { slug } = params
const { query } = searchParams
}
这里有个容易漏的地方。TypeScript 不会拦住你写 props.params.slug,因为 Promise 上确实没有 slug,但如果你的类型标注还停在旧版本,编译期一片安静,运行时才拿到 undefined。所以类型包必须同步升级,这一条前面提过一次,这里再强调一遍。
# layout 里想保持同步就用 use
不是所有组件都适合改成 async,尤其是 layout。React 19 的 use Hook 可以在同步组件里解开 Promise: