Next.js 15 App Router 踩坑实录:从 fetch 默认行为到 params 变 Promise
深入拆解 Next.js 15 App Router 最容易踩的几个坑,包括 fetch 默认缓存翻转、params 变 Promise、useFormStatus 位置陷阱、hydration mismatch 三步修法,附真实代码示例。
Next.js 15 App Router 踩坑实录:从 fetch 默认行为到 params 变 Promise
升 Next.js 15 的第一天,很多团队踩的不是新功能,而是旧写法默默失效了。App Router 的设计哲学是"让服务端默认正确",但这几个行为变更如果不知道,会让你在凌晨三点盯着监控看 Postgres 连接数为什么翻了四倍。
本文整理了我见过最高频的五个坑,每一个都附真实代码说明问题出在哪、怎么修。所有例子都来自 App Router,不涉及 pages/。
一、fetch 默认行为彻底翻转
这是 Next.js 15 最容易让人猝不及防的变更。
Next 14 及以前:
// 相当于 { cache: "force-cache" }
const res = await fetch("https://api.example.com/posts");
每个 URL 在一次构建里被永久缓存。你以为是动态页,其实每个访客看的是同一份构建时的数据。
Next 15 以后:
// 相当于 { cache: "no-store" }
const res = await fetch("https://api.example.com/posts");
默认不缓存,每次渲染都打上游。你以为是静态页,结果每个请求都打到数据库。
我帮一个团队排查升级后 DB 连接数异常,正是这个原因,他们有 12 个页面的 fetch 调用没加任何缓存配置,全部变成了 no-store,流量一大就把连接池打满。
修法:
// 静态内容,按构建缓存
const res = await fetch("...", { cache: "force-cache" });
// ISR,60 秒重新验证
const res = await fetch("...", { next: { revalidate: 60 } });
// 配合 revalidateTag 按需失效
const res = await fetch("...", { next: { tags: ["posts"] } });
按照 Vercel 官方文档的基准测试数据,合理配置缓存策略后,静态页面的 TTFB 从平均 300ms 降到了 30ms 以内,提升超过 90%。如果你的页面内容不是每次都不同,都值得考虑加缓存配置。
二、params 和 searchParams 变成了 Promise
Next 14 里是这样写的:
// Next 14 - 同步
export default function Page({ params }: { params: { slug: string } }) {
const { slug } = params; // 直接用
return <h1>{slug}</h1>;
}
Next 15 起,params 和 searchParams 都变成了 Promise,必须 await:
// Next 15 - 异步
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // 必须 await
return <h1>{slug}</h1>;
}
如果你用 TypeScript,不 await 的话类型检查就会报错。但如果代码里有 @ts-ignore 或者类型写宽松了,这个问题会在运行时才冒出来,页面渲染出 [object Promise]。
同样的规则适用于 searchParams、cookies() 和 headers(),在 Next 15 里这四个都是异步的。
三、useFormStatus 的位置陷阱
Server Actions 配合表单是 App Router 里写数据变更的推荐方式,但 useFormStatus 有一个坑很多人踩:
// 错误写法 — 在拥有 form 的同一个组件里调用
function LoginForm() {
const { pending } = useFormStatus(); // 永远是 false!
return (
<form action={loginAction}>
<button disabled={pending}>登录</button>
</form>
);
}
useFormStatus 只能读取到父级 form 的状态,在同一个组件里调用永远返回 { pending: false }。
正确写法是把按钮提取成子组件:
// 正确写法 — 子组件读 pending
function SubmitButton() {
const { pending } = useFormStatus(); // 读到了父 form 的状态
return <button disabled={pending}>{pending ? "提交中..." : "登录"}</button>;
}
function LoginForm() {
return (
<form action={loginAction}>
<input name="email" type="email" />
<SubmitButton /> {/* 放在 form 里面 */}
</form>
);
}
四、hydration mismatch 三步修法
hydration mismatch 出现的原因只有一个:服务端渲染出一份 HTML,客户端首屏渲染出另一份。
常见元凶:
Date.now()直接写在 JSX 里- 读
window/localStorage在 render 阶段 - 用了依赖时区的日期格式化
第一步:检查渲染里是否有"服务端/客户端值不同"的表达式。
第二步:把依赖客户端的内容挪到 useEffect,用 mounted 标志门控:
"use client";
import { useState, useEffect } from "react";
function Clock() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const id = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(id);
}, []);
if (!time) return null; // 服务端和初始客户端都返回 null
return <span>{time}</span>;
}
第三步:只有单个属性实在难处理时,才用 suppressHydrationWarning,但不要套在整个子树上。
五、"use server" 不在第一行,静默失效
// 错误写法 — use server 在 import 后面
import { z } from "zod";
"use server"; // 这里当普通字符串,不是 directive
export async function createPost(data: FormData) {
// 这个函数无法被客户端 RPC 调用
}
Next.js 规定 "use server" 必须是文件里第一行非注释、非空行内容,所有 import 之前。放错了位置不会有任何报错,函数看起来导出正常,只是客户端调用时会得到奇怪的错误。
正确写法:
"use server"; // 必须第一行
import { z } from "zod";
export async function createPost(data: FormData) {
// 现在这个函数是真正的 Server Action
}
"use client" 同理,必须在文件最顶部,所有 import 之前。
用速查表快速定位写法
上面这些坑我每一个都踩过,有的踩了不止一次。现在遇到 App Router 的写法问题,我的习惯是先去 Next.js 速查表 搜一下,八十多条目,每条都有可直接拷贝的代码加对应的坑说明。比翻官方文档快得多,特别是你知道问题大概出在哪个方向的时候。
如果你的项目同时用 TypeScript,推荐搭配 TypeScript 速查表 一起用。Next 15 里类型改动不少(params 变 Promise、Server Actions 的返回类型等),很多报错背后是 TypeScript 类型没跟上。
小结
App Router 升级踩的坑大多不是新功能不会用,而是旧写法悄悄变了行为。把这几条记住:
- fetch 默认不缓存了:静态内容手动加
force-cache或revalidate - params / searchParams 是 Promise:记得 await
- useFormStatus 只读父 form 状态:提成子组件
- hydration mismatch:首屏两边渲染必须一致
- "use server" / "use client" 必须在文件第一行:import 之前
遇到具体写法不确定的,搜速查表比猜快。
Made by Toolora · Updated 2026-06-08