切换语言
切换主题

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都没遇到,因为正式上线之前我们已经用真实数据测了一个礼拜。

具体步骤:

  1. 升级Next.js到14 —— 先不动/pages,只升级框架版本
  2. 迁移路由钩子 —— 把next/router改成next/navigation,保证代码兼容两个路由器
  3. 创建/app/new目录 —— 在这里重建页面结构
  4. 复用现有组件 —— 把/pages里的React组件直接import进来,不用重写
  5. 配置rewrites —— 加上面那段配置,用?new=true切换
  6. 内部测试+灰度 —— 让团队用新版本,发现问题及时修
  7. 正式上线 —— 把/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),不是每次请求都拉新数据。

我刚开始迁移时没注意这个,把商品价格页面迁过去,结果价格一直不更新。用户投诉说”明明降价了,页面还显示原价”,我排查了半天才发现是缓存的锅。

记住这个对照表:

  • getServerSidePropscache: 'no-store'
  • getStaticPropscache: 'force-cache'(默认行为)
  • getStaticProps + revalidatenext: { 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/headersnext/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/routernext/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 RouterApp Router
useRouter().push(url)useRouter().push(url) (有,但不建议用)
useRouter().pathnameusePathname()
useRouter().queryuseSearchParams()
useRouter().asPathusePathname() + 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做页面切换动画,迁移后全挂了。

解决方法:

  1. 用”use client”包裹使用这些库的组件
  2. 检查库有没有更新版本支持React 18(有些库已经适配了)
  3. 实在不行,换个支持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

    步骤1: 评估项目是否值得迁移

    判断标准:
    • 嵌套布局需求:需要多层布局且切换页面时不想重新渲染
    • 性能优化空间:首屏加载时间超过3秒,有优化空间
    • 长期维护:项目要维护3年以上,早迁移早享受

    不建议迁移的场景:
    • 项目马上要下线
    • 小型静态站点(5个页面以内)
    • 团队对React 18不熟
    • 依赖大量老旧第三方库
  2. 2

    步骤2: 选择迁移策略

    根据项目规模选择:

    小项目(<10页面,页面独立)→ 渐进式迁移:
    • 一个页面一个页面迁移
    • 可以接受切换时的loading状态
    • 适合博客等页面耦合度低的项目

    大项目(>10页面,用户体验要求高)→ 零停机方案:
    • 在/app/new目录重建所有页面
    • 用rewrites+查询参数控制版本切换
    • 内部测试通过后再正式上线
  3. 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

    步骤4: 处理路由和导航

    更新路由相关代码:
    • next/router → next/navigation
    • useRouter().pathname → usePathname()
    • useRouter().query → useSearchParams()
    • 使用Link组件替代router.push()

    注意:next/navigation的useRouter行为与Pages Router不同,建议直接用Link组件
  5. 5

    步骤5: 配置布局系统

    利用嵌套布局避免页面切换闪烁:
    • 创建app/layout.tsx作为根布局(Header、Footer)
    • 为功能区域创建子布局(如app/dashboard/layout.tsx)
    • 每层layout只添加该层特有的UI元素
    • 子布局自动继承父布局,切换时不会重新渲染
  6. 6

    步骤6: 处理错误和404

    错误处理:
    • 创建error.tsx捕获错误并显示友好提示
    • 使用'use client'标记error组件

    404处理:
    • 删除/pages/404.js
    • 创建app/not-found.tsx
    • 在page.tsx中使用notFound()函数触发404
  7. 7

    步骤7: 测试和优化

    测试要点:
    • 测试所有页面路由是否正常
    • 验证数据获取是否正确
    • 检查客户端交互是否正常
    • 确认布局切换不闪烁

    性能优化:
    • 减少不必要的"use client"标记
    • 合理使用缓存策略
    • 监控FCP、TTI、CLS等指标
    • 对比迁移前后的性能数据

常见问题

渐进式迁移和零停机迁移有什么区别?
渐进式迁移是一个页面一个页面迁移,适合小项目,但在/pages和/app之间切换会有loading状态。

零停机迁移是在/app/new目录重建所有页面,用查询参数控制版本切换,测试通过后再正式上线,适合大项目且用户体验要求高的场景。
getServerSideProps迁移后数据怎么获取?
将页面组件改为async函数,直接在组件内fetch数据。

注意设置cache选项:
• getServerSideProps对应cache: 'no-store'
• getStaticProps对应cache: 'force-cache'

如果页面有客户端交互,需要拆成Server Component(拉数据)和Client Component(处理交互)。
迁移后页面切换时为什么会闪烁?
通常是因为没有正确使用layout系统。应该创建app/layout.tsx作为根布局,为功能区域创建子布局。这样切换页面时只有内容区更新,导航栏和侧边栏不会重新渲染,避免闪烁。
useRouter在App Router中怎么用?
App Router使用next/navigation而不是next/router。useRouter().pathname改为usePathname(),useRouter().query改为useSearchParams()。建议直接使用Link组件进行导航,而不是router.push()。
迁移后第三方库不兼容怎么办?
将使用这些库的组件标记为'use client'。

常见的不兼容库包括:
• Framer Motion
• 图表库(ECharts、Chart.js)
• 轮播库等

迁移前建议先检查库的GitHub issue确认兼容性。
开发服务器变慢怎么解决?
这是Next.js 14的已知问题。

临时解决方案:
• 定期重启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日

评论

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

相关文章