Menu

如何从 Pages 迁移到 App Router

本指南将帮助你:

升级

Node.js 版本

现在最低 Node.js 版本为 v18.17。有关更多信息,请参阅 Node.js 文档

Next.js 版本

要更新到 Next.js 版本 13,请使用你喜欢的包管理器运行以下命令:

Terminal
npm install next@latest react@latest react-dom@latest

ESLint 版本

如果你使用 ESLint,则需要升级 ESLint 版本:

Terminal
npm install -D eslint-config-next@latest

值得注意的是:你可能需要在 VS Code 中重启 ESLint 服务器以使 ESLint 更改生效。打开命令面板(Mac 上为 cmd+shift+p;Windows 上为 ctrl+shift+p)并搜索 ESLint: Restart ESLint Server

下一步

更新后,请查看以下部分了解下一步操作:

升级新功能

Next.js 13 引入了新的 App Router,带有新功能和约定。新路由器在 app 目录中可用,并与 pages 目录共存。

升级到 Next.js 13 需要使用 App Router。你可以继续使用 pages,同时使用在两个目录中都能工作的新功能,如更新的 Image 组件Link 组件Script 组件Font 优化

<Image/> 组件

Next.js 12 通过临时导入 next/future/image 为 Image 组件引入了新的改进。这些改进包括更少的客户端 JavaScript、更容易扩展和设计图像的方式、更好的可访问性以及原生浏览器懒加载。

在版本 13 中,这种新行为现在是 next/image 的默认设置。

有两个代码模组可以帮助你迁移到新的 Image 组件:

  • next-image-to-legacy-image 代码模组:安全自动地将 next/image 导入重命名为 next/legacy/image。现有组件将保持相同的行为。
  • next-image-experimental 代码模组:危险地添加内联样式并删除未使用的属性。这将更改现有组件的行为以匹配新的默认值。要使用此代码模组,你需要先运行 next-image-to-legacy-image 代码模组。

<Link> 组件不再需要手动添加 <a> 标签作为子元素。此行为在 版本 12.2 中作为实验性选项添加,现在是默认行为。在 Next.js 13 中,<Link> 始终渲染 <a> 并允许你将属性转发到底层标签。

例如:

import Link from 'next/link'
 
// Next.js 12: 必须嵌套 `<a>` 否则会被排除
<Link href="/about">
  <a>About</a>
</Link>
 
// Next.js 13: `<Link>` 始终在底层渲染 `<a>`
<Link href="/about">
  About
</Link>

要将链接升级到 Next.js 13,你可以使用 new-link 代码模组

<Script> 组件

next/script 的行为已更新以同时支持 pagesapp,但需要进行一些更改以确保平稳迁移:

  • 将你之前在 _document.js 中包含的任何 beforeInteractive 脚本移至根布局文件 (app/layout.tsx)。
  • 实验性 worker 策略在 app 中尚不起作用,使用此策略的脚本必须删除或修改为使用不同的策略(例如 lazyOnload)。
  • onLoadonReadyonError 处理程序在 Server Components 中不起作用,因此确保将它们移至 Client Component 或完全删除它们。

字体优化

以前,Next.js 通过 内联字体 CSS 来帮助你优化字体。版本 13 引入了新的 next/font 模块,它使你能够自定义字体加载体验,同时仍然确保良好的性能和隐私。next/fontpagesapp 目录中都受支持。

虽然 内联 CSSpages 中仍然有效,但在 app 中不起作用。你应该改用 next/font

请参阅 字体优化 页面了解如何使用 next/font

pages 迁移到 app

🎥 观看: 了解如何逐步采用 App Router → YouTube(16 分钟)

迁移到 App Router 可能是你第一次使用 Next.js 基于的 React 功能,如 Server Components、Suspense 等。结合 Next.js 的新功能,如 特殊文件布局,迁移意味着需要学习新概念、心智模型和行为变化。

