关于 nextjs-advanced-routing
这是一项面向 Next.js App Router 开发者的高级路由编码技能,涵盖 Route Handlers、Server Actions、并行与拦截路由、错误处理、草稿模式及 Suspense 流式传输。
主要功能
- 01创建 Route Handlers
- 02实现 Server Actions
- 03构建并行和拦截路由
- 04处理错误与草稿模式
- 05使用 Suspense 流式传输内容
- 06避免 TypeScript `any`
适用场景
- 01为 App Router 项目添加 API 端点
- 02实现表单提交和服务端变更
- 03创建模态框或抽屉式路由
- 04设置流式响应和错误边界
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wsimmonds/ claude-nextjs-skills --skill nextjs-advanced-routing安装后在 Claude Code 中输入/nextjs-advanced-routing确认已加载。
详细介绍
Next.js 高级路由
概览
全面介绍 Next.js App Router 的高级功能,包括 Route Handlers(API 路由)、并行路由、拦截路由、Server Actions、错误处理、草稿模式,以及使用 Suspense 实现流式传输。
TypeScript:绝不使用 any 类型
关键规则: 此代码库启用了 @typescript-eslint/no-explicit-any。使用 any 会导致构建失败。
❌ 错误:
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ 正确:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
常见的 Next.js 类型模式
// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }
// Server actions
async function myAction(formData: FormData) { ... }
何时使用此技能
以下情况请使用此技能:
- 使用 Route Handlers 创建 API 端点
- 实现并行路由或拦截路由
- 使用 Server Actions 构建表单
- 设置 Cookie 或处理变更
- 创建错误边界
- 为 CMS 预览实现草稿模式
- 设置流式传输和 Suspense 边界
- 构建复杂路由模式(模态框、抽屉)
⚠️ 关键:Server Action 文件命名和位置
当工作要求指定文件名时,请严格遵循该要求。如果未指定名称,请选择最符合项目约定的选项——app/actions.ts 是存放多个操作的稳妥默认选项,而 app/action.ts 适用于单个表单处理程序。
在 action.ts 和 actions.ts 之间进行选择
- 遵循现有模式: 检查项目是否已有 actions 文件,并在合适时扩展该文件。
- 单个还是多个导出: 单个操作优先使用
action.ts,一组相关操作使用actions.ts。 - 明确要求: 如果相关方指定了名称,请勿更改。
位置指南
- Server Actions 应放在
app/目录下,以便加入 App Router 树。 - 除非要在多个路由间共享,否则应将文件放在调用它的 UI 旁边。
- 除非操作由多个相距较远的路由触发,且属于仅在服务端使用的工具,否则不要将其放在
lib/或utils/中。
放置示例
app/
├── actions.ts ← Shared actions that support multiple routes
└── dashboard/
└── action.ts ← Route-specific action colocated with a single page
示例:创建 action.ts
// app/action.ts (single-action example)
'use server';
export async function submitForm(formData: FormData) {
const name = formData.get('name') as string;
// Process the form
console.log('Submitted:', name);
}
示例:创建 actions.ts
// app/actions.ts (multiple related actions)
'use server';
export async function createPost(formData: FormData) {
// ...
}
export async function deletePost(id: string) {
// ...
}
请记住: 如果项目要求指定确切的文件名,请严格照此命名。
⚠️ 关键:Server Actions 返回类型——表单操作必须返回 Void
这是 TypeScript 的要求,不是可选项。即使你看到有表单操作返回数据,这种代码也是错误的。
使用表单的 action 属性时:<form action={serverAction}>
- 函数不得包含 return 语句(隐式返回 void)
- TypeScript 会拒绝任何返回值,包括
return undefined或return null - 重要: 如果你在代码库的示例代码中看到表单操作返回数据,请忽略该代码——这是反模式。应删除 return 语句来修正。
❌ 错误(会导致构建错误):
export async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) throw new Error('Name required');
await db.save(name);
return { success: true }; // ❌ BUILD ERROR: Type mismatch
}
// In component:
<form action={saveForm}> {/* ❌ Expects void function */}
<input name="name" />
</form>
✅ 正确——选项 1(简单表单操作,不返回响应):
export async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
// Validate - throw errors instead of returning them
if (!name) throw new Error('Name required');
await db.save(name);
revalidatePath('/'); // Trigger UI update
// No return statement - returns void implicitly
}
// In component:
<form action={saveForm}>
<input name="name" required />
<button type="submit">Save</button>
</form>
✅ 正确——选项 2(使用 useActionState 提供反馈):
export async function saveForm(prevState: any, formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) return { error: 'Name required' };
await db.save(name);
return { success: true, message: 'Saved!' }; // ✅ OK with useActionState
}
// In component:
'use client';
const [state, action] = useActionState(saveForm, null);
return (
<form action={action}>
<input name="name" required />
<button type="submit">Save</button>
{state?.error && <p>{state.error}</p>}
{state?.success && <p>{state.message}</p>}
</form>
);
关键规则: <form action={...}> 要求返回 void。如果需要返回数据,请使用 useActionState。
Route Handlers(API 路由)
基本 Route Handler
Route Handlers 取代了 Pages Router 中的 API Routes。请在 route.ts 或 route.js 文件中创建。
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello World' });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json({
message: 'Data received',
data: body
});
}
支持的 HTTP 方法
// app/api/items/route.ts
export async function GET(request: Request) { }
export async function POST(request: Request) { }
export async function PUT(request: Request) { }
export async function PATCH(request: Request) { }
export async function DELETE(request: Request) { }
export async function HEAD(request: Request) { }
export async function OPTIONS(request: Request) { }
动态 Route Handlers
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const id = params.id;
const post = await db.posts.findUnique({ where: { id } });
return Response.json(post);
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.posts.delete({ where: { id: params.id } });
return Response.json({ success: true });
}
请求标头和 Cookie
// app/api/profile/route.ts
import { cookies, headers } from 'next/headers';
export async function GET(request: Request) {
// Access headers
const headersList = await headers();
const authorization = headersList.get('authorization');
// Access cookies
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session-token');
if (!sessionToken) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await fetchUser(sessionToken.value);
return Response.json(user);
}
在 Route Handlers 中设置 Cookie
// app/api/login/route.ts
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const { email, password } = await request.json();
const token = await authenticate(email, password);
if (!token) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set('session-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
});
return Response.json({ success: true });
}
CORS 配置
// app/api/public/route.ts
export async function GET(request: Request) {
const data = await fetchData();
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
流式响应
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${i}\n\n`));
await new Promise(resolve => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
Server Actions
Server Actions 支持服务端变更,无需创建 API 端点。
基本 Server Action(不返回值)
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// No return statement - Server Actions with forms should return void
}
注意: 关于返回数据与不返回数据的模式,请参见下方“在表单中使用 Server Actions”一节。
⚠️ 关键:服务端操作文件组织
文件命名规范:
- 当利益相关方指定文件名时(例如“在名为
action.ts的文件中创建服务端操作”),请严格使用指定名称。 - 常见文件名:
action.ts(单数)或actions.ts(复数)——根据需求说明或现有代码选择对应名称。 - 将文件放在合适的目录中:通常是
app/action.ts或app/actions.ts。
'use server' 指令的两种模式:
模式 1:文件级(推荐用于多个操作):
// app/actions.ts
'use server'; // At the top - ALL exports are server actions
export async function createPost(formData: FormData) { ... }
export async function updatePost(formData: FormData) { ... }
export async function deletePost(postId: string) { ... }
模式 2:函数级(用于单个操作或混合文件):
// app/action.ts or any file
export async function createPost(formData: FormData) {
'use server'; // Inside the function - ONLY this function is a server action
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
}
客户端组件调用服务端操作:
当客户端组件需要调用服务端操作时(例如,在 onClick 中或提交表单时):
- 在单独的文件中创建带有 'use server' 指令的服务端操作
- 在客户端组件中导入并使用该操作
✅ 正确模式:
// app/actions.ts - Server Actions file
'use server';
import { cookies } from 'next/headers';
export async function updateUserPreference(key: string, value: string) {
const cookieStore = await cookies();
cookieStore.set(key, value);
// Or perform other server-side operations
await db.userSettings.update({ [key]: value });
}
// app/InteractiveButton.tsx - Client Component
'use client';
import { updateUserPreference } from './actions';
export default function InteractiveButton() {
const handleClick = () => {
updateUserPreference('theme', 'dark');
};
return (
<button onClick={handleClick}>
Update Preference
</button>
);
}
❌ 错误——在同一个文件中混用 'use server' 和 'use client':
// app/CookieButton.tsx
'use client'; // This file is a client component
export async function setCookie() {
'use server'; // ERROR! Can't have server actions in client component file
// ...
}
在表单中使用服务端操作——两种模式
模式 1:简单表单操作(返回 void / 抛出错误)
关键:直接使用表单的 action 属性时,服务端操作必须返回 void(不返回任何内容)。不要返回 { success: true } 或任何对象。
**验证规则:**检查所有输入;如果验证失败,则抛出错误。不要返回错误对象。
⚠️ 重要:即使你看到代码库中的示例代码从表单操作返回 { success: true },也不要照搬这种模式。这是反模式。始终:
- 检查/验证输入
- 如果验证失败则抛出错误(不要返回错误对象)
- 处理请求
- 不返回任何内容(返回 void)
表单操作的正确模式:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate
if (!title || !content) {
throw new Error('Title and content are required');
}
// Save to database
await db.posts.create({ data: { title, content } });
// Revalidate or redirect - no return needed
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
模式 2:使用 useActionState 的表单(返回数据)
如果需要显示成功/错误消息,请使用 useActionState:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
return { success: false, error: 'Title and content required' };
}
const post = await db.posts.create({ data: { title, content } });
return { success: true, post };
}
// app/posts/new/page.tsx
'use client';
import { createPost } from '@/app/actions';
import { useActionState } from 'react';
export default function NewPost() {
const [state, action, isPending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>Post created!</p>}
</form>
);
}
主要区别:
- **模式 1:**仅使用表单操作;服务端操作返回 void,并使用
revalidatePath - **模式 2:**使用
useActionState;服务端操作返回要显示的数据
表单验证示例——多个字段
验证多个必填字段时,请一次性检查所有字段;如果有任何字段缺失,则抛出错误:
'use server';
export async function saveContactMessage(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate all fields - throw if any are missing
if (!name || !email || !message) {
throw new Error('All fields are required');
}
// Save to database
console.log('Saving contact message:', { name, email, message });
// No return - returns void implicitly
}
这样可以:
- ✅ 通过 TypeScript 检查(返回 void)
- ✅ 在处理前验证所有输入
- ✅ 验证失败时抛出错误(阻止保存到数据库)
- ✅ 不返回错误对象(否则会导致表单操作类型错误)
客户端组件中的服务端操作
// app/actions.ts
'use server';
export async function updateUsername(userId: string, username: string) {
await db.users.update({
where: { id: userId },
data: { username },
});
return { success: true };
}
// app/components/UsernameForm.tsx
'use client';
import { updateUsername } from '@/app/actions';
import { useState } from 'react';
export default function UsernameForm({ userId }: { userId: string }) {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
await updateUsername(userId, username);
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="New username"
/>
<button type="submit" disabled={loading}>
{loading ? 'Updating...' : 'Update'}
</button>
</form>
);
}
带验证的服务端操作(抛出错误)
直接使用表单操作时,验证失败应抛出错误(不要返回错误对象):
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validation - throw error if invalid
if (!title || !content) {
throw new Error('Title and content are required');
}
if (title.length > 100) {
throw new Error('Title must be less than 100 characters');
}
if (content.length < 10) {
throw new Error('Content must be at least 10 characters');
}
// Save to database
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// No return - form actions return void
}
**如需返回验证状态:**如果需要返回验证错误或在 UI 中显示错误,请改用 useActionState(上面的模式 2)。
在服务端操作中设置 Cookie
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
export async function setTheme(theme: 'light' | 'dark') {
const cookieStore = await cookies();
cookieStore.set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
});
return { success: true };
}
重新验证和重定向
// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } });
// Revalidate specific path
revalidatePath('/posts');
// Or revalidate by cache tag
revalidateTag('posts');
// Redirect after deletion
redirect('/posts');
}
步骤 0:确定并行路由的范围
在实现并行路由之前,请先确定它们应放在路由结构中的哪个位置。
分析路由上下文
**关键问题:**此功能针对特定页面/板块,还是整个应用?
- 特定页面/板块 → 在该路由目录下创建
- 整个应用 → 在根目录下创建
路由范围决策流程
当需求提到特定功能或页面时:
"Create a [feature-name] with parallel routes for X and Y"
→ Structure: app/[feature-name]/@x/ and app/[feature-name]/@y/
当需求涉及应用级布局时:
"Create an app with parallel routes for X and Y"
→ Structure: app/@x/ and app/@y/
常见的范围错误
❌ 错误——并行路由的范围不正确:
Request: "Create a [specific-feature] with sections for X and Y"
app/
├── @x/ # ❌ Created at root - affects entire app!
├── @y/ # ❌ Wrong scope
└── layout.tsx # ❌ Root layout modified unnecessarily
这会使并行路由成为全局路由,而它们本应仅适用于特定功能。
✅ 正确——并行路由的范围设置恰当:
Request: "Create a [specific-feature] with sections for X and Y"
app/
├── [specific-feature]/
│ ├── @x/ # ✅ Scoped to this feature
│ ├── @y/ # ✅ Only affects this route
│ └── layout.tsx # ✅ Feature-specific layout
└── layout.tsx # Root layout unchanged
决策标准
-
分析需求 - 查找具体的功能/页面名称
- 提到了具体的名词/功能?→ 在
app/[that-feature]/下创建 - 对应用的通用描述?→ 判断应放在根目录还是新路由下
- 提到了具体的名词/功能?→ 在
-
考虑 URL 结构 - 内容应位于哪个 URL?
/feature路径 → 使用app/feature/@slots/- 根路径
/→ 使用app/@slots/ - 嵌套的
/parent/feature→ 使用app/parent/feature/@slots/
-
考虑影响范围 - 会影响应用的哪些部分?
- 仅影响一个功能/页面?→ 限定在功能目录中
- 影响多个相关页面?→ 限定在父目录中
- 影响整个应用?→ 使用根目录
实用示例
示例 1:特定功能的并行路由
Scenario: a user profile page needs tabs for posts and activity
Analysis:
- "user profile page" = specific feature
- Should be at /profile URL
- Only affects profile page
Structure:
app/
├── profile/
│ ├── @posts/
│ │ └── page.tsx
│ ├── @activity/
│ │ └── page.tsx
│ └── layout.tsx # Accepts posts, activity slots
示例 2:全应用范围的并行路由
Scenario: the overall application layout must expose sidebar and main content slots
Analysis:
- "application layout" = root level
- Affects entire app
- Should be at root
Structure:
app/
├── @sidebar/
│ └── page.tsx
├── @main/
│ └── page.tsx
└── layout.tsx # Root layout with slots
示例 3:嵌套分区的并行路由
Scenario: the admin area adds an analytics view with charts and tables
Analysis:
- "admin panel" = existing section
- "analytics view" = subsection
- Should be at /admin/analytics URL
Structure:
app/
├── admin/
│ ├── analytics/
│ │ ├── @charts/
│ │ │ └── page.tsx
│ │ ├── @tables/
│ │ │ └── page.tsx
│ │ └── layout.tsx # Analytics-specific layout
│ └── layout.tsx # Admin layout (unchanged)
快速参考
| 需求模式 | 路由范围 | 示例结构 |
|---|---|---|
| 特定功能的需求 | app/[feature]/ | app/profile/@tab/ |
| 父级区域内的分区 | app/[parent]/[section]/ | app/admin/analytics/@view/ |
| 全应用布局需求 | app/ | app/@sidebar/ |
| 包含多个面板的页面 | app/[page]/ | app/settings/@panel/ |
重要规则: 在默认使用根级并行路由之前,务必先分析需求中的范围指示。
并行路由
并行路由允许在同一布局中同时渲染多个页面。
⚠️ 重要:先了解路由范围
在创建并行路由之前,请先查看上文的“步骤 0:确定并行路由范围”,以确定正确的目录层级。
不要默认在根目录创建并行路由,而应根据需求中提到的功能/页面设置适当的范围。
创建并行路由(限定于功能)
适用于特定功能的并行路由(最常见):
app/
├── [feature-name]/
│ ├── @slot1/
│ │ └── page.tsx
│ ├── @slot2/
│ │ └── page.tsx
│ ├── layout.tsx # Feature layout accepting slot props
│ └── page.tsx # Feature main page
创建并行路由(根级)
适用于全应用范围的并行路由(较少见):
app/
├── @slot1/
│ └── page.tsx
├── @slot2/
│ └── page.tsx
├── layout.tsx # Root layout with slots
└── page.tsx
使用并行路由的布局(限定于功能的示例)
适用于包含并行路由的功能:
// app/[feature]/layout.tsx
export default function FeatureLayout({
children,
slot1,
slot2,
}: {
children: React.ReactNode;
slot1: React.ReactNode;
slot2: React.ReactNode;
}) {
return (
<div>
<h1>Feature Page</h1>
<div className="main">{children}</div>
<div className="slots">
<div className="slot1">{slot1}</div>
<div className="slot2">{slot2}</div>
</div>
</div>
);
}
使用并行路由的布局(根级示例)
适用于全应用范围的并行路由:
// app/layout.tsx
export default function RootLayout({
children,
sidebar,
main,
}: {
children: React.ReactNode;
sidebar: React.ReactNode;
main: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<div className="app-layout">
<aside>{sidebar}</aside>
<main>{main}</main>
{children}
</div>
</body>
</html>
);
}
默认并行路由
创建一个 default.tsx,用于处理未匹配的路由或提供回退 UI:
// Feature-scoped: app/[feature]/@slot1/default.tsx
export default function Default() {
return null; // Or a default UI
}
// Root-level: app/@sidebar/default.tsx
export default function Default() {
return <div>Default sidebar content</div>;
}
条件并行路由
并行路由可以根据运行时条件有选择地渲染:
// app/[feature]/layout.tsx (or any layout with parallel routes)
export default function Layout({
children,
analytics,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
}) {
const showAnalytics = true; // Could be based on user permissions, feature flags, etc.
return (
<div>
<main>{children}</main>
{showAnalytics && <aside>{analytics}</aside>}
</div>
);
}
注意: 此模式适用于任何布局层级(根级或限定于功能)。
拦截路由
拦截路由允许你在保留当前页面上下文的同时,在当前布局中加载一个路由。
拦截路由约定
(.)- 匹配同一层级的路由段(..)- 匹配上一级的路由段(..)(..)- 匹配上两级的路由段(...)- 从根目录匹配路由段
使用拦截路由实现模态框模式
app/
├── photos/
│ ├── [id]/
│ │ └── page.tsx # Full photo page
│ └── page.tsx # Photo gallery
├── @modal/
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx # Modal photo view
└── layout.tsx
模态框模式的布局
// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children}
{modal}
</div>
);
}
模态框组件
// app/@modal/(.)photos/[id]/page.tsx
import Modal from '@/components/Modal';
import PhotoView from '@/components/PhotoView';
export default async function PhotoModal({
params,
}: {
params: { id: string };
}) {
const photo = await getPhoto(params.id);
return (
<Modal>
<PhotoView photo={photo} />
</Modal>
);
}
// app/@modal/default.tsx
export default function Default() {
return null;
}
客户端模态框组件
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
const handleClose = () => {
router.back();
};
return (
<dialog ref={dialogRef} onClose={handleClose}>
<button onClick={handleClose}>Close</button>
{children}
</dialog>
);
}
错误边界
基本错误边界
// app/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
嵌套错误边界
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="dashboard-error">
<h2>Dashboard Error</h2>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}
全局错误边界
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Application Error</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</body>
</html>
);
}
未找到边界
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>Page Not Found</h2>
<p>Could not find requested resource</p>
<Link href="/">Return Home</Link>
</div>
);
}
// Trigger programmatically
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
if (!post) {
notFound();
}
return <div>{post.title}</div>;
}
草稿模式
草稿模式允许你预览无头 CMS 中的草稿内容。
启用草稿模式
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// Check secret
if (secret !== process.env.DRAFT_SECRET) {
return Response.json({ message: 'Invalid token' }, { status: 401 });
}
// Enable Draft Mode
const draft = await draftMode();
draft.enable();
// Redirect to the path from the fetched post
redirect(slug || '/');
}
禁用草稿模式
// app/api/draft/disable/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
const draft = await draftMode();
draft.disable();
redirect('/');
}
在页面中使用草稿模式
// app/posts/[slug]/page.tsx
import { draftMode } from 'next/headers';
export default async function Post({ params }: { params: { slug: string } }) {
const draft = await draftMode();
const isDraft = draft.isEnabled;
// Fetch draft or published content
const post = await getPost(params.slug, isDraft);
return (
<article>
{isDraft && (
<div className="draft-banner">
<p>Draft Mode Active</p>
<a href="/api/draft/disable">Exit Draft Mode</a>
</div>
)}
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
流式传输与 Suspense
使用 Suspense 进行基本流式传输
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<RecentActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
async function Stats() {
const stats = await fetchStats(); // Slow query
return <div className="stats">{JSON.stringify(stats)}</div>;
}
async function RecentActivity() {
const activity = await fetchRecentActivity();
return (
<ul>
{activity.map((item) => (
<li key={item.id}>{item.description}</li>
))}
</ul>
);
}
嵌套 Suspense 边界
// app/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<PageSkeleton />}>
<MainContent />
</Suspense>
</div>
);
}
async function MainContent() {
const data = await fetchMainData();
return (
<div>
<h2>{data.title}</h2>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={data.id} />
</Suspense>
</div>
);
}
async function Comments({ postId }: { postId: string }) {
const comments = await fetchComments(postId);
return (
<ul>
{comments.map((c) => <li key={c.id}>{c.text}</li>)}
</ul>
);
}
使用 loading.tsx 显示加载状态
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="loading-skeleton">
<div className="skeleton-header" />
<div className="skeleton-body" />
</div>
);
}
使用加载 UI 进行流式传输
// app/posts/loading.tsx
export default function PostsLoading() {
return (
<div>
{[1, 2, 3].map((i) => (
<div key={i} className="post-skeleton">
<div className="skeleton-title" />
<div className="skeleton-excerpt" />
</div>
))}
</div>
);
}
高级模式
使用服务器操作进行乐观更新
// app/components/LikeButton.tsx
'use client';
import { useOptimistic } from 'react';
import { likePost } from '@/app/actions';
export default function LikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, amount: number) => state + amount
);
const handleLike = async () => {
addOptimisticLike(1);
await likePost(postId);
};
return (
<button onClick={handleLike}>
Likes: {optimisticLikes}
</button>
);
}
使用表单实现渐进增强
// app/posts/new/page.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
export default function NewPost() {
const [state, formAction] = useFormState(createPost, null);
return (
<form action={formAction}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
{state?.errors && (
<div className="errors">
{Object.entries(state.errors).map(([field, messages]) => (
<div key={field}>
{messages.map((msg) => <p key={msg}>{msg}</p>)}
</div>
))}
</div>
)}
<SubmitButton />
</form>
);
}
总结
- 路由处理程序 -
route.ts文件中的 API 端点,通过导出 HTTP 方法实现 - 服务端操作 - 使用 'use server' 指令执行服务端变更
- 并行路由 - 使用
@folder语法同时渲染多个页面 - 拦截路由 - 使用
(.)语法在上下文中加载路由,用于模态框 - 错误边界 - 使用
error.tsx和global-error.tsx处理错误 - 草稿模式 - 预览来自 CMS 的草稿内容
- 流式渲染 - 使用 Suspense 边界进行渐进式渲染
- Cookie - 在路由处理程序和服务端操作中访问和设置 Cookie
- 重新验证 - 使用
revalidatePath和revalidateTag使缓存失效