引言: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
;
}
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
;
}
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 (
);
}
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
-
{posts.map((post: any) => (
- {post.title}
))}
);
}
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 (
{post.title}
{post.body}
);
}
// 可选:生成静态参数(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 (
);
}
App Router (新) – 正确的拆分方式:
app/contact/page.tsx (Server Component):
// 这是一个 Server Component,负责渲染页面框架
import ContactForm from ‘./contact-form’;
export default function ContactPage() {
return (
Contact Us
Fill out the form below and we’ll get back to you.
);
}
app/contact/contact-form.tsx (Client Component):
‘use client’;
import { useState } from ‘react’;
// 注意:这里不能直接导入服务器端的数据库操作
// 需要通过 API 路由来处理
export default function ContactForm() {
const [name, setName] = useState(”);
const [email, setEmail] = useState(”);
const [message, setMessage] = useState(”);
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>(‘idle’);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus(‘sending’);
try {
const response = await fetch(‘/api/contact’, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify({ name, email, message }),
});
if (response.ok) {
setStatus(‘success’);
// 重置表单
setName(”);
setEmail(”);
setMessage(”);
} else {
setStatus(‘error’);
}
} catch {
setStatus(‘error’);
}
};
return (
);
}
app/api/contact/route.ts:
import { NextResponse } from ‘next/server’;
export async function POST(request: Request) {
try {
const { name, email, message } = await request.json();
// 这里可以执行服务器端逻辑,如保存到数据库
// await db.contacts.create({ name, email, message });
// 或者发送邮件
// await sendEmail({ name, email, message });
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: ‘Failed to process request’ },
{ status: 500 }
);
}
}
4.2 难题二:全局状态管理
问题
: 在 Pages Router 中,我们经常使用 Context API 或 Redux 等全局状态管理方案。在 App Router 中,由于 Server Components 不能使用 Context,需要重新设计。
解决方案
: 将状态管理限制在 Client Components 内,或使用服务器端状态管理方案。
示例:用户认证状态
app/providers.tsx (管理全局状态):
‘use client’;
import { createContext, useContext, useState, useEffect } from ‘react’;
import type { User } from ‘@/types’;
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise
logout: () => void;
loading: boolean;
}
const AuthContext = createContext
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState
const [loading, setLoading] = useState(true);
useEffect(() => {
// 检查本地存储或 cookie
const storedUser = localStorage.getItem(‘user’);
if (storedUser) {
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
const login = async (email: string, password: string) => {
const response = await fetch(‘/api/auth/login’, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
localStorage.setItem(‘user’, JSON.stringify(userData));
} else {
throw new Error(‘Login failed’);
}
};
const logout = () => {
setUser(null);
localStorage.removeItem(‘user’);
fetch(‘/api/auth/logout’, { method: ‘POST’ });
};
return (
{children}
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error(‘useAuth must be used within an AuthProvider’);
}
return context;
}
app/layout.tsx (使用 Provider):
import ‘./global.css’;
import { AuthProvider } from ‘./providers’;
export const metadata = {
title: ‘My App’,
description: ‘Generated by Next.js’,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
);
}
app/dashboard/page.tsx (使用认证状态):
import { redirect } from ‘next/navigation’;
import { AuthProvider } from ‘../providers’;
import DashboardContent from ‘./dashboard-content’;
// 这是一个 Server Component,可以检查认证状态
export default async function DashboardPage() {
// 在实际应用中,这里应该检查服务器端的认证状态
// 例如通过 cookies 或 session
// const user = await getCurrentUser();
// if (!user) {
// redirect(‘/login’);
// }
return (
);
}
app/dashboard/dashboard-content.tsx:
‘use client’;
import { useAuth } from ‘../providers’;
export default function DashboardContent() {
const { user, logout } = useAuth();
if (!user) {
return
;
}
return (
Welcome, {user.name}
);
}
4.3 难题三:路由守卫和中间件
问题
: 在 Pages Router 中,我们经常使用
getServerSideProps
进行路由保护。在 App Router 中,需要新的方式。
解决方案
: 使用中间件(Middleware)或在 Server Components 中进行重定向。
方案一:使用中间件(推荐)
middleware.ts (项目根目录):
import { NextResponse } from ‘next/server’;
import type { NextRequest } from ‘next/server’;
export function middleware(request: NextRequest) {
// 检查受保护的路由
if (request.nextUrl.pathname.startsWith(‘/dashboard’)) {
const token = request.cookies.get(‘auth-token’)?.value;
if (!token) {
// 重定向到登录页
return NextResponse.redirect(new URL(‘/login’, request.url));
}
// 验证 token
// const user = await verifyToken(token);
// if (!user) {
// return NextResponse.redirect(new URL(‘/login’, request.url));
// }
}
return NextResponse.next();
}
export const config = {
matcher: [‘/dashboard/:path*’, ‘/admin/:path*’],
};
方案二:在 Server Component 中重定向
app/dashboard/page.tsx:
import { redirect } from ‘next/navigation’;
import { cookies } from ‘next/headers’;
export default async function DashboardPage() {
const cookieStore = cookies();
const token = cookieStore.get(‘auth-token’)?.value;
if (!token) {
redirect(‘/login’);
}
// 验证 token
// const user = await verifyToken(token);
// if (!user) {
// redirect(‘/login’);
// }
return (
Dashboard
{/* 仪表板内容 */}
);
}
4.4 难题四:数据获取与缓存策略
问题
: Pages Router 使用
getServerSideProps
和
getStaticProps
,而 App Router 使用
fetch
API 和缓存选项。
解决方案
: 理解新的缓存策略并正确应用。
缓存策略详解:
// app/posts/[id]/page.tsx
// 1. 动态渲染(每次请求都重新获取)
export default async function PostPage({ params }: { params: { id: string } }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`, {
cache: ‘no-store’, // 等同于 getServerSideProps
});
const post = await res.json();
return
;
}
// 2. 静态渲染(构建时生成,可重新验证)
export default async function PostPage({ params }: { params: { id: string } }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`, {
next: { revalidate: 60 }, // ISR: 60秒后重新验证
});
const post = await res.json();
return
;
}
// 3. 完全静态(构建时生成,永不重新验证)
export default async function PostPage({ params }: { params: { id: string } }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`, {
next: { revalidate: false }, // 等同于 getStaticProps
});
const post = await res.json();
return
;
}
// 4. 使用 generateStaticParams 进行 SSG
export async function generateStaticParams() {
const posts = await fetch(‘https://api.example.com/posts’).then((res) => res.json());
return posts.map((post: any) => ({ id: post.id.toString() }));
}
4.5 难题五:错误处理和加载状态
问题
: 在 Pages Router 中,错误处理通常在组件内部或通过 Error Boundary 处理。App Router 提供了更细粒度的错误处理。
解决方案
: 使用
error.tsx
和
loading.tsx
。
app/posts/error.tsx (错误边界):
‘use client’;
import { useEffect } from ‘react’;
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 记录错误到日志服务
console.error(‘Posts Error:’, error);
}, [error]);
return (
Something went wrong!
{error.message}
);
}
app/posts/loading.tsx (加载状态):
export default function Loading() {
return (
Loading Posts…
);
}
app/posts/page.tsx (使用错误和加载):
import PostsList from ‘./posts-list’;
export default async function PostsPage() {
// 这个组件会自动使用同目录下的 loading.tsx 和 error.tsx
return
}
app/posts/posts-list.tsx:
async function getPosts() {
const res = await fetch(‘https://jsonplaceholder.typicode.com/posts’);
if (!res.ok) throw new Error(‘Failed to fetch posts’);
return res.json();
}
export default async function PostsList() {
const posts = await getPosts();
return (
-
{posts.map((post: any) => (
-
{post.title}
{post.body.substring(0, 100)}…
))}
);
}
5. 高级迁移技巧
5.1 渐进式迁移:混合使用 Pages 和 App Router
Next.js 13+ 支持在同一个应用中同时使用 Pages Router 和 App Router,这为大型项目提供了平滑迁移路径。
配置 next.config.js:
const nextConfig = {
// 确保两个路由系统可以共存
experimental: {
appDir: true, // 启用 App Router
},
};
module.exports = nextConfig;
目录结构:
app/
# 新的 App Router 页面
dashboard/
page.tsx
pages/
# 旧的 Pages Router 页面(暂时保留)
legacy/
index.tsx
在 App Router 中引用 Pages Router 页面:
// app/legacy-wrapper/page.tsx
import LegacyPage from ‘../../pages/legacy/index’;
export default function LegacyWrapper() {
// 注意:这种方式可能有限制,推荐逐步迁移
return
}
5.2 处理表单提交和 Action
App Router 引入了 Server Actions 的概念,允许在表单中直接调用服务器函数。
app/actions.ts:
‘use server’;
import { redirect } from ‘next/navigation’;
import { cookies } from ‘next/headers’;
export async function loginAction(formData: FormData) {
const email = formData.get(’email’) as string;
const password = formData.get(‘password’) as string;
// 验证逻辑
if (!email || !password) {
return { error: ‘Email and password required’ };
}
// 调用认证服务
const response = await fetch(‘https://api.example.com/login’, {
method: ‘POST’,
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
return { error: ‘Invalid credentials’ };
}
const { token } = await response.json();
// 设置 cookie
cookies().set(‘auth-token’, token, {
httpOnly: true,
secure: process.env.NODE_ENV === ‘production’,
maxAge: 60 * 60 * 24, // 7 days
});
redirect(‘/dashboard’);
}
export async function logoutAction() {
cookies().delete(‘auth-token’);
redirect(‘/login’);
}
app/login/page.tsx (使用 Server Action):
import { loginAction } from ‘../actions’;
export default function LoginPage() {
return (
Login
);
}
5.3 处理第三方库和自定义 Hook
问题
: 许多第三方库(如
react-query
、
swr
、
zustand
)在 Server Components 中无法直接使用。
解决方案
: 将使用这些库的组件标记为 Client Components,并在 Server Components 中组合使用。
示例:使用 React Query
app/providers.tsx:
‘use client’;
import { QueryClient, QueryClientProvider } from ‘@tanstack/react-query’;
import { useState } from ‘react’;
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
{children}
);
}
app/layout.tsx:
import { QueryProvider } from ‘./providers’;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
);
}
app/posts/page.tsx:
import PostsList from ‘./posts-list’;
export default function PostsPage() {
return (
Posts
);
}
app/posts/posts-list.tsx:
‘use client’;
import { useQuery } from ‘@tanstack/react-query’;
export default function PostsList() {
const { data, isLoading, error } = useQuery({
queryKey: [‘posts’],
queryFn: async () => {
const res = await fetch(‘/api/posts’);
return res.json();
},
});
if (isLoading) return
;
if (error) return
;
return (
-
{data?.map((post: any) => (
- {post.title}
))}
);
}
6. 测试和验证
6.1 单元测试
使用 Jest 和 React Testing Library:
// __tests__/posts-list.test.tsx
import { render, screen } from ‘@testing-library/react’;
import PostsList from ‘@/app/posts/posts-list’;
// 模拟 fetch
global.fetch = jest.fn();
describe(‘PostsList’, () => {
it(‘renders posts’, async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => [{ id: 1, title: ‘Test Post’ }],
});
const postsList = await PostsList();
render(postsList);
expect(screen.getByText(‘Test Post’)).toBeInTheDocument();
});
});
6.2 端到端测试
使用 Playwright:
// e2e/posts.spec.ts
import { test, expect } from ‘@playwright/test’;
test(‘should navigate to post detail’, async ({ page }) => {
await page.goto(‘/posts’);
await page.click(‘text=Test Post’);
await expect(page).toHaveURL(/\/posts\/\d+/);
await expect(page.getByRole(‘heading’)).toContainText(‘Test Post’);
});
7. 性能优化最佳实践
7.1 减少客户端 JavaScript
原则
: 尽可能使用 Server Components,只在必要时使用 Client Components。
优化前:
// app/page.tsx – 不必要的 Client Component
‘use client’;
export default function Home() {
return
Welcome
;
}
优化后:
// app/page.tsx – Server Component
export default function Home() {
return
Welcome
;
}
7.2 使用 Streaming 和 Suspense
app/posts/page.tsx:
import { Suspense } from ‘react’;
import PostsList from ‘./posts-list’;
import PostStats from ‘./post-stats’;
export default function PostsPage() {
return (
Posts
}>