上个月把公司一个老项目从 Pages Router 迁到 App Router,本来觉得最多两天搞定,结果整整折腾了一周。中间遇到的坑,有的是文档没写清楚,有的是我自己想当然,有的纯粹是 Next.js 的行为跟直觉不一样。趁记忆还新鲜,全部记下来。
先说结论
| 坑 | 严重程度 | 解决耗时 | 一句话总结 |
|---|---|---|---|
| Server/Client Component 边界搞混 | ⭐⭐⭐⭐⭐ | 2天 | 默认是 Server Component,useState 直接炸 |
| layout.tsx 不会重新渲染 | ⭐⭐⭐⭐ | 半天 | 切路由时 layout 状态不重置 |
| metadata 导出和 'use client' 冲突 | ⭐⭐⭐ | 2小时 | Client Component 不能导出 metadata |
| fetch 默认缓存策略 | ⭐⭐⭐⭐ | 1天 | 数据死活不更新,原来是被缓存了 |
| 动态路由 generateStaticParams 的坑 | ⭐⭐⭐ | 半天 | build 时报错,运行时又正常 |
背景:为什么要迁移
项目是一个内部运营后台,之前用 Next.js 13 Pages Router 写的,功能不复杂,大概三十多个页面。迁移的直接原因是要加几个新功能,同事说「反正要改,不如一步到位上 App Router」。
说实话我一开始是拒绝的。Pages Router 用得好好的,干嘛折腾?但 Server Component 确实有吸引力------直接在组件里查数据库,不用写 API 路由了。行吧,干。
坑一:Server Component 和 Client Component 的边界
这是最大的坑。
App Router 下所有组件默认是 Server Component ,不能用 useState、useEffect、onClick 这些东西。要用就得在文件顶部加 'use client'。
道理我都懂,实际写起来完全是另一回事。
第一个炸的地方
迁移一个列表页,原来的代码大概长这样:
tsx
// app/dashboard/users/page.tsx
import { useState } from 'react'
export default function UsersPage() {
const [search, setSearch] = useState('')
const [users, setUsers] = useState([])
// ... 省略 fetch 逻辑
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<UserList users={users} />
</div>
)
}
直接报错:
vbnet
You're importing a component that needs useState. It only works in a Client Component
but none of its parents are marked with "use client"
好,加 'use client'。加完之后这个页面就完全变成客户端渲染了,Server Component 的好处全没了。
正确的拆法
折腾了一天才想明白,关键是把交互逻辑拆到子组件里,页面本身保持 Server Component:
tsx
// app/dashboard/users/page.tsx(Server Component,不加 'use client')
import { prisma } from '@/lib/prisma'
import { UserSearch } from './user-search'
export default async function UsersPage() {
// 直接在组件里查数据库,这就是 Server Component 的好处
const users = await prisma.user.findMany({
take: 50,
orderBy: { createdAt: 'desc' }
})
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">用户管理</h1>
{/* 把需要交互的部分拆成 Client Component */}
<UserSearch initialUsers={users} />
</div>
)
}
tsx
// app/dashboard/users/user-search.tsx(Client Component)
'use client'
import { useState, useMemo } from 'react'
import type { User } from '@prisma/client'
interface Props {
initialUsers: User[]
}
export function UserSearch({ initialUsers }: Props) {
const [search, setSearch] = useState('')
const filtered = useMemo(() => {
if (!search.trim()) return initialUsers
return initialUsers.filter(u =>
u.name?.toLowerCase().includes(search.toLowerCase()) ||
u.email?.toLowerCase().includes(search.toLowerCase())
)
}, [search, initialUsers])
return (
<div>
<input
className="border rounded px-3 py-2 mb-4 w-full max-w-md"
placeholder="搜索用户名或邮箱..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<table className="w-full">
<thead>
<tr>
<th className="text-left p-2">ID</th>
<th className="text-left p-2">姓名</th>
<th className="text-left p-2">邮箱</th>
</tr>
</thead>
<tbody>
{filtered.map(user => (
<tr key={user.id} className="border-t">
<td className="p-2">{user.id}</td>
<td className="p-2">{user.name}</td>
<td className="p-2">{user.email}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
页面首屏服务端渲染带数据,搜索交互在客户端完成。
经验法则:能不加 'use client' 就不加,需要交互的部分拆成最小的子组件。
坑二:layout.tsx 切路由不重新渲染
这个坑隐蔽得多。
我在 layout 里放了个侧边栏,侧边栏上有「当前模块」的高亮状态,用 useState 管理。结果发现点击不同菜单,URL 变了,页面内容也变了,但侧边栏高亮不对。
原因:App Router 的 layout 在同级路由切换时不会卸载重建,状态会保留。 这是设计如此,不是 bug。文档里写了,但很容易略过。
解决方案是别用 useState 管这个状态,改用 usePathname() 直接读当前路径:
tsx
// components/sidebar.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const menuItems = [
{ href: '/dashboard', label: '概览' },
{ href: '/dashboard/users', label: '用户管理' },
{ href: '/dashboard/orders', label: '订单管理' },
{ href: '/dashboard/settings', label: '系统设置' },
]
export function Sidebar() {
const pathname = usePathname()
return (
<nav className="w-60 bg-gray-50 min-h-screen p-4">
{menuItems.map(item => {
// 用 pathname 判断高亮,不依赖任何 state
const isActive = pathname === item.href ||
(item.href !== '/dashboard' && pathname.startsWith(item.href))
return (
<Link
key={item.href}
href={item.href}
className={`block px-3 py-2 rounded mb-1 ${
isActive
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-200'
}`}
>
{item.label}
</Link>
)
})}
</nav>
)
}
记住:layout 里的状态跨路由持久化,需要随路由变化的东西用 usePathname 或 useSearchParams 驱动,别用 useState。
坑三:metadata 和 'use client' 不能共存
给每个页面设置 title 和 description,Next.js 14 的方式是导出 metadata 对象或 generateMetadata 函数:
tsx
// 这样写没问题
export const metadata = {
title: '用户管理 - 后台',
description: '管理系统用户'
}
export default async function UsersPage() {
// ...
}
但如果这个文件加了 'use client',metadata 导出直接被忽略------不报错,不生效,你根本不知道它没工作。
这也是坑一重要的另一个原因:页面级组件保持 Server Component,metadata 才能正常导出。 需要交互的往下拆。
如果整个页面确实必须是 Client Component(比如复杂表单页),把 metadata 放到同目录的 layout.tsx 里,或者用父级 layout 的 generateMetadata 根据路径动态生成。
坑四:fetch 默认缓存,数据死活不更新
这个坑让我怀疑了整整一天。
在 Server Component 里 fetch 了一个内部 API 拿配置数据,第一次加载正常。然后我去数据库改了数据,刷新页面------没变。清缓存刷新------还是没变。重启 dev server------变了。
原因是 Next.js 14 的 fetch 在 Server Component 里默认开启缓存 (相当于 cache: 'force-cache')。dev 模式下表现有时还不一致,更迷惑人。
tsx
// ❌ 默认被缓存,数据不会实时更新
const res = await fetch('https://api.example.com/config')
// ✅ 方案一:每次请求都重新获取
const res = await fetch('https://api.example.com/config', {
cache: 'no-store'
})
// ✅ 方案二:设置过期时间(ISR 的效果)
const res = await fetch('https://api.example.com/config', {
next: { revalidate: 60 } // 60 秒后过期
})
// ✅ 方案三:页面级别设置(影响整个页面的所有 fetch)
export const dynamic = 'force-dynamic' // 等价于每个 fetch 都 no-store
// 或
export const revalidate = 60 // 页面级 ISR
后台系统这种数据实时性要求高的,建议直接在 layout 或 page 里设 export const dynamic = 'force-dynamic',省得一个个 fetch 去配。面向用户的前台再按需用 revalidate 做 ISR。
另外,如果用的是 Prisma 直接查数据库(不走 fetch),上面这些缓存策略不生效。Prisma 查询不经过 Next.js 的 fetch 缓存层,要控制缓存得用 unstable_cache 或者 React 的 cache 函数,又是另一个话题了。
坑五:generateStaticParams 的玄学行为
动态路由 [id] 配合 generateStaticParams 做静态生成,build 的时候遇到了诡异问题。
tsx
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await prisma.post.findMany({
select: { slug: true }
})
return posts.map(post => ({ slug: post.slug }))
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug }
})
if (!post) notFound()
return <article>{post.content}</article>
}
build 报错说数据库连不上,但 next dev 跑得好好的。
排查了半天,是 build 环境的 .env 没加载到正确的数据库连接串。这不是 Next.js 的锅,但 App Router 在 build 时会真正执行 generateStaticParams 去预渲染页面,踩过 Pages Router 的 getStaticPaths 就不陌生。
还有个更隐蔽的问题:Next.js 14 中 params 在某些情况下是个 Promise。 升级到较新版本可能需要这样写:
tsx
// 新版本需要 await params
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
// ...
}
这个变更文档里提了一句。如果从 13 直接升上来,大概率会被坑:TypeScript 会报类型错误,但没开严格模式的话,运行时可能直接拿到一个 Promise 对象当 string 用,查不到数据,返回 404,你还纳闷数据明明在数据库里。
额外收获:几个迁移小技巧
1. 渐进式迁移
App Router 和 Pages Router 可以共存。/app 下的路由优先级高于 /pages,所以可以一个页面一个页面地迁,不用一把梭。
2. loading.tsx 白送 Suspense
路由目录下放一个 loading.tsx,Next.js 自动帮你包 <Suspense>。页面里的异步数据加载期间会显示 loading 内容,不用手动写 Suspense 边界:
tsx
// app/dashboard/users/loading.tsx
export default function Loading() {
return (
<div className="p-6 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48 mb-4" />
<div className="h-64 bg-gray-200 rounded" />
</div>
)
}
3. error.tsx 也是自动的
同理,放一个 error.tsx 自动充当 Error Boundary,Server Component 和 Client Component 的错误都能兜住。记得加 'use client',Error Boundary 必须是客户端组件。
小结
迁完回头看,App Router 的心智模型确实比 Pages Router 复杂,但收益是实打实的------服务端组件直接查库省掉 API 层、自动 Streaming SSR、嵌套 Layout。新项目我会直接用 App Router,老项目就看情况,别像我一样低估迁移成本。
核心就一条:想清楚每个组件是 Server 还是 Client,画好边界线,其他问题都是小问题。
迁移清单放这了,有同样计划的可以参考着来。