导航
导航
文章目录󰁋
  1. 一、升级前的准备工作
    1. 一键升级
    2. 手动升级
  2. 二、React 19 全面支持
    1. useFormState 换成 useActionState
    2. useFormStatus 拿到了更多信息
  3. 三、异步 Request API
    1. cookies
    2. headers 和 draftMode
    3. params 和 searchParams
    4. layout 里想保持同步就用 use
    5. Route Handler 里的 params
  4. 四、fetch 默认不再缓存
    1. 整段批量开缓存
    2. Route Handler 的 GET 同样受影响
  5. 五、客户端路由缓存策略调整
  6. 六、其他 API 变更
    1. @next/font 包被移除
    2. experimental-edge 废弃
    3. 两个实验配置转正
    4. NextRequest 上的 geo 和 ip 没了
    5. Speed Insights 自动埋点移除
  7. 七、迁移节奏建议
  8. 总结
  9. 参考

Next.js 15 新特性完全指南 异步API与缓存策略的全部变化

把一个 App Router 项目从 Next.js 14 升到 15,最先跳出来的往往不是报错,是数据库的监控曲线。原本靠 fetch 自动缓存扛住的列表接口,升级后每次请求都真的打到了后端,QPS 直接翻几倍。紧接着才是编辑器里那一片红,cookies() 拿不到值了,params.slug 变成了 undefined

这两件事都不是 bug,是 Next.js 15 有意为之的行为变更。这篇把 15 的核心改动逐条拆开,讲清楚改法、背后的原因,以及哪些可以先放着不动。读完你会有一份能直接排期的迁移清单,而不是只知道「要加 await」。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 升级前的环境要求和官方 codemod 能覆盖的范围
  • React 19 带来的 useActionStateuseFormStatus 增强和 Actions
  • 异步 Request API 的完整清单,以及 layout 里怎么用 use 保持同步
  • fetch 默认不缓存这一刀砍下去,你的项目该怎么补
  • 客户端路由缓存调整和 staleTimes 配置
  • @next/fontexperimental-edgeNextRequest.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/fontnext/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

useFormStateuseActionState 取代,前者还能用但已经标记废弃。新 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>
)
}

多出来的 datamethodaction 用在做通用提交按钮组件时很方便,比如根据 method 决定按钮文案,或者从 data 里读出正在提交的条目 ID 做局部高亮。

提示:如果还没升到 React 19,useFormStatus 依然只有 pending 一个属性。

React 19 本身还有 useref 作为 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 组件里的 paramssearchParams 也变成了 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:

import { use } from 'react'

type Params = Promise<{ slug: string }>

export default function Layout(props: {
children: React.ReactNode
params: Params
}) {
const params = use(props.params)
const { slug } = params

return <div>{slug}</div>
}

Route Handler 里的 params

// Next.js 14
type Params = { slug: string }
export async function GET(request: Request, segmentData: { params: Params }) {
const params = segmentData.params
}

// Next.js 15
type Params = Promise<{ slug: string }>
export async function GET(request: Request, segmentData: { params: Params }) {
const params = await segmentData.params
}

Route Handler 这块最容易被忘掉,因为它们通常没有页面那么频繁地被打开,问题往往在灰度期间才暴露。升级时直接全局搜 params 过一遍最稳。

四、fetch 默认不再缓存

开头说的监控曲线异常就出在这里。

Next.js 14 及以前,fetch 默认相当于 cache: 'force-cache',写代码时不声明任何东西,框架也会帮你缓存。15 把这个默认值改成了不缓存:

export default async function RootLayout() {
// 不缓存,这是 15 的新默认行为
const a = await fetch('https://example.com/api/data')

// 想要缓存必须显式声明
const b = await fetch('https://example.com/api/data', {
cache: 'force-cache'
})
}

我一开始也觉得这是个倒退,缓存明明是好事,为什么要关掉。后来遇到过一次数据「明明改了但页面死活不更新」的排查,才理解官方的取舍。默认缓存最大的问题是它是隐式的,新人写了一个取实时数据的 fetch,框架悄悄给缓存住了,这类 bug 表现为「偶发的脏数据」,比多打几次接口难查得多。改成默认不缓存之后,性能问题会立刻反映在监控上,而正确性问题被消灭了。

用错误的性能换正确性,通常是划算的。

整段批量开缓存

如果某个 layout 或 page 下的请求确实都该缓存,不用一个个加参数:

// 在根布局里设置默认缓存策略
export const fetchCache = 'default-cache'

export default async function RootLayout() {
// 走缓存
const a = await fetch('https://example.com/api/data')

// 单独声明不缓存
const b = await fetch('https://example.com/api/data', {
cache: 'no-store'
})
}

Route Handler 的 GET 同样受影响

// 需要静态化就显式声明
export const dynamic = 'force-static'

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

顺便提一句,这条和 App Router 里另一个经典坑正好相反。以前是 GET handler 被意外静态化导致数据不更新,现在是默认动态导致缓存失效,两个方向的问题我在 App Router 避坑指南 里都整理过。

升级时的实操建议是这样,先别急着到处加 force-cache。跑一遍压测或者看两天监控,把真正高频且能容忍延迟的接口挑出来,只给这些加缓存并配上合适的 revalidate 时间。无脑全开等于把 15 的改动又退回去了,那些「其实需要实时」的接口会再次埋雷。

怎么找出哪些 fetch 受影响?最笨也最有效的办法是全局搜一遍裸调用,也就是第二个参数为空的那些。这类调用在 14 里全部走缓存,升级后全部变成实时请求,命中率的落差最大。搜出来之后按数据性质分三类处理,配置类和字典类直接加 cache: 'force-cache';列表和详情这种会变但不要求秒级的,配 next: { revalidate: 60 } 之类的时间窗;账户余额、库存、消息未读数这些必须实时的,保持默认就对了。

