Next.js Pages Router 迁移 App Router 实战指南:渐进式策略与避坑清单
上周五下午三点,技术总监在会议室抛出一个问题:“咱们这个Next.js 12的项目,能升级到14吗?”
我盯着屏幕上那个运行了两年的老项目,心里一紧。说实话,我第一反应是——千万别。倒不是不想升级,而是上次升级React 17的时候,我们整整花了一周修bug,客服电话都被打爆了。
不过这次好像有点不一样。
晚上回家我开始翻官方文档,看到App Router那些新特性:Server Components、嵌套布局、更好的性能…心里又痒痒了。但翻到迁移指南那一页,我又犯愁了——满屏都是API对照表,getServerSideProps要改成什么、_app.js要拆成什么…头大。
更麻烦的是,官方推荐的”渐进式迁移”听起来很美好,但真正试了才发现:在/pages和/app之间切换页面时,用户会看到loading转圈,体验反而变差了。
我花了整整两周时间踩坑、翻社区讨论、尝试不同方案,最后总结出一套相对靠谱的迁移策略。本文会分享这些实战经验,包括:
- 如何判断你的项目是否值得迁移
- 两种迁移策略的真实利弊(不是官方文档上的理论)
- getServerSideProps迁移的详细步骤和代码示例
- 7个我亲自踩过的大坑和解决方法
如果你也在纠结要不要升级,或者已经开始迁移但遇到了问题,希望这篇文章能帮你少走些弯路。
为什么要迁移?先算清这笔账
说到迁移,我得先泼个冷水:不是所有项目都值得折腾。
上个月有个朋友问我,他们公司那个马上要下线的活动页要不要升级到App Router。我直接劝退了——明明半年后就要删掉的代码,何必浪费时间?
那什么样的项目值得迁移呢?我总结了几个判断标准:
嵌套布局需求
这个是我们团队迁移的主要原因。我们的SaaS后台有个侧边栏+顶部导航+内容区的三层布局,用Pages Router的时候,每次切换页面,侧边栏都会整个重新渲染一遍。
用户点开新页面,能明显感觉到整个界面闪了一下。不是网络慢,就是布局重绘。
App Router的嵌套布局完美解决了这个问题。迁移后,WorkOS团队报告说”登录体验显著改善,没有加载状态或布局抖动” —— 我们自己测下来也是这样,用户切换页面时,只有内容区更新,导航栏稳如泰山。
性能优化空间
如果你的项目首屏加载时间超过3秒,那App Router可能帮得上忙。
我们有个商品列表页,以前用getServerSideProps拉数据,每次刷新都要等服务器渲染完整个HTML。改成Server Components后,可以把列表数据在服务端拉好,直接stream给客户端,首屏时间从3.2秒降到1.8秒。
不过这里有个坑:不是所有页面都能提速。纯客户端交互的页面(比如画布编辑器),迁移后基本没区别,甚至可能因为多了一层抽象变慢。
长期维护的项目
如果这个项目要维护三年以上,那早迁移早享受。Vercel已经明确表示,未来的新特性都会优先支持App Router,Pages Router进入”维护模式”了。
我不想两年后再来迁移一次,到时候API可能又变了,踩的坑只会更多。
不建议迁移的场景
但这几种情况,我建议别急着迁移:
- 项目马上要下线的 —— 没必要
- 小型静态站点(5个页面以内) —— 收益太小,不值当
- 团队对React 18不熟 —— 先搞懂Suspense和Server Components再说
- 依赖大量老旧第三方库 —— 你可能会发现一堆库不兼容
说了这么多,核心就一句话:别为了迁移而迁移。先问自己,迁移能解决什么实际问题?如果答案是”没有,就是想尝鲜”,那还是算了吧。
我们团队算过一笔账:投入两周人力,换来用户体验提升和未来三年的技术债减少,值。你的项目呢?
两种迁移策略的选择
官方文档推荐”渐进式迁移”,听起来很稳妥——慢慢来,一个页面一个页面迁。
但我试了之后发现,这玩意儿有个致命问题。
渐进式迁移的坑
想象一下这个场景:你把首页迁到了/app目录,但商品详情页还在/pages里。用户从首页点进商品页,页面突然白屏转圈…转完才显示内容。
为啥?页面从App Router跳到Pages Router,Next.js会当成两个独立的应用,整个JavaScript bundle要重新加载一遍。用户体验瞬间回到2010年。
WorkOS团队在博客里也吐槽过这个:“在不同路由器之间导航,就像在两个不相关的应用之间跳转”。他们本来也想渐进式迁移,后来放弃了。
那渐进式迁移就完全不可行吗?也不是。
适合渐进式的场景:
- 页面之间耦合度很低(比如博客,文章之间没啥关联)
- 可以按模块完整迁移(比如先把整个用户中心模块迁了,再迁商品模块)
- 能接受切换时的loading状态
我见过一个技术博客这么做,效果还行。但SaaS产品或电商网站,别想了。
WorkOS的零停机方案
那复杂项目怎么办?WorkOS给了个巧妙的方案。
他们的做法是:在/app下建了个临时目录/app/new,把所有页面都在这里重写一遍,然后用查询参数控制访问哪个版本。
听起来有点绕,看代码就清楚了:
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/:path*',
destination: '/new/:path*',
has: [
{
type: 'query',
key: 'new',
value: 'true',
},
],
},
]
},
}
这样一来,普通用户访问/dashboard还是用老版本,但加上?new=true就能看到新版本。
测试、产品、设计师可以提前在生产环境验证新版本,用户完全无感知。等新版本测完没问题了,把/app/new整个改成/app,把/pages删掉,搞定。
我们团队用的就是这个方案。整个迁移过程中,用户一个bug都没遇到,因为正式上线之前我们已经用真实数据测了一个礼拜。
具体步骤:
- 升级Next.js到14 —— 先不动/pages,只升级框架版本
- 迁移路由钩子 —— 把
next/router改成next/navigation,保证代码兼容两个路由器 - 创建/app/new目录 —— 在这里重建页面结构
- 复用现有组件 —— 把/pages里的React组件直接import进来,不用重写
- 配置rewrites —— 加上面那段配置,用
?new=true切换 - 内部测试+灰度 —— 让团队用新版本,发现问题及时修
- 正式上线 —— 把/app/new移到/app,删掉rewrites和/pages
我们从步骤1到步骤7,总共花了10个工作日。其中6天在重写页面,3天在修bug,最后1天上线。
我的建议
如果你的项目:
- 少于10个页面,页面间独立 → 渐进式迁移
- 超过10个页面,用户体验要求高 → 零停机方案
- 新项目 → 直接用App Router,别折腾
别想着边开发新功能边迁移,我试过,最后两边的代码风格完全不一样,看着难受。要么集中两周干完,要么就先别动。
getServerSideProps迁移实战
这个是最多人问我的问题:“getServerSideProps不能用了,数据怎么拿?”
其实App Router的数据获取更简单,就是得换个思路。
从”分离”到”合并”
Pages Router的逻辑是这样的:数据获取(getServerSideProps)和UI(组件)分开写,Next.js帮你在服务端调用数据函数,把结果传给组件。
App Router不玩这套了。页面组件本身就是async函数,直接在里面拉数据:
// ❌ 老写法: pages/project/[id].tsx
export async function getServerSideProps(context) {
const { id } = context.params
const res = await fetch(`https://api.example.com/projects/${id}`)
const project = await res.json()
return {
props: { project }
}
}
export default function ProjectPage({ project }) {
return <h1>{project.title}</h1>
}
// ✅ 新写法: app/project/[id]/page.tsx
export default async function ProjectPage({ params }) {
const { id } = params
const res = await fetch(`https://api.example.com/projects/${id}`, {
cache: 'no-store' // 关键!等同于getServerSideProps的行为
})
const project = await res.json()
return <h1>{project.title}</h1>
}
看起来简单多了,对吧?但别急,这里有两个大坑。
坑1:cache配置搞错了
默认情况下,App Router的fetch是有缓存的(等同于getStaticProps),不是每次请求都拉新数据。
我刚开始迁移时没注意这个,把商品价格页面迁过去,结果价格一直不更新。用户投诉说”明明降价了,页面还显示原价”,我排查了半天才发现是缓存的锅。
记住这个对照表:
getServerSideProps→cache: 'no-store'getStaticProps→cache: 'force-cache'(默认行为)getStaticProps + revalidate→next: { revalidate: 60 }
坑2:客户端状态怎么办
原来用getServerSideProps的页面,经常还有客户端交互,比如筛选、排序。
迁到App Router后,你会发现:async Server Component不能用useState、useEffect这些hook。
咋办?拆组件。
// app/products/page.tsx (Server Component)
export default async function ProductsPage() {
const products = await fetchProducts() // 服务端拉数据
return <ProductList initialData={products} /> // 传给Client Component
}
// components/ProductList.tsx (Client Component)
'use client' // 注意这行!
import { useState } from 'react'
export function ProductList({ initialData }) {
const [products, setProducts] = useState(initialData)
const [filter, setFilter] = useState('')
// 客户端的筛选逻辑
const filtered = products.filter(p => p.name.includes(filter))
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</div>
)
}
这样一来,服务端负责拉数据,客户端负责交互,职责分明。
但注意:别滥用”use client”。我见过有人整个页面都标成”use client”,那就失去了Server Components的意义了。
实际迁移步骤
我总结了个两步走的流程:
第一步:拆分组件
先在原来的pages目录里,把组件拆成”纯展示”和”有状态”两部分,测试通过。
第二步:移动到app目录
- 把纯展示部分放到 app/[route]/page.tsx,标记为async,在里面拉数据
- 把有状态的部分提取到单独文件,加上”use client”
- 删掉getServerSideProps代码
这样做的好处是:万一出问题,你能快速回滚,不会两边代码都乱套。
还有个小细节
原来用context.req.cookies读取用户身份的,现在改成:
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = cookies()
const token = cookieStore.get('auth-token')
// 拿到token去请求用户数据...
}
类似的还有headers()、redirect()这些,都从next/headers或next/navigation导入。官方文档有完整列表,我就不抄了。
7个常见大坑与解决方案
好了,重头戏来了。下面这7个坑,我全踩过,每个都让我debug了至少一小时。
坑1:服务端错误被吞掉了
现象:页面不渲染,也不报错,就显示个loading骨架屏或者空白。
我的经历:有次改了个API调用,结果页面一片空白。打开控制台,啥报错都没有。我以为是数据没返回,加了一堆console.log,还是没找到问题。
最后发现,服务端抛了个异常,但因为我没配置error.tsx,Next.js就把错误吞了,直接显示Suspense的fallback。
解决方法:在每个路由目录下加error.tsx:
// app/dashboard/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>出错了:{error.message}</h2>
<button onClick={reset}>重试</button>
</div>
)
}
加了这个,至少能看到错误信息了。开发环境Next.js会显示详细堆栈,生产环境显示友好的错误提示。
坑2:useRouter不work了
现象:useRouter().push()不跳转,或者报错说某个方法不存在。
原因:next/router和next/navigation是两套API,不兼容。
我刚开始以为只要改个import路径就行:
// ❌ 错误做法
import { useRouter } from 'next/navigation'
const router = useRouter()
router.push('/dashboard') // push方法不存在!
后来才知道,next/navigation的useRouter根本没有push方法,得用单独的函数:
// ✅ 正确做法
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
const router = useRouter()
router.push('/dashboard') // 这个其实有,但行为不一样
// 或者直接用Link组件
import Link from 'next/link'
<Link href="/dashboard">跳转</Link>
对照表(我贴在电脑旁边,迁移的时候一直看):
| Pages Router | App Router |
|---|---|
useRouter().push(url) | useRouter().push(url) (有,但不建议用) |
useRouter().pathname | usePathname() |
useRouter().query | useSearchParams() |
useRouter().asPath | usePathname() + useSearchParams() |
坑3:动态导入失效
现象:用next/dynamic导入的组件不渲染,控制台报 “You’re importing a component that needs useState. It only works in a Client Component…”
原因:Server Component默认在服务端渲染,某些client-only的库(比如图表库)会报错。
我之前有个图表页面,用的ECharts:
// ❌ 这样会报错
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
export default function Page() {
return <Chart data={data} />
}
报错说Chart组件需要window对象,但服务端没有。
解决方法:给page.tsx加”use client”,或者把Chart提取到单独的Client Component:
// app/charts/page.tsx
import { ClientChart } from './ClientChart'
export default function Page() {
return <ClientChart />
}
// app/charts/ClientChart.tsx
'use client'
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
export function ClientChart() {
return <Chart data={data} />
}
坑4:页面切换时闪烁
现象:点链接跳转,整个页面重新渲染,顶部导航栏、侧边栏都闪一下。
原因:layout.tsx配置不对,或者根本没用layout。
App Router的核心优势就是嵌套布局,结果我一开始没好好利用,把导航栏直接写在每个page.tsx里,当然会闪。
正确做法:
// app/layout.tsx (根布局,所有页面共享)
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header /> {/* 顶部导航,永远不重新渲染 */}
{children}
</body>
</html>
)
}
// app/dashboard/layout.tsx (仪表盘布局)
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<Sidebar /> {/* 侧边栏,切换仪表盘内的页面时不重新渲染 */}
<main>{children}</main>
</div>
)
}
这样一来,用户在/dashboard/analytics和/dashboard/settings之间切换,只有main区域更新,侧边栏纹丝不动。
坑5:404页面不生效
现象:自定义的404页面不显示,还是Next.js默认的那个。
原因:Pages Router的404.js和App Router的not-found.tsx冲突了。
我迁移的时候,/pages/404.js还在,结果App Router的/app/not-found.tsx不起作用。
解决方法:删掉/pages/404.js和/pages/500.js,用App Router的约定:
// app/not-found.tsx
export default function NotFound() {
return <h1>页面不存在</h1>
}
在page.tsx里手动触发404:
import { notFound } from 'next/navigation'
export default async function Page({ params }) {
const data = await fetchData(params.id)
if (!data) {
notFound() // 触发404
}
return <div>{data.title}</div>
}
坑6:开发服务器越来越慢
现象:刚启动的时候还好,改几次代码之后,热更新要等10秒,再后来直接崩溃。
坦白讲:这个我也没完美解决。
这是Next.js 14的已知问题,FlightControl团队在博客里吐槽过:“dev server性能烂到我愿意放弃所有新特性来避免它”。他们说每20分钟就要重启一次开发服务器。
我们团队的体验也差不多。
临时解决办法:
- 定期重启dev server(我设了个15分钟的提醒)
- 用
next dev --turbo启用实验性的Turbopack(快一些,但偶尔有bug) - 减少不必要的Server Component,有些页面其实用Client Component就够了
Next.js 15据说改进了这个问题,但我还没试过。
坑7:第三方库不兼容
现象:某些动画库(Framer Motion、Lottie)报错,说找不到window或document。
原因:这些库是纯客户端的,不能在Server Component里用。
我之前用Framer Motion做页面切换动画,迁移后全挂了。
解决方法:
- 用”use client”包裹使用这些库的组件
- 检查库有没有更新版本支持React 18(有些库已经适配了)
- 实在不行,换个支持SSR的库
特别注意这些常见的client-only库:
- Framer Motion(页面退出动画在App Router里有问题,官方issue里还在讨论)
- swiper、slick-carousel等轮播库
- 各种图表库(ECharts、Chart.js等)
- 拖拽库(react-dnd、dnd-kit等)
如果你的项目重度依赖这些库,迁移前先去GitHub看看issue,确认兼容性。
迁移后的优化建议
迁移完成不是终点,还有不少优化空间。
减少客户端JavaScript
这是Server Components的最大优势。
我们原来的商品列表页,光是React组件就有120KB(gzip后)。迁移后,把数据展示部分改成Server Component,只把筛选和排序做成Client Component,bundle size降到45KB。
检查方法:
npm run build
看输出里哪些页面标了(Static)或(SSR),哪些是○(表示用了Client Component)。如果满屏都是○,说明你可能用”use client”用多了。
优化技巧:
- 静态内容(文字、图片)放Server Component
- 交互组件(表单、按钮)用Client Component
- 别整个页面都标”use client”,只标需要的子组件
合理使用缓存
App Router的缓存策略比Pages Router复杂多了。
// 不缓存,每次请求都拉新数据(适合实时数据)
fetch(url, { cache: 'no-store' })
// 缓存60秒,之后重新验证(适合频繁更新但不需要实时的数据)
fetch(url, { next: { revalidate: 60 } })
// 永久缓存(适合不变的静态数据)
fetch(url, { cache: 'force-cache' })
我们的产品列表用了60秒revalidate,既保证数据不太旧,又减少了服务器压力。上线后API调用量减少了60%。
性能监控
迁移前后对比这几个指标:
- 首屏时间(FCP) - 用户看到第一块内容的时间
- 交互时间(TTI) - 页面完全可交互的时间
- 累积布局偏移(CLS) - 页面加载时是否抖动
我们用Vercel Analytics监控,发现迁移后FCP从3.2秒降到1.8秒,TTI从5.1秒降到3.3秒。
但注意,不是所有页面都会变快。纯客户端交互的页面(比如我们的画布编辑器),迁移后几乎没区别。
小心过度优化
别为了用Server Component而强行拆组件。
我之前把一个表单拆成20个小组件,想着”拆得越细,Server Component占比越高”。结果代码可读性变差,同事看不懂,维护成本反而增加了。
经验法则:如果一个组件需要useState/useEffect,直接标”use client”,别纠结。Server Components是工具,不是KPI。
结论
说了这么多,总结一下核心观点:
迁移不是为了赶时髦,是为了解决实际问题。如果你的项目需要嵌套布局、想减少客户端JS、或者要长期维护,那App Router值得投入时间。
选对迁移策略:小项目渐进式慢慢来,大项目用零停机方案一次性切换。别想着边开发边迁移,会很乱。
踩坑是正常的,我列的7个坑只是冰山一角。遇到问题先去GitHub搜issue,90%的坑别人都踩过了。
不要过度优化,Server Components是工具,不是目的。代码可读性和团队效率比bundle size重要。
迁移这件事,我的建议是:先选1-2个页面试点,跑通流程,总结经验,再全面推进。我们团队就是这么做的,第一个页面迁了3天,等搞清楚套路了,后面10个页面只花了5天。
最后想说,Next.js App Router确实有不少问题(特别是dev server性能),但整体方向是对的。等生态成熟了,这些坑会慢慢填平。
如果你也在迁移过程中遇到了问题,欢迎留言讨论,说不定我也踩过同样的坑。
相关资源:
祝迁移顺利!
Next.js Pages Router 到 App Router 迁移完整流程
从评估到上线的完整迁移步骤,包含两种策略选择和常见问题解决
⏱️ 预计耗时: 80 小时
- 1
步骤1: 评估项目是否值得迁移
判断标准:
• 嵌套布局需求:需要多层布局且切换页面时不想重新渲染
• 性能优化空间:首屏加载时间超过3秒,有优化空间
• 长期维护:项目要维护3年以上,早迁移早享受
不建议迁移的场景:
• 项目马上要下线
• 小型静态站点(5个页面以内)
• 团队对React 18不熟
• 依赖大量老旧第三方库 - 2
步骤2: 选择迁移策略
根据项目规模选择:
小项目(<10页面,页面独立)→ 渐进式迁移:
• 一个页面一个页面迁移
• 可以接受切换时的loading状态
• 适合博客等页面耦合度低的项目
大项目(>10页面,用户体验要求高)→ 零停机方案:
• 在/app/new目录重建所有页面
• 用rewrites+查询参数控制版本切换
• 内部测试通过后再正式上线 - 3
步骤3: 迁移getServerSideProps
步骤:
1. 将页面组件改为async函数
2. 直接在组件内fetch数据
3. 设置正确的cache选项:
• getServerSideProps → cache: 'no-store'
• getStaticProps → cache: 'force-cache'
• getStaticProps + revalidate → next: { revalidate: 60 }
4. 客户端交互部分拆成Client Component:
• 服务端拉数据,传给Client Component
• Client Component处理useState、useEffect等交互逻辑 - 4
步骤4: 处理路由和导航
更新路由相关代码:
• next/router → next/navigation
• useRouter().pathname → usePathname()
• useRouter().query → useSearchParams()
• 使用Link组件替代router.push()
注意:next/navigation的useRouter行为与Pages Router不同,建议直接用Link组件 - 5
步骤5: 配置布局系统
利用嵌套布局避免页面切换闪烁:
• 创建app/layout.tsx作为根布局(Header、Footer)
• 为功能区域创建子布局(如app/dashboard/layout.tsx)
• 每层layout只添加该层特有的UI元素
• 子布局自动继承父布局,切换时不会重新渲染 - 6
步骤6: 处理错误和404
错误处理:
• 创建error.tsx捕获错误并显示友好提示
• 使用'use client'标记error组件
404处理:
• 删除/pages/404.js
• 创建app/not-found.tsx
• 在page.tsx中使用notFound()函数触发404 - 7
步骤7: 测试和优化
测试要点:
• 测试所有页面路由是否正常
• 验证数据获取是否正确
• 检查客户端交互是否正常
• 确认布局切换不闪烁
性能优化:
• 减少不必要的"use client"标记
• 合理使用缓存策略
• 监控FCP、TTI、CLS等指标
• 对比迁移前后的性能数据
常见问题
渐进式迁移和零停机迁移有什么区别?
零停机迁移是在/app/new目录重建所有页面,用查询参数控制版本切换,测试通过后再正式上线,适合大项目且用户体验要求高的场景。
getServerSideProps迁移后数据怎么获取?
注意设置cache选项:
• getServerSideProps对应cache: 'no-store'
• getStaticProps对应cache: 'force-cache'
如果页面有客户端交互,需要拆成Server Component(拉数据)和Client Component(处理交互)。
迁移后页面切换时为什么会闪烁?
useRouter在App Router中怎么用?
迁移后第三方库不兼容怎么办?
常见的不兼容库包括:
• Framer Motion
• 图表库(ECharts、Chart.js)
• 轮播库等
迁移前建议先检查库的GitHub issue确认兼容性。
开发服务器变慢怎么解决?
临时解决方案:
• 定期重启dev server(建议15分钟)
• 使用next dev --turbo启用Turbopack
• 减少不必要的Server Component
Next.js 15据说改进了这个问题。
迁移需要多长时间?
• 小项目(<10页面)可能需要3-5天
• 大项目可能需要2-3周
建议先选1-2个页面试点,跑通流程后再全面推进。我们团队第一个页面迁了3天,后面10个页面只花了5天。
17 分钟阅读 · 发布于: 2025年12月18日 · 修改于: 2026年1月22日
相关文章
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 单元测试实战:Jest + React Testing Library 完整配置指南

评论
使用 GitHub 账号登录后即可评论