从 Pages Router 换到 App Router 的第一个月,最常见的现象是代码能跑、页面能出,但性能和预期完全对不上。服务端组件写着写着变成了一堆 'use client',明明用了 Suspense 首屏还是白等两秒,Server Action 改完数据页面纹丝不动。这些都不是 bug,是新架构下的心理惯性没转过来。
这篇把 App Router 里最容易踩的 10 个坑摆出来,每个都给出错误写法、为什么错、以及正确做法。全部基于 Next.js 官方文档的行为约定,读完你能拿走一份可以直接对着自己项目做 review 的清单。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 服务端组件到底该怎么取数,为什么不需要中间那层 API 路由
- Route Handler 静默静态化导致数据不更新的三种破解办法
Suspense放在哪一层才真的能流式上屏- Context Provider 怎么写才不会把整页拖成客户端渲染
'use client'的边界规则,哪些组件根本不用加- 服务端组件和客户端组件怎么组合,为什么 children 能穿透边界
- Server Action 之后的数据重新验证,
revalidatePath和revalidateTag怎么选 redirect为什么会被try/catch吃掉- Metadata API 的正确用法,以及 Next.js 15 之后
params的变化
# 一、在服务端组件里调自己的 API 路由
这是从 Pages Router 迁过来的人第一个会犯的错。以前的思维定式是「数据要从接口拿」,于是在服务端组件里 fetch 自己项目的 /api/posts。
// 错误:在服务端组件中调用自己的 API 路由
export default async function Page() {
// 需要多写一个 API 路由,而且地址被硬编码了
const data = await fetch('http://localhost:3000/api/posts').then(res => res.json())
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
这段代码在开发环境跑得好好的,上线之后 localhost:3000 直接失效,于是又得引入一个 NEXT_PUBLIC_BASE_URL 环境变量来拼地址。绕了一大圈,问题是这一整圈本来就不该存在。
服务端组件本身就跑在服务器上,它有数据库连接、有环境变量、有内网访问权限。多调一次自己的 API 路由,等于让服务器给自己发了一个 HTTP 请求,白白付出一次完整的网络往返加一次序列化开销。
// 正确:直接在服务端组件中获取数据
export default async function Page() {
const data = await fetch('https://api.example.com/posts', {
// Next.js 15 起 fetch 默认不缓存,要缓存得显式声明
next: { revalidate: 3600 }
}).then(res => res.json())
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
这里有个细节要注意。Next.js 14 及以前 fetch 默认是缓存的,15 之后改成了默认不缓存,所以老教程里那些「不用管,框架会帮你缓存」的说法已经过期,现在必须自己写 next: { revalidate } 或 cache: 'force-cache'。这块的完整变化我在 Next.js 15 新特性完全指南 里写过。
那什么时候还需要 API 路由呢?给外部系统调用的 webhook、给移动端复用的接口、需要单独做限流鉴权的端点,这些场景 Route Handler 依然有价值。它只是不该成为自己页面取数的必经之路。
# 二、Route Handler 被静态化,数据永远不变
Next.js 默认会对 Route Handler 做静态优化。开发模式下一切正常,一部署到生产环境就出事。
// app/api/time/route.js
export async function GET() {
console.log('API 被调用')
// 生产环境下会被静态缓存,时间永远停在构建那一刻
return Response.json({ time: new Date().toLocaleTimeString() })
}
部署完刷新一百次,返回的时间一模一样,日志里一条 API 被调用 都不打。第一直觉会怀疑是 CDN 缓存,去清了半天缓存也没用。
真正的原因是这个 handler 在构建期就被执行过一次,结果被固化成了静态文件。Next.js 判断的依据很简单,它看不出这个函数有任何依赖运行时信息的地方,那就默认它是个常量。
破解办法有三种,本质都是告诉框架「这个函数必须每次请求都跑」:
// 方法一:读取只有请求时才存在的信息
export async function GET(request) {
const token = request.cookies.get('token')
return Response.json({ time: new Date().toLocaleTimeString() })
}
// 方法二:同一文件里存在非 GET 方法
export async function GET() {
return Response.json({ time: new Date().toLocaleTimeString() })
}
export async function POST() {
return Response.json({ time: new Date().toLocaleTimeString() })
}
// 方法三:显式声明路由段配置
export const dynamic = 'force-dynamic'
export async function GET() {
return Response.json({ time: new Date().toLocaleTimeString() })
}
前两种是「被动触发」,靠 cookies、headers、非 GET 方法这些运行时才能确定的东西让框架自动切换到动态模式。第三种是「主动声明」。
我的建议是能显式就显式。
export const dynamic = 'force-dynamic' 只有一行,读代码的人一眼就知道这个路由是动态的。靠 request.cookies.get('token') 这种副作用来触发,几个月后有人做重构把这行没用到的代码删了,问题又会原地复活,而且这次更难查。
# 三、客户端组件也不必绕道 API 路由
上一条的反面版本。有人以为客户端组件里只能请求自己的 /api/*,于是又造了一层转发。
'use client'
import { useState } from 'react'
export default function PostsPage() {
const [posts, setPosts] = useState([])
return (
<>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={async () => {
// 绕道自己创建的 API 路由
const res = await fetch('/api/posts')
const data = await res.json()
setPosts(data)
}}>
获取文章
</button>
</>
)
}
客户端组件运行在浏览器里,浏览器能请求任何允许跨域的地址,中间这层转发纯属多余:
'use client'
import { useState } from 'react'
export default function PostsPage() {
const [posts, setPosts] = useState([])
return (
<>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={async () => {
// 直接调外部 API
const res = await fetch('https://api.example.com/posts')
const data = await res.json()
setPosts(data)
}}>
获取文章
</button>
</>
)
}
不过这条有明确的例外,得说清楚,不然容易被带偏。需要藏 API key 的、目标接口没开 CORS 的、要在服务端做数据裁剪减少传输量的,这三种情况该加转发层就得加。判断标准不是「客户端能不能直连」,而是「直连会不会泄露东西或者根本连不上」。
# 四、Suspense 放错位置,流式渲染白写
Suspense 是 App Router 流式渲染的核心,也是最容易写成摆设的一个。
import { Suspense } from 'react'
async function fetchPosts() {
// 假设这里有 2 秒延迟
}
// 错误:页面组件自己 await 了,Suspense 拦不住
export default async function Page() {
const data = await fetchPosts()
return (
<div>
<h1>文章列表</h1>
<Suspense fallback={<div>加载中...</div>}>
<PostList data={data} />
</Suspense>
</div>
)
}
代码里有 Suspense,看起来该有的都有了,但用户体验和没写一样,整整两秒白屏,连 文章列表 这个标题都出不来。
那为什么加了 Suspense 还是白屏呢?因为 Suspense 能流式补上的只有它包裹的那棵子树。页面组件自己在最外层 await,整个返回值都还没产生,React 连 Suspense 的 fallback 都拿不到,自然什么都渲染不出来。
正确做法是把取数下沉到子组件里,让页面组件保持同步:
import { Suspense } from 'react'
async function Posts() {
const data = await fetchPosts()
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// 正确:页面组件不 await,异步子组件被 Suspense 包住
export default function Page() {
return (
<div>
<h1>文章列表</h1>
<Suspense fallback={<div>加载中...</div>}>
<Posts />
</Suspense>
</div>
)
}