Next.js 从 Pages Router 完整迁移到 App Router:以博客应用的迁移任务为实战范本
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
本文以 Next.js 仓库中evals/evals/agent-030-app-router-migration-hard/PROMPT.md定义的迁移任务为核心,围绕它所要求的全部约束——迁移所有路由与文件、彻底删除pages目录、替换已废弃的 Pages Router API、补齐 TypeScript 类型——逐一展开。配合该评测目录下自带的 Pages Router 博客项目(含getServerSideProps、getStaticProps+ ISR、getStaticPaths+fallback、API Routes、_app/_document等高级模式)及其验收测试 EVAL.ts,你将掌握每一种 Pages Router API 在 App Router 中的标准替代写法,以及如何用自动化测试验证迁移的正确性。
一、任务要求:一份“Hard”难度的迁移任务书在约束什么
迁移任务的原始定义只有一段话,但信息量很密集(见 PROMPT.md):
将 Pages Router 中的每一条路由和每一个文件迁移到 App Router。完成后彻底删除 pages 目录。确保使用了正确的 App Router API。如果某个 Pages Router API 在 App Router 中已不存在,请替换为新版 API 或新模式。记得添加类型。
拆开来看,它提出了五层递进的约束:
- 全量迁移:不是迁移部分页面,而是“every route and file”,包括隐藏文件(
_app.js、_document.js、_error.js)和 API 路由; - 彻底删除
pages目录:不允许两套 Router 共存混用,迁移完成后项目中不能残留任何 Pages Router 入口; - API 语义对齐:
next/head、next/router、getInitialProps、req/res风格的 API handler 等 Pages Router 专属 API 必须换成 App Router 对应物; - 新模式替换:部分 Pages Router 能力(如
getServerSideProps返回props)在 App Router 中没有同名 API,要改写为“async Server Component 内直接取数据”这类新模式; - 类型化:所有产物必须是
.ts/.tsx,函数签名、params、children等都要有类型——这正是目录名 “hard” 的难度来源:它不是简单改文件名,而是要求同时处理数据获取、路由处理器、Metadata API、'use client'指令放置等多个进阶模式的迁移。
评测环境的工程配置也值得先看一眼。package.json 声明了next: ^16、react: 19.1.0、typescript: ^5,脚本为标准的dev/build/start(即next dev、next build、next start);next.config.ts 是空的NextConfig,意味着迁移不需要任何额外配置项;tsconfig.json 开启了strict: true并配置了@/*路径别名,验收时类型检查是真实生效的。
二、原始 Pages Router 项目盘点:每个文件对应什么 App Router 模式
迁移前的项目是一个典型的复杂博客应用,文件结构如下:
evals/evals/agent-030-app-router-migration-hard/ ├── components/AppProvider.js # Context Provider(主题状态) ├── pages/ │ ├── _app.js # 全局包装:导航 + AppProvider + 全局样式 │ ├── _document.js # 自定义 html/body:lang、favicon、字体 preconnect │ ├── _error.js # 自定义错误页(getInitialProps) │ ├── 404.js # 自定义 404 │ ├── index.js # 首页:getServerSideProps + Head + useRouter │ ├── blog/ │ │ ├── index.js # 列表:getStaticProps + revalidate: 60 │ │ └── [id].js # 详情:getStaticPaths + fallback:'blocking' │ └── api/posts/ │ ├── index.js # GET/POST 文章 │ └── [id].js # GET/PUT/DELETE 单篇文章 └── styles/globals.css按任务要求逐文件映射到 App Router,对照关系是:
| Pages Router 文件/API | App Router 替代 |
|---|---|
pages/_app.js+pages/_document.js | app/layout.tsx(根布局,含html/body/metadata/children) |
pages/_error.js(getInitialProps) | app/error.tsx('use client'错误边界) |
pages/404.js | app/not-found.tsx |
pages/index.js的getServerSideProps | app/page.tsxasync Server Component 内直接fetch |
pages/blog/index.js的getStaticProps+revalidate | app/blog/page.tsx+export const revalidate |
pages/blog/[id].js的getStaticPaths+fallback | app/blog/[id]/page.tsx+generateStaticParams |
pages/api/posts/index.js | app/api/posts/route.ts(GET/POST函数) |
pages/api/posts/[id].js | app/api/posts/[id]/route.ts(GET/PUT/DELETE函数) |
next/head的<Head> | export const metadata |
next/router的useRouter | next/navigation的useRouter(仅客户端组件) |
components/AppProvider.js(JS) | 保留并迁移为 TS 客户端组件,在根布局中包裹children |
验收测试 EVAL.ts 的 7 个用例恰好逐条覆盖这张映射表,下一节按映射表逐项展开。
三、_app.js+_document.js→ 根布局app/layout.tsx
原始的两个隐藏文件分工是:_app.js 在每次路由切换时包裹页面,提供导航头、<main>容器、页脚和 AppProvider(一个持有theme状态、基于createContext/useState的 Context Provider,还带useAppContext守卫);_document.js 则声明<Html lang="en">、favicon 和 Google Fonts 的preconnect+ 样式表。
App Router 中没有这两个文件,它们合并为唯一的根布局app/layout.tsx。验收测试的第一个用例(EVAL.ts 中 “Root layout exists and replaces _app/_document”)用正则锁定了四条硬性断言:
<html>标签必须带lang属性(承接_document.js的lang="en");- 必须有
<body>标签; - 文件内容必须出现
metadata/Metadata(承接_document.js中<Head>的description等站点级 meta); - 必须接收
children且类型为ReactNode(替代_app.js的<Component {...pageProps} />)。
因此迁移后的根布局骨架应类似:
// app/layout.tsx import type { Metadata } from 'next' import type { ReactNode } from 'react' import './globals.css' import { AppProvider } from '@/components/AppProvider' export const metadata: Metadata = { description: 'A complex blog application', icons: { icon: '/favicon.ico' }, } export default function RootLayout({ children, }: { children: ReactNode }) { return ( <html lang="en"> <head> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" /> </head> <body> <AppProvider>{children}</AppProvider> </body> </html> ) }两个细节需要注意:
- 原
_app.js里的导航头/页脚等静态 UI 可以直接写进布局(布局是 Server Component,next/link在其中可用);而AppProvider使用了useState,属于客户端逻辑,因此迁移为 TS 后组件本身需要加'use client',再在布局中包裹children——这正是任务书“proper'use client'directive placement”所指的内容之一; - 站点级
description从_document.js的<meta>提升为布局的metadata导出,favicon 用icons字段声明,字体preconnect直接放在head中(测试只校验html lang、body、metadata与children: ReactNode,字体链接属于可自由保留的细节)。
四、getServerSideProps→ async Server Component 直接取数
首页 pages/index.js 是整套样例中最典型的 SSR 页面,原实现有三块:
// pages/index.js(迁移前) export async function getServerSideProps({ req }) { const userAgent = req.headers['user-agent'] || '' const posts = await fetch( 'https://jsonplaceholder.typicode.com/posts?_limit=5' ).then((res) => res.json()) return { props: { posts, userAgent, timestamp: new Date().toISOString() }, } }req.headers['user-agent']:Pages Router 直接暴露 Node 风格的req对象;fetch文章列表并在服务端渲染时打时间戳;- 组件内混用了
<Head>(next/head)和next/router的useRouter(点击按钮router.push('/blog'))。
App Router 中等价的做法是把页面直接写成 async Server Component:在渲染期间执行fetch,请求头改用next/headers的headers()API 读取(req对象在 App Router 的页面中不存在了):
// app/page.tsx import { headers } from 'next/headers' import type { Metadata } from 'next' import { HomeClient } from './home-client' export const metadata: Metadata = { title: 'Home - My Blog', description: 'Welcome to my blog homepage', openGraph: { title: 'Home - My Blog' }, } export default async function HomePage() { const h = await headers() const userAgent = h.get('user-agent') || '' const posts = await fetch( 'https://jsonplaceholder.typicode.com/posts?_limit=5' ).then((res) => res.json()) return ( <main> <p>Server-side rendered at: {new Date().toISOString()}</p> <p>Your user agent: {userAgent}</p> {/* 渲染 posts 列表 */} <HomeClient /> </main> ) }这里有三处关键转换:
req→headers():Server Component 中不再有请求/响应对象,读请求头一律走next/headers;<Head>→metadata导出:title、description、og:title分别落到title、description和openGraph.title字段;- 客户端交互拆分:带
onClick的按钮不能留在 Server Component 里,需要拆出客户端组件(测试文件检查的是app/home-client.tsx)。在该客户端组件里,useRouter必须改从next/navigation导入而不是next/router——EVAL.ts 的 “Client components use next/navigation hooks” 用例会同时断言import.*useRouter.*next\/navigation必须存在、next/router的导入必须不存在。
另外注意:该用例对首页的判定使用了 LLM 语义评审(environment.toSatisfyCriterion),其判分说明写得很明确——“Judge runtime behavior, not style: any organization that renders the fetched data from a Server Component is correct”。也就是说,数据获取可以内联fetch,也可以抽到 helper 函数;组件不强制export default async function这种确切形态。这提示我们迁移时不必逐行复刻旧结构,只要保证“Server Component 在服务端渲染期取到并渲染数据”这一运行时语义即可。
五、getStaticProps+revalidate→ 顶层revalidate(ISR)
博客列表页 pages/blog/index.js 使用getStaticProps抓取全部文章,并通过返回值中的revalidate: 60实现 60 秒的 ISR。App Router 中getStaticProps不存在,等价物是两件事的组合:
// app/blog/page.tsx import type { Metadata } from 'next' export const metadata: Metadata = { title: 'Blog - My Blog', description: 'All blog posts', } export const revalidate = 60 // 与 getStaticProps 的 revalidate: 60 等价 export default async function BlogIndex() { const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then( (res) => res.json() ) return ( <div> <h1>All Blog Posts</h1> {/* 渲染 posts 卡片;“Read More” 的跳转逻辑同样应拆到客户端组件 */} </div> ) }对应关系是:return { props, revalidate: 60 }中的revalidate从函数返回值提升为模块顶层的export const revalidate;props则不再需要——页面自己就是 async 函数,取到什么就渲染什么。测试 “Blog index migrated with ISR equivalent” 对此有三条断言:页面必须是 async Server Component、内容必须出现revalidate且后跟数字(/revalidate.*\d+/)、剥离注释后的代码中不允许残留getStaticProps字样。
最后这条断言依赖一个值得留意的工具函数stripComments(EVAL.ts 第 19-21 行):它先删块注释再删行注释,然后才做正则匹配。换句话说,迁移后的文件里允许用注释说明“这里原来是什么 API”(迁移备注不会被判违规),但实际执行代码里必须彻底移除旧 API。这是一个很实用的迁移习惯:注释可以记录来路,代码只能去新路。
六、getStaticPaths+fallback: 'blocking'→generateStaticParams
动态详情页 pages/blog/[id].js 是样例中最复杂的路由,同时涉及三种机制:
getStaticPaths预取前 10 篇文章生成paths,并声明fallback: 'blocking'(源文件第 4-17 行);getStaticProps并行fetch文章正文与评论(Promise.all),revalidate: 300;抓取失败时返回notFound: true;- 组件中用
router.isFallback显示Loading...。
迁移到app/blog/[id]/page.tsx后的对应模式:
// app/blog/[id]/page.tsx import type { Metadata } from 'next' import { notFound } from 'next/navigation' import { BlogPostClient } from './blog-post-client' export const revalidate = 300 // 对应 getStaticProps 的 revalidate: 300 export async function generateStaticParams() { const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then( (res) => res.json() ) return posts.slice(0, 10).map((post) => ({ id: post.id.toString(), })) } export async function generateMetadata({ params, }: { params: Promise<{ id: string }> }): Promise<Metadata> { const { id } = await params const post = await fetch( `https://jsonplaceholder.typicode.com/posts/${id}` ).then((res) => res.json()) return { title: `${post.title} - My Blog`, description: post.body.substring(0, 160), openGraph: { title: post.title, description: post.body.substring(0, 160), }, } } export default async function BlogPost({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params let post: { title: string; body: string } let comments: { id: number; name: string; body: string; email: string }[] try { ;[post, comments] = await Promise.all([ fetch(`https://jsonplaceholder.typicode.com/posts/${id}`).then((r) => r.json()), fetch(`https://jsonplaceholder.typicode.com/posts/${id}/comments`).then( (r) => r.json() ), ]) } catch { notFound() // 对应 getStaticProps 返回 notFound: true } return ( <article> <BlogPostClient id={id} /> <h1>{post.title}</h1> <p>{post.body}</p> {/* 渲染 comments 列表 */} </article> ) }逐点对照:
getStaticPaths→generateStaticParams:返回值形状一致([{ params }]),函数体内同样先fetch再slice(0, 10).map。测试断言该文件必须导出generateStaticParams且为 async Server Component,同时剥离注释后不得出现getStaticPaths或getStaticProps;fallback: 'blocking'的归宿:App Router 没有fallback选项;构建时未覆盖的路径会按需构建,加载态由 React 的Suspense/框架的流式渲染机制承担,组件里原来的router.isFallback分支没有直接等价物,可以移除或改用 Suspense 骨架屏;notFound: true→notFound():动态导入notFound()并调用即可触发全局not-found.tsx;params是 Promise:next ^16中路由的params为异步对象,页面与generateMetadata都需要await params——这正是任务书 “Make sure to add types” 里最容易踩坑的类型点:{ params: Promise<{ id: string }> }。
七、API Routes → Route Handlers
两条 API 路由是典型的req/res风格:
- pages/api/posts/index.js:
GET返回模拟文章列表,POST校验title/content后创建文章(400/201),其他方法返回 405 并设置Allow头; - pages/api/posts/[id].js:
GET单篇、PUT更新、DELETE删除,同样带 405 +Allow头兜底。
App Router 的对应物是route.ts文件,导出与 HTTP 方法同名的异步函数,使用标准的Request/Response或NextRequest/NextResponse:
// app/api/posts/route.ts import { NextRequest, NextResponse } from 'next/server' export async function GET() { const posts = [ { id: 1, title: 'First Post', content: 'This is the first post' }, { id: 2, title: 'Second Post', content: 'This is the second post' }, ] return NextResponse.json(posts) } export async function POST(request: NextRequest) { const { title, content } = await request.json() if (!title || !content) { return NextResponse.json( { error: 'Title and content are required' }, { status: 400 } ) } const newPost = { id: Date.now(), title, content, createdAt: new Date().toISOString(), } return NextResponse.json(newPost, { status: 201 }) }动态段则改为从路由参数取值——Pages Router 里从req.query拿id,Route Handler 里改为context.params(异步对象,需await):
// app/api/posts/[id]/route.ts import { NextRequest, NextResponse } from 'next/server' type RouteContext = { params: Promise<{ id: string }> } export async function GET(_request: NextRequest, context: RouteContext) { const { id } = await context.params return NextResponse.json({ id: parseInt(id), title: `Post ${id}`, content: `This is the content for post ${id}`, createdAt: new Date().toISOString(), }) } // PUT / DELETE 同理:await context.params 后按原逻辑返回测试 “API routes migrated to Route Handlers” 的断言是:app/api/posts/route.ts必须导出GET/POST且内容中出现Request/Response/NextRequest/NextResponse之一;app/api/posts/[id]/route.ts必须导出GET/PUT/DELETE之一。原 handler 里res.status(405)+Allow头的兜底逻辑可以整体省略(Route Handler 只注册你导出的方法,未导出的方法自动得到 405)。
八、Metadata API 全面替换next/head
样例项目中next/head出现了 5 次:首页、博客列表、文章详情、_error.js、404.js。迁移后的统一规则是——页面文件导出metadata(或 async 的generateMetadata),不再 importnext/head:
- 静态标题/描述:
export const metadata: Metadata = { title, description, openGraph }; - 依赖数据的标题(文章详情页的
post.title):使用generateMetadata,其params同样是Promise类型; - 站点级 meta(原
_document.js的description、favicon):提升到根布局的metadata导出。
验收测试 “Metadata API replaces next/head” 对app/page.tsx与app/blog/page.tsx各做了双向断言:必须匹配export.*metadata,且不允许出现import.*Head.*next/head或<Head>标签。也就是说旧 API 既不能“导入着用”,也不能以 JSX 形式残留。
九、错误处理:_error.js与404.js→error.tsx+not-found.tsx
原始错误处理有两个文件:
- pages/_error.js:接收
statusCode,区分 404 与服务端/客户端错误文案,并通过getInitialProps从res.statusCode/err.statusCode推断状态码; - pages/404.js:静态 404 文案加“回到首页”按钮(又用了一次
next/router)。
App Router 的对应物:
// app/error.tsx 'use client' // 错误边界必须是客户端组件 export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div className="error-page"> <h1>Sorry, something went wrong.</h1> <button onClick={reset}>Try again</button> </div> ) }// app/not-found.tsx import Link from 'next/link' export default function NotFound() { return ( <div className="error-page"> <h1>404 - Page Not Found</h1> <p>The page you're looking for doesn're exist.</p> <Link href="/">Go Back Home</Link> </div> ) }对照要点:error.tsx的errorprop 承接了_error.js的err(statusCode不再显式传递,404 场景由not-found.tsx分流处理);reset提供了原实现没有的“重试”能力;not-found.tsx中的“回到首页”按钮改用next/link或服务端可渲染的跳转,避免在纯展示页面依赖next/navigation。测试 “Error handling migrated to error.js and not-found.js” 的断言是:app/error.tsx必须带'use client'且出现error与Error的匹配(即接收 error prop),app/not-found.tsx必须存在。另外,_error.js的getInitialProps写法整体删除——App Router 没有页面级getInitialProps。
十、类型化与验收清单:EVAL.ts 是迁移完成的“定义”
任务书最后一句 “Make sure to add types” 在这个项目里落到了几处具体的类型签名上,它们也恰好是迁移中最容易写错的地方:
| 位置 | 需要的类型 |
|---|---|
app/layout.tsx | { children: ReactNode },children必须声明为ReactNode(测试用/children.*ReactNode/校验) |
| 动态页面 | { params: Promise<{ id: string }> },且渲染前await params |
generateMetadata | 返回Promise<Metadata> |
| Route Handler | NextRequest/NextResponse参数与RouteContext类型 |
AppProvider | 迁移为 TS 时给createContext补上{ theme: string; setTheme: React.Dispatch<React.SetStateAction<string>> }类型,并加'use client' |
最终,EVAL.ts 提供了完整的自动化验收清单,7 个用例逐一锁定迁移的“完成”定义:
| 测试用例 | 核心验证点 |
|---|---|
| Root layout exists and replaces _app/_document | app/layout.tsx存在;<html lang>、<body>、metadata、children: ReactNode |
| Home page migrated to Server Component with async data fetching | app/page.tsx存在;由 LLM 评审确认是“渲染服务端取回数据的 async Server Component”(不看代码风格,看运行时行为) |
| Blog index migrated with ISR equivalent | async 页面 +revalidate数字 + 代码中无getStaticProps |
| Dynamic blog route migrated to generateStaticParams | 导出generateStaticParams+ async + 代码中无getStaticPaths/getStaticProps |
| API routes migrated to Route Handlers | 导出 HTTP 方法函数 + 使用 Request/Response 系 API |
| Metadata API replaces next/head | 导出metadata且无next/head导入或<Head> |
| Error handling migrated to error.js and not-found.js | error.tsx带'use client'且接收errorprop;not-found.tsx存在 |
| Client components use next/navigation hooks | useRouter必须来自next/navigation,禁止next/router |
这套测试设计本身也有两点值得借鉴:其一,stripComments让“迁移备注注释”与“真实代码残留”被区别对待,正则检查只针对实际执行的代码;其二,对存在多种合法形态的语义性检查(首页的 Server Component 取数)没有硬编码正则,而是交给 LLM 评审,并给评审附上了一份“参考正确形态”——这避免了旧版正则误杀把fetch抽到 helper 的正确解法。
结语
这个 “hard” 迁移任务的全部复杂度,浓缩起来就是三张映射表:Pages 专属 API(getServerSideProps/getStaticProps/getStaticPaths/getInitialProps/req.reshandler)到 App Router 模式(async Server Component、顶层revalidate、generateStaticParams、Route Handler)的替换;next/head与next/router到metadata/next/navigation的导入迁移;以及 JS 文件到带类型.ts/.tsx产物的转换(params是Promise、children是ReactNode)。按照 PROMPT.md 的要求完成后,pages目录应当被整体移除,项目只保留app目录与客户端组件,并以 EVAL.ts 的 7 个用例作为迁移正确性的可执行定义。
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考