我们建议通过将迁移分解为更小的步骤来降低这些更新的复杂性。app 目录有意设计为可与 pages 目录同时工作,以允许逐页迁移。

  • app 目录支持嵌套路由 布局。了解更多
  • 使用嵌套文件夹定义路由,并使用特殊的 page.js 文件使路由段公开可访问。了解更多
  • 特殊文件约定 用于为每个路由段创建 UI。最常见的特殊文件是 page.jslayout.js
    • 使用 page.js 定义特定于路由的 UI。
    • 使用 layout.js 定义跨多个路由共享的 UI。
    • 特殊文件可以使用 .js.jsx.tsx 文件扩展名。
  • 你可以在 app 目录中放置其他文件,如组件、样式、测试等。了解更多
  • 数据获取函数如 getServerSidePropsgetStaticProps 已被 app 中的 新 API 替代。getStaticPaths 已被 generateStaticParams 替代。
  • pages/_app.jspages/_document.js 已被单个 app/layout.js 根布局替代。了解更多
  • pages/_error.js 已被更精细的 error.js 特殊文件替代。了解更多
  • pages/404.js 已被 not-found.js 文件替代。
  • pages/api/* API 路由已被 route.js(路由处理程序)特殊文件替代。

步骤 1:创建 app 目录

更新到最新的 Next.js 版本(需要 13.4 或更高版本):

npm install next@latest

然后,在项目根目录(或 src/ 目录)创建一个新的 app 目录。

步骤 2:创建根布局

app 目录中创建一个新的 app/layout.tsx 文件。这是一个 根布局,它将应用于 app 中的所有路由。

app/layout.tsx
TypeScript
export default function RootLayout({
  // 布局必须接受一个 children 属性。
  // 这将由嵌套布局或页面填充
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
  • app 目录必须包含根布局。
  • 根布局必须定义 <html><body> 标签,因为 Next.js 不会自动创建它们
  • 根布局替代了 pages/_app.tsxpages/_document.tsx 文件。
  • 布局文件可以使用 .js.jsx.tsx 扩展名。

要管理 <head> HTML 元素,你可以使用 内置 SEO 支持

app/layout.tsx
TypeScript
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'Home',
  description: 'Welcome to Next.js',
}

迁移 _document.js_app.js

如果你有现有的 _app_document 文件,你可以将内容(例如全局样式)复制到根布局 (app/layout.tsx)。app/layout.tsx 中的样式将_不会_应用于 pages/*。你应该在迁移时保留 _app/_document,以防止 pages/* 路由中断。一旦完全迁移,你可以安全地删除它们。

如果你使用任何 React Context 提供者,它们需要移至 Client Component

getLayout() 模式迁移到布局(可选)

Next.js 推荐在 pages 目录中添加 Page 组件的属性 来实现每页布局。这种模式可以用 app 目录中对 嵌套布局 的原生支持来替代。

查看前后示例

之前

components/DashboardLayout.js
export default function DashboardLayout({ children }) {
  return (
    <div>
      <h2>My Dashboard</h2>
      {children}
    </div>
  )
}
pages/dashboard/index.js
import DashboardLayout from '../components/DashboardLayout'
 
export default function Page() {
  return <p>My Page</p>
}
 
Page.getLayout = function getLayout(page) {
  return <DashboardLayout>{page}</DashboardLayout>
}

之后

  • pages/dashboard/index.js 中移除 Page.getLayout 属性,并按照 迁移页面的步骤 迁移到 app 目录。

    app/dashboard/page.js
    export default function Page() {
      return <p>My Page</p>
    }
  • DashboardLayout 的内容移至新的 Client Component 以保留 pages 目录的行为。

    app/dashboard/DashboardLayout.js
    'use client' // 此指令应位于文件顶部,在任何导入之前。
     
    // 这是一个 Client Component
    export default function DashboardLayout({ children }) {
      return (
        <div>
          <h2>My Dashboard</h2>
          {children}
        </div>
      )
    }
  • DashboardLayout 导入到 app 目录中的新 layout.js 文件中。

    app/dashboard/layout.js
    import DashboardLayout from './DashboardLayout'
     
    // 这是一个 Server Component
    export default function Layout({ children }) {
      return <DashboardLayout>{children}</DashboardLayout>
    }
  • 你可以逐步将 DashboardLayout.js(Client Component)中的非交互部分移至 layout.js(Server Component),以减少发送给客户端的组件 JavaScript 数量。

步骤 3:迁移 next/head

pages 目录中,使用 next/head React 组件来管理 <head> HTML 元素,如 titlemeta。在 app 目录中,next/head 被新的 内置 SEO 支持 替代。

之前:

pages/index.tsx
TypeScript
import Head from 'next/head'
 
export default function Page() {
  return (
    <>
      <Head>
        <title>My page title</title>
      </Head>
    </>
  )
}

之后:

app/page.tsx
TypeScript
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'My Page Title',
}
 
export default function Page() {
  return '...'
}

查看所有元数据选项

步骤 4:迁移页面

  • app 目录 中的页面默认是 Server Components。这与 pages 目录不同,后者中的页面是 Client Components
  • app 中的数据获取 已经改变。getServerSidePropsgetStaticPropsgetInitialProps 已被更简单的 API 替代。
  • app 目录使用嵌套文件夹定义路由,并使用特殊的 page.js 文件使路由段公开可访问。
  • pages 目录app 目录路由
    index.jspage.js/
    about.jsabout/page.js/about
    blog/[slug].jsblog/[slug]/page.js/blog/post-1

我们建议将页面迁移分为两个主要步骤:

  • 步骤 1:将默认导出的 Page 组件移至新的 Client Component。
  • 步骤 2:将新的 Client Component 导入到 app 目录中的新 page.js 文件中。

值得注意的是:这是最简单的迁移路径,因为它与 pages 目录的行为最相似。

步骤 1:创建新的 Client Component

  • app 目录中创建一个新的单独文件(例如 app/home-page.tsx 或类似文件),该文件导出一个 Client Component。要定义 Client Components,请在文件顶部(在任何导入之前)添加 'use client' 指令。
    • 与 Pages Router 类似,在初始页面加载时,有一个 优化步骤 将 Client Components 预渲染为静态 HTML。
  • 将默认导出的页面组件从 pages/index.js 移至 app/home-page.tsx
app/home-page.tsx
TypeScript
'use client'
 
// 这是一个 Client Component(与 `pages` 目录中的组件相同)
// 它以 props 形式接收数据,可以访问状态和副作用,并且
// 在初始页面加载期间在服务器上预渲染。
export default function HomePage({ recentPosts }) {
  return (
    <div>
      {recentPosts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}

步骤 2:创建新页面

  • app 目录中创建一个新的 app/page.tsx 文件。这默认是一个 Server Component。

  • home-page.tsx Client Component 导入到页面中。

  • 如果你在 pages/index.js 中获取数据,则使用新的 数据获取 API 将数据获取逻辑直接移至 Server Component 中。有关更多详细信息,请参阅 数据获取升级指南

    app/page.tsx
    TypeScript
    // 导入你的 Client Component
    import HomePage from './home-page'
     
    async function getPosts() {
      const res = await fetch('https://...')
      const posts = await res.json()
      return posts
    }
     
    export default async function Page() {
      // 直接在 Server Component 中获取数据
      const recentPosts = await getPosts()
      // 将获取的数据转发给你的 Client Component
      return <HomePage recentPosts={recentPosts} />
    }
  • 如果你之前的页面使用了 useRouter,你需要更新为新的路由钩子。了解更多

  • 启动你的开发服务器并访问 http://localhost:3000。你应该会看到你现有的索引路由,现在通过 app 目录提供服务。

步骤 5:迁移路由钩子

一个新的路由器已被添加,以支持 app 目录中的新行为。

app 中,你应该使用从 next/navigation 导入的三个新钩子:useRouter()usePathname()useSearchParams()

  • 新的 useRouter 钩子从 next/navigation 导入,行为与从 next/router 导入的 pages 中的 useRouter 钩子不同。
    • next/router 导入的 useRouter 钩子app 目录中不受支持,但可以继续在 pages 目录中使用。
  • 新的 useRouter 不返回 pathname 字符串。使用单独的 usePathname 钩子。
  • 新的 useRouter 不返回 query 对象。搜索参数和动态路由参数现在是分开的。使用 useSearchParamsuseParams 钩子。
  • 你可以一起使用 useSearchParamsusePathname 来监听页面更改。有关更多详细信息,请参阅 路由器事件 部分。
  • 这些新钩子仅在 Client Components 中受支持。它们不能在 Server Components 中使用。
app/example-client-component.tsx
TypeScript
'use client'
 
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 
export default function ExampleClientComponent() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
 
  // ...
}

此外,新的 useRouter 钩子有以下更改:

  • isFallback 已被移除,因为 fallback被替代
  • localelocalesdefaultLocalesdomainLocales 值已被移除,因为在 app 目录中不再需要内置的 i18n Next.js 功能。了解更多关于 i18n
  • basePath 已被移除。替代方案不会成为 useRouter 的一部分。它尚未实现。
  • asPath 已被移除,因为 as 的概念已从新路由器中删除。
  • isReady 已被移除,因为它不再必要。在 静态渲染 期间,任何使用 useSearchParams() 钩子的组件都将跳过预渲染步骤,而是在运行时在客户端上渲染。
  • route 已被移除。usePathnameuseSelectedLayoutSegments() 提供了替代方案。

查看 useRouter() API 参考

pagesapp 之间共享组件

要保持组件在 pagesapp 路由器之间兼容,请参考 next/compat/router 中的 useRouter 钩子。 这是来自 pages 目录的 useRouter 钩子,但旨在在路由器之间共享组件时使用。一旦你准备好仅在 app 路由器上使用它,请更新为来自 next/navigation 的新 useRouter

步骤 6:迁移数据获取方法

pages 目录使用 getServerSidePropsgetStaticProps 为页面获取数据。在 app 目录中,这些先前的数据获取函数被基于 fetch()async React Server Components 构建的更简单的 API 所取代。

app/page.tsx
TypeScript
export default async function Page() {
  // 此请求应该被缓存,直到手动失效。
  // 类似于 `getStaticProps`。
  // `force-cache` 是默认值,可以省略。
  const staticData = await fetch(`https://...`, { cache: 'force-cache' })
 
  // 此请求应该在每次请求时重新获取。
  // 类似于 `getServerSideProps`。
  const dynamicData = await fetch(`https://...`, { cache: 'no-store' })
 
  // 此请求应该缓存 10 秒的生命周期。
  // 类似于带有 `revalidate` 选项的 `getStaticProps`。
  const revalidatedData = await fetch(`https://...`, {
    next: { revalidate: 10 },
  })
 
  return <div>...</div>
}

服务器端渲染(getServerSideProps

pages 目录中,getServerSideProps 用于在服务器上获取数据并将 props 转发给文件中默认导出的 React 组件。页面的初始 HTML 从服务器预渲染,然后在浏览器中"水合"页面(使其具有交互性)。

pages/dashboard.js
// `pages` 目录
 
export async function getServerSideProps() {
  const res = await fetch(`https://...`)
  const projects = await res.json()
 
  return { props: { projects } }
}
 
export default function Dashboard({ projects }) {
  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  )
}

在 App Router 中,我们可以使用 Server Components 在 React 组件内部放置数据获取。这允许我们向客户端发送更少的 JavaScript,同时保持来自服务器的渲染 HTML。

通过将 cache 选项设置为 no-store,我们可以指示获取的数据永不缓存。这类似于 pages 目录中的 getServerSideProps

app/dashboard/page.tsx
TypeScript
// `app` 目录
 
// 这个函数可以命名为任何名称
async function getProjects() {
  const res = await fetch(`https://...`, { cache: 'no-store' })
  const projects = await res.json()
 
  return projects
}
 
export default async function Dashboard() {
  const projects = await getProjects()
 
  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  )
}

访问请求对象

pages 目录中,你可以基于 Node.js HTTP API 检索基于请求的数据。

例如,你可以从 getServerSideProps 检索 req 对象,并使用它来检索请求的 cookies 和 headers。

pages/index.js
// `pages` 目录
 
export async function getServerSideProps({ req, query }) {
  const authHeader = req.getHeaders()['authorization'];
  const theme = req.cookies['theme'];
 
  return { props: { ... }}
}
 
export default function Page(props) {
  return ...
}

app 目录暴露了新的只读函数来检索请求数据:

app/page.tsx
TypeScript
// `app` 目录
import { cookies, headers } from 'next/headers'
 
async function getData() {
  const authHeader = (await headers()).get('authorization')
 
  return '...'
}
 
export default async function Page() {
  // 你可以在 Server Components 内直接使用 `cookies` 或 `headers`
  // 或在你的数据获取函数中使用
  const theme = (await cookies()).get('theme')
  const data = await getData()
  return '...'
}

静态站点生成(getStaticProps

pages 目录中,getStaticProps 函数用于在构建时预渲染页面。此函数可用于从外部 API 或直接从数据库获取数据,并在构建过程中将此数据传递给整个页面。

pages/index.js
// `pages` 目录
 
export async function getStaticProps() {
  const res = await fetch(`https://...`)
  const projects = await res.json()
 
  return { props: { projects } }
}
 
export default function Index({ projects }) {
  return projects.map((project) => <div>{project.name}</div>)
}

app 目录中,使用 fetch() 进行数据获取将默认为 cache: 'force-cache',这将缓存请求数据,直到手动失效。这类似于 pages 目录中的 getStaticProps

app/page.js
// `app` 目录
 
// 这个函数可以命名为任何名称
async function getProjects() {
  const res = await fetch(`https://...`)
  const projects = await res.json()
 
  return projects
}
 
export default async function Index() {
  const projects = await getProjects()
 
  return projects.map((project) => <div>{project.name}</div>)
}

动态路径(getStaticPaths

pages 目录中,getStaticPaths 函数用于定义应在构建时预渲染的动态路径。

pages/posts/[id].js
// `pages` 目录
import PostLayout from '@/components/post-layout'
 
export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
  }
}
 
export async function getStaticProps({ params }) {
  const res = await fetch(`https://.../posts/${params.id}`)
  const post = await res.json()
 
  return { props: { post } }
}
 
export default function Post({ post }) {
  return <PostLayout post={post} />
}

app 目录中,getStaticPathsgenerateStaticParams 替代。

generateStaticParams 的行为类似于 getStaticPaths,但具有更简化的 API 用于返回路由参数,并且可以在 layouts 内使用。generateStaticParams 的返回形状是段的数组,而不是嵌套的 param 对象数组或已解析路径的字符串。

app/posts/[id]/page.js
// `app` 目录
import PostLayout from '@/components/post-layout'
 
export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }]
}
 
async function getPost(params) {
  const res = await fetch(`https://.../posts/${(await params).id}`)
  const post = await res.json()
 
  return post
}
 
export default async function Post({ params }) {
  const post = await getPost(params)
 
  return <PostLayout post={post} />
}

使用名称 generateStaticParams 比在 app 目录的新模型中使用 getStaticPaths 更合适。get 前缀被更具描述性的 generate 所替代,现在不再需要 getStaticPropsgetServerSideProps,它独立存在得更好。Paths 后缀被 Params 替代,这对于具有多个动态段的嵌套路由更合适。

替换 fallback

pages 目录中,从 getStaticPaths 返回的 fallback 属性用于定义在构建时未预渲染的页面的行为。此属性可以设置为 true 以在生成页面时显示回退页面,设置为 false 以显示 404 页面,或设置为 blocking 以在请求时生成页面。

pages/posts/[id].js
// `pages` 目录
 
export async function getStaticPaths() {
  return {
    paths: [],
    fallback: 'blocking'
  };
}
 
export async function getStaticProps({ params }) {
  ...
}
 
export default function Post({ post }) {
  return ...
}

app 目录中,config.dynamicParams 属性 控制如何处理 generateStaticParams 之外的参数:

  • true:(默认)未包含在 generateStaticParams 中的动态段将按需生成。
  • false:未包含在 generateStaticParams 中的动态段将返回 404。

这替代了 pages 目录中 getStaticPathsfallback: true | false | 'blocking' 选项。dynamicParams 不包含 fallback: 'blocking' 选项,因为在流式传输中,'blocking'true 之间的差异可以忽略不计。

app/posts/[id]/page.js
// `app` 目录
 
export const dynamicParams = true;
 
export async function generateStaticParams() {
  return [...]
}
 
async function getPost(params) {
  ...
}
 
export default async function Post({ params }) {
  const post = await getPost(params);
 
  return ...
}

dynamicParams 设置为 true(默认值)时,如果请求了尚未生成的路由段,它将被服务器渲染并缓存。

增量静态再生成(带有 revalidategetStaticProps

pages 目录中,getStaticProps 函数允许你添加 revalidate 字段,以在一定时间后自动重新生成页面。

pages/index.js
// `pages` 目录
 
export async function getStaticProps() {
  const res = await fetch(`https://.../posts`)
  const posts = await res.json()
 
  return {
    props: { posts },
    revalidate: 60,
  }
}
 
export default function Index({ posts }) {
  return (
    <Layout>
      <PostList posts={posts} />
    </Layout>
  )
}

app 目录中,使用 fetch() 进行数据获取可以使用 revalidate,这将缓存请求指定的秒数。

app/page.js
// `app` 目录
 
async function getPosts() {
  const res = await fetch(`https://.../posts`, { next: { revalidate: 60 } })
  const data = await res.json()
 
  return data.posts
}
 
export default async function PostList() {
  const posts = await getPosts()
 
  return posts.map((post) => <div>{post.name}</div>)
}

API 路由

API 路由在 pages/api 目录中继续工作,无需任何更改。但是,它们在 app 目录中已被 Route Handlers 所取代。

Route Handlers 允许你使用 Web RequestResponse API 为给定路由创建自定义请求处理程序。

app/api/route.ts
TypeScript
export async function GET(request: Request) {}

值得注意的是:如果你以前使用 API 路由从客户端调用外部 API,现在你可以使用 Server Components 安全地获取数据。了解更多关于数据获取的信息。

单页应用程序

如果你同时从单页应用程序 (SPA) 迁移到 Next.js,请参阅我们的文档了解更多信息。

步骤 7:样式

pages 目录中,全局样式表仅限于 pages/_app.js。使用 app 目录,此限制已取消。全局样式可以添加到任何布局、页面或组件。

Tailwind CSS

如果你使用 Tailwind CSS,你需要将 app 目录添加到你的 tailwind.config.js 文件中:

tailwind.config.js
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}', // <-- 添加此行
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],
}

你还需要在 app/layout.js 文件中导入全局样式:

app/layout.js
import '../styles/globals.css'
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

了解更多关于使用 Tailwind CSS 的样式

同时使用 App Router 和 Pages Router

在由不同 Next.js 路由器提供服务的路由之间导航时,将进行硬导航。使用 next/link 的自动链接预获取不会跨路由器预获取。

相反,你可以优化导航在 App Router 和 Pages Router 之间,以保留预获取和快速页面转换。了解更多

Codemods

Next.js 提供 Codemod 转换,帮助在功能被弃用时升级你的代码库。有关更多信息,请参见 Codemods