Next.js 13 App Router 全面升级指南 从 Pages 到 App 模式如何快速上手并解决常见迁移难题

引言:Next.js 13 的范式转变
Next.js 13 引入了 App Router(应用路由)作为新的默认路由机制,这是一个颠覆性的变化,它彻底改变了我们构建 React 应用的方式。与传统的 Pages Router(基于文件系统的页面路由)相比,App Router 基于 React Server Components (RSC) 构建,提供了更强大的服务器端渲染能力、更细粒度的数据获取和更优化的性能表现。
对于现有的 Pages Router 项目,迁移到 App Router 不是一个简单的文件重命名过程,而是一个需要理解新概念、重构组件和调整数据流的系统性工程。本指南将带你从零开始,深入理解 App Router 的核心概念,提供详细的迁移步骤,并针对迁移过程中的常见难题给出解决方案。
1. 理解 App Router 的核心概念
在开始迁移之前,我们必须先理解 App Router 与 Pages Router 的根本区别。
1.1 目录结构的变化
Pages Router (传统模式):
pages/
index.js # 首页
about.js # 关于页面
posts/
[id].js # 动态路由:/posts/123
index.js # /posts
App Router (新模式):
app/
layout.tsx # 根布局
page.tsx # 首页
about/
page.tsx # 关于页面
posts/
[id]/
page.tsx # 动态路由:/posts/123
layout.tsx # posts 分组的布局
page.tsx # /posts
关键变化:
layout.tsx
: 定义共享 UI,支持嵌套
page.tsx
: 定义页面内容
路由段
: 每个文件夹都是一个路由段
文件扩展名
: 推荐使用
.tsx
/
.ts
(TypeScript)
1.2 React Server Components (RSC) vs Client Components
这是 App Router 最核心的概念。默认情况下,所有组件都是
Server Components
,它们:
在服务器上渲染,不打包到客户端 JavaScript
可以直接访问后端资源(文件系统、数据库、API)
没有 React Hooks(useState, useEffect 等)
不能使用浏览器 API
Client Components
需要显式声明:
// app/some-component.tsx
‘use client’; // 必须在文件顶部

import { useState } from ‘react’;

export default function SomeComponent() {
const [count, setCount] = useState(0);
return ;
}
1.3 数据获取方式的改变
Pages Router:
// pages/posts/[id].tsx
export async function getServerSideProps(context) {
const res = await fetch(`https://api.example.com/posts/${context.params.id}`);
const post = await res.json();
return { props: { post } };
}

export default function Post({ post }) {
return

{post.title}

;
}
App Router:
// app/posts/[id]/page.tsx
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
cache: ‘no-store’ // 或者 ‘force-cache’
});
return res.json();
}

export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return

{post.title}

;
}
2. 迁移前的准备工作
2.1 环境升级
首先确保你的开发环境满足要求:
# 升级 Next.js 到 13+
npm install next@latest react@latest react-dom@latest

# 如果使用 TypeScript,确保类型定义
npm install -D @types/react @types/react-dom

# 安装 ESLint 插件(可选但推荐)
npm install -D eslint-config-next
2.2 配置文件更新
next.config.js:
/** @type {import(‘next’).NextConfig} */
const nextConfig = {
// 如果你暂时不想使用 App Router,可以显式禁用
// experimental: {
// appDir: false,
// },

// 推荐启用 TypeScript 和 ESLint
typescript: {
ignoreBuildErrors: false,
},
eslint: {
ignoreDuringBuildErrors: false,
},
};

