Skip to main content

Next.js App 与 Pages 路由

App Router 不是一个「更好的目录」,它是围绕 RSC 重写的整套数据获取与缓存模型。迁移成本主要在心智,不在代码量。

一、围绕 RSC 重写的模型​

最显眼的变化在目录约定,但改的不只是目录。app/ 下用 layout.tsx / page.tsx / loading.tsx / error.tsx / template.tsx 这组文件描述路由段与嵌套关系:layout 负责跨子路由复用的外壳、page 是路由出口、loading 是段级加载态、error 是段级错误边界、template 每次导航都重新挂载。它们天然嵌套,子路由自动继承父 layout。这和 Pages Router 里每个页面自己包一层 _app 完全不同——边界与复用是声明出来的,不是手写的。

Pages Router 的一切以 pages/ 目录约定式函数为中心;App Router 默认是 Server Component,组件的「在哪执行」成了首要问题。所以迁移不是搬家,是重写取数与边界的心智模型。目录约定好看,代价是思维切换:过去「这是个页面」,现在要先想「这是服务端组件还是客户端组件、数据在哪取、缓存多久」。团队磨合期最易错的不是语法,是把客户端状态写进服务端组件。

二、取数位置从组件搬到服务端​

Pages Router 时代用 getServerSideProps / getStaticProps 这类约定式函数取数,取数时机与页面强绑定。App Router 里取数就是「在服务端组件里直接 await 一个请求」,并用缓存选项描述这份数据的新鲜度:要静态就设长缓存、要定时更新就设 revalidate、要每次最新就关缓存。

// Pages Router:取数绑定在页面级约定函数上
export async function getServerSideProps() {
const data = await fetch('https://api.example.com/list').then((r) => r.json())
return { props: { data } }
}

// App Router:在服务端组件里直接取,新鲜度用缓存选项表达
export default async function Page() {
const res = await fetch('https://api.example.com/list', { next: { revalidate: 60 } })
const data = await res.json()
return <List items={data} />
}
// 粒度从「页面级」细到「组件级」:每个组件各取各的数,互不影响
async function Price({ id }: { id: string }) {
const p = await fetch(`https://api.example.com/price/${id}`, { cache: 'no-store' })
return <span>{(await p.json()).value}</span>
}

写法变简单了,但缓存在哪一层失效变成了必须自己想清楚的事。截至 2026 年 10 月,Next.js 16 默认不再缓存 fetch 结果(opt-in),要新鲜度就显式声明 revalidate 或用 use cache,这终结了「我以为拿到实时数据其实拿到缓存」的一类困惑。

迁移时第一个会撞上的破坏性变更:从 Next.js 15 起 params 与 searchParams 是 Promise,必须 await 之后再用。旧写法直接解构 params.id 会静默拿到 undefined——不报错,只是数据不对,排查起来很费时间。

三、默认缓存与显式开启的反转​

至少要分清三层:请求层(同一个请求在单次渲染中被复用)、数据缓存层(跨请求复用,依赖 revalidate 或标签失效)、路由与 CDN 层(整页 HTML 的缓存)。绝大多数「数据不更新」的 bug 不是取数写错了,而是某一层缓存没有按预期失效。

排查「数据不更新」:
1. 期望哪一层失效?(请求 / 数据 / 路由+CDN)
2. 实际哪一层没失效?
3. 对应改 revalidate 标签、cache 选项,还是让 CDN 回源

多层缓存叠加时,任一层未失效都会拿到旧数据——所以设了 revalidate 不一定按时间更新,前提是更上层的路由/CDN 缓存也按预期失效。先确定期望哪一层失效,再动手。

① 请求级复用:同一次渲染内,相同请求只真正发一次② 数据缓存:跨请求复用,靠 revalidate 时间或 tag 失效③ 整页缓存:整页 HTML 与 RSC payload 的缓存④ 客户端路由缓存:浏览器内导航时复用已取过的路由段「数据怎么不更新」多半出在第 ④ 层——它在浏览器里,服务端 revalidate 不一定能立刻清掉它。
图:App Router 的四层缓存——从服务端一路到浏览器,越往下越容易被忽略

四、不急着迁移的几种情况​

三条判断:老项目稳定、没有 RSC 需求,不必为迁移而迁移;重度依赖 Pages 生态的定制中间件与插件,评估成本划不来;团队没有迁移窗口,强行切只会引入并行维护两套模型的风险。不迁也是一种合理决策——Pages Router 仍受维护,只是不再加新特性。

共存期的目录结构长这样,两套路由各管一段:

app/                  # App Router:新页面写这里
layout.tsx
product/[id]/page.tsx
pages/ # Pages Router:暂时不迁的留在这里
legacy/report.tsx
api/legacy-webhook.ts

# 两条都要注意:同名路由以 app 优先,别让两套都命中同一个路径

五、按路由增量迁移​

可执行顺序是「按路由分批、先内容页后交互页」:app 与 pages 可以共存,按路由逐个迁移,先迁静态内容页(收益高、风险低),最后动交易链路(交互密、要慎)。共存是渐进迁移的基础,不要一次性切换。

app/
layout.tsx # 新布局
page.tsx # 已迁移
checkout/
page.tsx # 交互页,最后迁
pages/
checkout.tsx # 旧路由,仍生效,逐步退役

共存期两套路由都要维护,成本翻倍是暂时的——节奏是把交互页留到最后,等 app 侧的工具链与团队习惯都成熟再切,避免半途卡在交易链路的边界坑里。

六、不只是目录换了​

把文件挪进 app 目录只是第一步。取数方式、缓存语义、布局与错误边界的写法全变了,这是重写不是搬家。

设了 revalidate 也不代表一定会按时间更新——多层缓存叠加时,任何一层没失效都会拿到旧数据。

好消息是两者可以共存:app 与 pages 能同时存在,这也是渐进迁移的基础。

迁移真正的门槛不是目录结构,而是把「这份数据该在什么时候失效」重新想一遍。

两套路由的差异与迁移路径见 Next.js App Router 文档,具体 API 的取舍以官方为准。