Next.js 13 引入的 App Router 不仅改变了路由组织方式,更将 React Server Components 变成了默认渲染模型。很多仍在使用 Pages Router 的项目在升级后会发现两套目录可以共存,但数据获取、布局复用和状态管理的写法差异巨大。本文将从实际迁移角度,展示如何把现有 /pages 目录逐步替换为 /app 目录,并把原本依赖 getServerSideProps 的页面改造成 Server Components。

一、Pages Router 与 App Router 的本质差异
Pages Router 的核心是每个文件对应一个路由,数据获取依赖 getServerSideProps、getStaticProps 和 getInitialProps 等 API,组件在服务端执行后把序列化数据传给页面组件。这种模式将数据获取和组件渲染强耦合在同一个文件里,页面级代码无法轻松复用布局,嵌套路由需要借助 _app.tsx 和 _document.tsx 来包装全局逻辑。比如一个典型的文章详情页需要在同文件中导出 getServerSideProps 和默认组件,逻辑分散且难以测试。
App Router 引入了基于文件约定的嵌套路由,app 目录下的 layout.tsx 可以层层嵌套,page.tsx 只负责页面主体。更重要的是,默认导出的组件可以是异步函数,直接使用 fetch 获取数据,不再需要专门的 getServerSideProps。布局、加载状态、错误边界分别通过 layout.tsx、loading.tsx、error.tsx 来声明,代码组织更接近组件树而非路由表。此外,Server Components 默认在服务端渲染,不会增加客户端 JavaScript 体积,这对性能敏感的应用非常关键。
// Pages Router 目录结构
pages/
_app.tsx
_document.tsx
posts/
[id].tsx
// App Router 目录结构
app/
layout.tsx
page.tsx
posts/
[id]/
page.tsx
loading.tsx
error.tsx
可以看到,App Router 把同一路由下的辅助文件集中在一个文件夹中,按职责拆分。这种设计让每个路由单元自包含,方便复用和并行开发。同时,根 layout 替代了 _app.tsx 和 _document.tsx 的大部分功能,全局样式、字体、Provider 都可以集中在 layout.tsx 中处理。
二、Server Components 的引入与数据获取重写
Server Components 是 React 的一种新组件类型,它在服务端渲染,不会被打包到客户端 JavaScript 中。这意味着组件内部可以直接访问数据库、文件系统等后端资源,而不需要通过 API 层中转。在 Pages Router 中,即使用了 getServerSideProps,页面组件本身仍然会被发送到客户端,而 Server Components 则彻底避免了这个开销。组件中如果只涉及数据展示、静态内容,完全不需要客户端运行时。
迁移数据获取逻辑时,最常见的改动是把 getServerSideProps 中的 fetch 直接移动到 async 组件内部。这样做的好处是请求可以在服务端与组件渲染并行,并且 Next.js 会自动对 fetch 结果进行缓存和去重。但要注意,Server Components 中不能使用 useState、useEffect 等客户端钩子,也不能绑定事件处理器,需要交互的部分必须拆分为 Client Component。下面是一个对比示例。
// Pages Router 写法
export async function getServerSideProps(context) {
const { id } = context.params;
const res = await fetch(`https://api.ipipp.com/posts/${id}`);
const post = await res.json();
return { props: { post } };
}
export default function PostPage({ post }) {
return <article>{post.title}</article>;
}
// App Router Server Component 写法
export default async function PostPage({ params }: { params: { id: string } }) {
const res = await fetch(`https://api.ipipp.com/posts/${params.id}`);
const post = await res.json();
return <article>{post.title}</article>;
}
从上述代码可以看出,Server Component 把数据获取和渲染放在同一个函数体里,减少了样板代码。但需要注意,这里的 fetch 默认会被 Next.js 缓存,如果希望每次请求都获取最新数据,可以设置 cache: 'no-store' 或者使用 next: { revalidate: 0 } 选项。另外,组件内部不能直接使用浏览器 API,例如 window、document,否则会报错。
三、迁移步骤与兼容策略
完整迁移一个大型项目并不现实,官方推荐渐进式迁移,即保留 pages 目录的同时逐步增加 app 目录。Next.js 允许两个目录并存,路由优先级上 app 目录会覆盖 pages 目录中相同路径的文件。迁移时可以按照模块逐个替换,比如先迁移静态页面,再迁移依赖上下文的动态页面。这样做的好处是每完成一个页面就能验证效果,降低整体风险。
具体步骤包括:在根目录创建 app 文件夹并添加 layout.tsx 和 page.tsx 作为基础骨架;将原有 _app.tsx 中的全局样式、Provider 等逻辑迁移到根 layout;把每个页面从 pages 移动到 app 对应路径,并将 getServerSideProps 改为 async 组件内的 fetch;如果页面包含客户端交互,使用 use client 指令标记子组件,保持 Server Component 尽可能精简。以根布局为例,它需要包含 html 和 body 结构,并可以引入全局样式。
// app/layout.tsx
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
如果页面中有一部分需要客户端交互,例如一个点赞按钮,不要将整个页面标记为 use client,而是单独抽出一个组件文件。这样页面主体仍然可以是 Server Component,只有按钮部分被发送到客户端。这种边界划分是 App Router 性能优化的重要原则。
'use client';
import { useState } from 'react';
export default function LikeButton() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>
{liked ? '取消赞' : '点赞'}
</button>;
}
在页面中使用这个客户端组件时,只需正常导入即可。Server Component 可以导入 Client Component,但反过来不行。如果 Client Component 需要接收来自 Server Component 的数据,建议通过 props 传递可序列化的值,而不是直接传递函数或类实例。
四、常见陷阱与调试技巧
迁移过程中最容易踩的坑是上下文丢失。Pages Router 中经常在 _app.tsx 里包裹 ThemeProvider、AuthProvider 等,迁移到 App Router 后,这些 Provider 必须放在根 layout 中并且标记为 use client,否则布局和页面无法访问到上下文。另一个问题是 useSearchParams 和 useRouter 这类客户端钩子现在必须在 Client Component 中使用,否则会触发构建错误。很多开发者会惯性把整个页面加上 use client,这虽然能解决问题,但会丧失 Server Components 的性能优势。
调试 Server Components 时,可以借助 Next.js 的构建输出来确认哪些组件被标记为客户端,或者在浏览器 DevTools 中查看网络请求,Server Components 不会出现在客户端 bundle 中。遇到 fetch 缓存不符合预期时,可以通过 fetch 的 cache 和 next.revalidate 选项控制缓存策略,避免数据陈旧。例如,对于需要实时更新的数据,可以这样写:
// 禁用缓存,每次请求都获取最新数据
const res = await fetch('https://api.ipipp.com/posts', { cache: 'no-store' });
// 或者设置重新验证周期,单位秒
const res = await fetch('https://api.ipipp.com/posts', { next: { revalidate: 60 } });
另外,错误边界的处理也值得关注。App Router 中每个路由段可以定义 error.tsx,它必须是 Client Component,用于捕获该段及其子段中的运行时错误。这样可以避免一个页面报错导致整个应用白屏。配合 loading.tsx 提供即时反馈,用户感知会更好。迁移时建议为每个主要路由都补充 error.tsx 和 loading.tsx,哪怕只有简单的提示文本。
最后提醒一点,迁移不是一蹴而就的过程。可以保持 pages 目录继续运行数月,同时在 app 目录中逐步增加新页面和重构旧页面。借助 Next.js 官方提供的 codemod 工具能够自动化一部分文件移动和导入路径调整,但业务逻辑的适配仍需要人工审查。掌握 Server Components 的边界和数据获取方式后,剩下的工作就是按部就班地替换和验证。