还有一类容易被忽略的是构建期取数。generateStaticParams 里的请求、以及被静态渲染的页面在构建时发的请求,这些不受运行时缓存策略影响,但它们的数量会随着页面数线性增长。如果你的站点有几千个静态页,升级后构建时长有没有变化值得单独量一下。

五、客户端路由缓存策略调整

<Link>useRouter 做页面跳转时,页面组件不再从客户端路由缓存里复用,每次导航都会重新取数据。浏览器的前进后退,以及跳转前后共享的那部分 layout,仍然走缓存。

这个改动的动机和上一节一致,都是在压缩「用户看到旧数据」的窗口。代价是频繁来回切换的页面会多发请求。

需要调整就用 staleTimes

/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
staleTimes: {
dynamic: 30, // 动态路由 30 秒内复用缓存
static: 180, // 静态路由 180 秒内复用缓存
},
},
}

module.exports = nextConfig

配上 dynamic: 30 相当于把 14 的行为找回来一部分。什么时候该配?后台管理系统这类「列表页和详情页之间来回横跳」的场景很值得配,用户一分钟内切二十次,每次都重新拉列表既浪费也卡手。而对内容型站点,保持默认更合适。

注意:layout 和 loading 状态在导航时依然会被复用,这部分行为没变。

六、其他 API 变更

这一节都是小改动,但漏掉任何一条都会让构建或运行时报错。

@next/font 包被移除

// 旧写法
import { Inter } from '@next/font/google'

// 新写法
import { Inter } from 'next/font/google'

字体功能早就内置进 next/font 了,独立包只是历史遗留,codemod 会自动处理。

experimental-edge 废弃

// 旧写法,现在会报错
export const runtime = 'experimental-edge'

// 新写法
export const runtime = 'edge'

两个实验配置转正

// bundlePagesExternals → bundlePagesRouterDependencies
const nextConfig = {
// 旧写法
experimental: {
bundlePagesExternals: true,
},
// 新写法
bundlePagesRouterDependencies: true,
}

// serverComponentsExternalPackages → serverExternalPackages
const nextConfig = {
// 旧写法
experimental: {
serverComponentsExternalPackages: ['package-name'],
},
// 新写法
serverExternalPackages: ['package-name'],
}

serverExternalPackages 这个平时用得不多,但只要你依赖了带原生模块的库,比如某些数据库驱动或者图像处理库,它就是刚需,写错名字的表现是构建时报一堆奇怪的模块解析错误。

NextRequest 上的 geo 和 ip 没了

NextRequestgeoip 属性被移除,理由是这些值本来就该由托管平台提供,框架不该假设自己跑在哪。用 Vercel 的话改成这样:

import { geolocation, ipAddress } from '@vercel/functions'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
const { city } = geolocation(request)
const ip = ipAddress(request)

// ...
}

自建部署的项目就得自己从请求头里取了,通常是 x-forwarded-for 或者网关约定的自定义头。这里有个坑要注意,x-forwarded-for 是可以被伪造的,如果这个 IP 要用于风控或限流,必须在网关层做过滤,只信任最靠近你的那一跳。

Speed Insights 自动埋点移除

Next.js 15 拿掉了 Speed Insights 的自动埋点,继续用需要按 Vercel 的快速入门 手动接。

七、迁移节奏建议

异步 API 的改动面积太大,一次性梭哈很容易做成一个几百文件的巨型 PR,review 不动也不敢合。我的建议是拆开走。

Step 1 先跑 codemod。 让工具把机械替换做完,然后仔细看 diff,重点检查那些被工具改过但你不认识的文件。

Step 2 分模块提交。 按路由分组,一次改一个业务模块,每组单独跑一遍构建和主流程测试。中间态用 UnsafeUnwrappedCookies 顶着,让主干始终可发布。

Step 3 重点测中间件和 API 路由。 涉及 cookiesheaders 的地方最容易出事,而且这类问题在页面上看不出来,得靠接口测试或者灰度流量。

Step 4 最后处理缓存策略。fetch 缓存和 staleTimes 放到最后,因为这一步需要参考真实流量数据,不看监控拍脑袋配没有意义。

类型这块单独提醒一句:

npm install @types/react@latest @types/react-dom@latest

如果项目里用了 Edge Runtime,记得把 experimental-edge 全部改成 edge,这个在本地开发时不一定报错,部署时才炸。

总结

Next.js 15 的改动可以归成三条主线:

  • 异步化cookiesheadersdraftModeparamssearchParams 全部变成 Promise,为并发渲染让路。层级浅的组件用 asyncawait,需要保持同步的 layout 用 React 19 的 use
  • 缓存默认值反转fetch 和 Route Handler 的 GET 默认不再缓存,客户端路由导航也不复用页面缓存。框架把「正确性」设成了默认,性能得你自己按需要加回来
  • API 清理@next/fontexperimental-edgeNextRequest.geo 这批老接口下线,两个实验配置转正

从升级成本看,异步化是工作量最大的一块,但它有 codemod 兜底;缓存策略调整工作量小,风险却最高,因为它不报错,只在监控曲线和账单上体现。

还有一点要提前想到,15 里那个 UnsafeUnwrappedCookies 过渡方案在 Next.js 16 里已经彻底移除了。所以升 15 的时候留下的技术债,下一次升级会连本带利收回去,具体改了哪些可以看 Next.js 16 升级指南。能一次改干净就别留尾巴。

参考