Next.js 从 Pages Router 完整迁移到 App Router:以博客应用的迁移任务为实战范本
2026/9/7 17:19:51 网站建设 项目流程

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 博客项目(含getServerSidePropsgetStaticProps+ 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 或新模式。记得添加类型。

拆开来看,它提出了五层递进的约束:

  1. 全量迁移:不是迁移部分页面,而是“every route and file”,包括隐藏文件(_app.js_document.js_error.js)和 API 路由;
  2. 彻底删除pages目录:不允许两套 Router 共存混用,迁移完成后项目中不能残留任何 Pages Router 入口;
  3. API 语义对齐next/headnext/routergetInitialPropsreq/res风格的 API handler 等 Pages Router 专属 API 必须换成 App Router 对应物;
  4. 新模式替换:部分 Pages Router 能力(如getServerSideProps返回props)在 App Router 中没有同名 API,要改写为“async Server Component 内直接取数据”这类新模式;
  5. 类型化:所有产物必须是.ts/.tsx,函数签名、paramschildren等都要有类型——这正是目录名 “hard” 的难度来源:它不是简单改文件名,而是要求同时处理数据获取、路由处理器、Metadata API、'use client'指令放置等多个进阶模式的迁移。

评测环境的工程配置也值得先看一眼。package.json 声明了next: ^16react: 19.1.0typescript: ^5,脚本为标准的dev/build/start(即next devnext buildnext 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 文件/APIApp Router 替代
pages/_app.js+pages/_document.jsapp/layout.tsx(根布局,含html/body/metadata/children
pages/_error.jsgetInitialPropsapp/error.tsx'use client'错误边界)
pages/404.jsapp/not-found.tsx
pages/index.jsgetServerSidePropsapp/page.tsxasync Server Component 内直接fetch
pages/blog/index.jsgetStaticProps+revalidateapp/blog/page.tsx+export const revalidate
pages/blog/[id].jsgetStaticPaths+fallbackapp/blog/[id]/page.tsx+generateStaticParams
pages/api/posts/index.jsapp/api/posts/route.tsGET/POST函数)
pages/api/posts/[id].jsapp/api/posts/[id]/route.tsGET/PUT/DELETE函数)
next/head<Head>export const metadata
next/routeruseRouternext/navigationuseRouter(仅客户端组件)
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”)用正则锁定了四条硬性断言:

  1. <html>标签必须带lang属性(承接_document.jslang="en");
  2. 必须有<body>标签;
  3. 文件内容必须出现metadata/Metadata(承接_document.js<Head>description等站点级 meta);
  4. 必须接收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 langbodymetadatachildren: 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/routeruseRouter(点击按钮router.push('/blog'))。

App Router 中等价的做法是把页面直接写成 async Server Component:在渲染期间执行fetch,请求头改用next/headersheaders()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> ) }

这里有三处关键转换:

  1. reqheaders():Server Component 中不再有请求/响应对象,读请求头一律走next/headers
  2. <Head>metadata导出titledescriptionog:title分别落到titledescriptionopenGraph.title字段;
  3. 客户端交互拆分:带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 revalidateprops则不再需要——页面自己就是 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> ) }

逐点对照:

  1. getStaticPathsgenerateStaticParams:返回值形状一致([{ params }]),函数体内同样先fetchslice(0, 10).map。测试断言该文件必须导出generateStaticParams且为 async Server Component,同时剥离注释后不得出现getStaticPathsgetStaticProps
  2. fallback: 'blocking'的归宿:App Router 没有fallback选项;构建时未覆盖的路径会按需构建,加载态由 React 的Suspense/框架的流式渲染机制承担,组件里原来的router.isFallback分支没有直接等价物,可以移除或改用 Suspense 骨架屏;
  3. notFound: truenotFound():动态导入notFound()并调用即可触发全局not-found.tsx
  4. params是 Promisenext ^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/ResponseNextRequest/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.queryid,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.js404.js。迁移后的统一规则是——页面文件导出metadata(或 async 的generateMetadata),不再 importnext/head

  • 静态标题/描述:export const metadata: Metadata = { title, description, openGraph }
  • 依赖数据的标题(文章详情页的post.title):使用generateMetadata,其params同样是Promise类型;
  • 站点级 meta(原_document.jsdescription、favicon):提升到根布局的metadata导出。

验收测试 “Metadata API replaces next/head” 对app/page.tsxapp/blog/page.tsx各做了双向断言:必须匹配export.*metadata,且不允许出现import.*Head.*next/head<Head>标签。也就是说旧 API 既不能“导入着用”,也不能以 JSX 形式残留。

九、错误处理:_error.js404.jserror.tsx+not-found.tsx

原始错误处理有两个文件:

  • pages/_error.js:接收statusCode,区分 404 与服务端/客户端错误文案,并通过getInitialPropsres.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&apos;re looking for doesn&apos;re exist.</p> <Link href="/">Go Back Home</Link> </div> ) }

对照要点:error.tsxerrorprop 承接了_error.jserrstatusCode不再显式传递,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'且出现errorError的匹配(即接收 error prop),app/not-found.tsx必须存在。另外,_error.jsgetInitialProps写法整体删除——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 HandlerNextRequest/NextResponse参数与RouteContext类型
AppProvider迁移为 TS 时给createContext补上{ theme: string; setTheme: React.Dispatch<React.SetStateAction<string>> }类型,并加'use client'

最终,EVAL.ts 提供了完整的自动化验收清单,7 个用例逐一锁定迁移的“完成”定义:

测试用例核心验证点
Root layout exists and replaces _app/_documentapp/layout.tsx存在;<html lang><body>metadatachildren: ReactNode
Home page migrated to Server Component with async data fetchingapp/page.tsx存在;由 LLM 评审确认是“渲染服务端取回数据的 async Server Component”(不看代码风格,看运行时行为)
Blog index migrated with ISR equivalentasync 页面 +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.jserror.tsx'use client'且接收errorprop;not-found.tsx存在
Client components use next/navigation hooksuseRouter必须来自next/navigation,禁止next/router

这套测试设计本身也有两点值得借鉴:其一,stripComments让“迁移备注注释”与“真实代码残留”被区别对待,正则检查只针对实际执行的代码;其二,对存在多种合法形态的语义性检查(首页的 Server Component 取数)没有硬编码正则,而是交给 LLM 评审,并给评审附上了一份“参考正确形态”——这避免了旧版正则误杀把fetch抽到 helper 的正确解法。

结语

这个 “hard” 迁移任务的全部复杂度,浓缩起来就是三张映射表:Pages 专属 API(getServerSideProps/getStaticProps/getStaticPaths/getInitialProps/req.reshandler)到 App Router 模式(async Server Component、顶层revalidategenerateStaticParams、Route Handler)的替换;next/headnext/routermetadata/next/navigation的导入迁移;以及 JS 文件到带类型.ts/.tsx产物的转换(paramsPromisechildrenReactNode)。按照 PROMPT.md 的要求完成后,pages目录应当被整体移除,项目只保留app目录与客户端组件,并以 EVAL.ts 的 7 个用例作为迁移正确性的可执行定义。

【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询