module.exports = nextConfig;
tsconfig.json:
{
“compilerOptions”: {
“target”: “ES2017”,
“lib”: [“dom”, “dom.iterable”, “esnext”],
“allowJs”: true,
“skipLibCheck”: true,
“strict”: true,
“noEmit”: true,
“esModuleInterop”: true,
“module”: “esnext”,
“moduleResolution”: “bundler”,
“resolveJsonModule”: true,
“isolatedModules”: true,
“jsx”: “preserve”,
“incremental”: true,
“plugins”: [
{
“name”: “next”
}
],
“baseUrl”: “.”,
“paths”: {
“@/*”: [“./*”]
}
},
“include”: [“next-env.d.ts”, “**/*.ts”, “**/*.tsx”, “.next/types/**/*.ts”],
“exclude”: [“node_modules”]
}
2.3 规划迁移策略
对于大型项目,建议采用
渐进式迁移
策略:
并行运行
: 保持现有 Pages Router,同时在
app/
目录下创建新页面
逐步迁移
: 从叶子页面开始,逐步向上迁移
共享组件
: 识别可复用的组件,优先转换为 Client Components
测试覆盖
: 确保每个迁移的页面都有充分的测试
3. 核心迁移步骤详解
3.1 创建基础 App 结构
首先创建 App Router 的基础骨架:
app/
layout.tsx # 根布局
page.tsx # 首页
error.tsx # 错误边界
loading.tsx # 加载状态
not-found.tsx # 404 页面
global.css # 全局样式
app/layout.tsx:
import ‘./global.css’;

export const metadata = {
title: ‘My App’,
description: ‘Generated by Next.js’,
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (

{children}

);
}
app/page.tsx:
export default function HomePage() {
return (

Welcome to the new App Router

This is the new home page using App Router

);
}
3.2 迁移动态路由
假设我们有一个 Posts 系统,包含列表页和详情页。
Pages Router:
pages/
posts/
index.tsx # /posts
[id].tsx # /posts/123
App Router:
app/
posts/
layout.tsx # Posts 共享布局
page.tsx # /posts
[id]/
page.tsx # /posts/123
app/posts/page.tsx (列表页):
// 这是一个 Server Component,可以直接获取数据
async function getPosts() {
const res = await fetch(‘https://jsonplaceholder.typicode.com/posts’, {
next: { revalidate: 60 }, // ISR: 60秒重新验证
});
return res.json();
}

export default async function PostsPage() {
const posts = await getPosts();

return (

All Posts

);
}
app/posts/[id]/page.tsx (详情页):
// 动态路由参数通过 params 传入
export default async function PostPage({
params
}: {
params: { id: string }
}) {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${params.id}`
);
const post = await res.json();

return (

);
}

// 可选:生成静态参数(SSG)
export async function generateStaticParams() {
const posts = await fetch(‘https://jsonplaceholder.typicode.com/posts’).then(
(res) => res.json()
);

return posts.map((post: any) => ({
id: post.id.toString(),
}));
}
3.3 处理 API 路由
Pages Router:
pages/
api/
posts.ts
App Router:
app/
api/
posts/
route.ts
app/api/posts/route.ts:
import { NextResponse } from ‘next/server’;

export async function GET() {
// 处理 GET 请求
const posts = await fetch(‘https://jsonplaceholder.typicode.com/posts’).then(
(res) => res.json()
);
return NextResponse.json(posts);
}

export async function POST(request: Request) {
// 处理 POST 请求
const body = await request.json();
// 保存到数据库…
return NextResponse.json({ message: ‘Post created’, data: body });
}
3.4 处理静态资源
Pages Router:
public/
images/
logo.png
App Router:
app/
favicon.ico
icon.png
opengraph-image.png
twitter-image.png
public/
images/
logo.png
App Router 支持自动元数据图像:
icon.png
– 应用图标
opengraph-image.png
– 社交分享图
twitter-image.png
– Twitter 分享图
4. 解决常见迁移难题
4.1 难题一:客户端组件与服务器组件的边界
问题
: 原有的 Pages Router 组件可能混合了服务器端和客户端逻辑,直接迁移会导致错误。
解决方案
: 明确组件边界,使用
‘use client’
指令。
示例:一个复杂的表单组件
Pages Router (旧):
// pages/contact.tsx
import { useState } from ‘react’;
import { sendEmail } from ‘../lib/email’;

export default function ContactPage() {
const [name, setName] = useState(”);
const [email, setEmail] = useState(”);
const [message, setMessage] = useState(”);
const [status, setStatus] = useState(‘idle’);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus(‘sending’);
try {
await sendEmail({ name, email, message });
setStatus(‘success’);
} catch {
setStatus(‘error’);
}
};

return (

setName(e.target.value)} />
setEmail(e.target.value)} />