nextjs-advanced-routing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Next.js Advanced Routing

Next.js 高级路由指南

Overview

概述

Provide comprehensive guidance for advanced Next.js App Router features including Route Handlers (API routes), Parallel Routes, Intercepting Routes, Server Actions, error handling, draft mode, and streaming with Suspense.
提供Next.js App Router高级功能的全面指导,包括Route Handlers(API路由)、Parallel Routes、Intercepting Routes、Server Actions、错误处理、草稿模式以及结合Suspense的流式渲染。

TypeScript: NEVER Use
any
Type

TypeScript:切勿使用
any
类型

CRITICAL RULE: This codebase has
@typescript-eslint/no-explicit-any
enabled. Using
any
will cause build failures.
❌ WRONG:
typescript
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ CORRECT:
typescript
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
重要规则: 此代码库已启用
@typescript-eslint/no-explicit-any
规则。使用
any
类型会导致构建失败。
❌ 错误示例:
typescript
function handleSubmit(e: any) { ... }
const data: any[] = [];
✅ 正确示例:
typescript
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];

Common Next.js Type Patterns

常见Next.js TypeScript模式

typescript
// 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) { ... }
typescript
// 页面Props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }

// 表单事件
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }

// Server Actions
async function myAction(formData: FormData) { ... }

When to Use This Skill

适用场景

Use this skill when:
  • Creating API endpoints with Route Handlers
  • Implementing parallel or intercepting routes
  • Building forms with Server Actions
  • Setting cookies or handling mutations
  • Creating error boundaries
  • Implementing draft mode for CMS previews
  • Setting up streaming and Suspense boundaries
  • Building complex routing patterns (modals, drawers)
在以下场景中使用本指南:
  • 使用Route Handler创建API端点
  • 实现并行路由或拦截路由
  • 结合Server Actions构建表单
  • 设置Cookie或处理数据变更
  • 创建错误边界
  • 为CMS预览实现草稿模式
  • 配置流式渲染与Suspense边界
  • 构建复杂路由模式(模态框、抽屉组件)

⚠️ CRITICAL: Server Action File Naming and Location

⚠️ 重要提示:Server Action文件命名与位置

When work requirements mention a specific filename, follow that instruction exactly. If no name is given, pick the option that best matches the project conventions—
app/actions.ts
is a safe default for collections of actions, while
app/action.ts
works for a single form handler.
当工作需求指定具体文件名时,严格按照要求执行。若未指定文件名,选择与项目约定最匹配的选项——
app/actions.ts
适用于多个Action的集合,而
app/action.ts
适用于单个表单处理器。

Choosing between
action.ts
and
actions.ts

action.ts
actions.ts
的选择

  • Match existing patterns: Check whether the project already has an actions file and extend it if appropriate.
  • Single vs multiple exports: Prefer
    action.ts
    for a single action, and
    actions.ts
    for a group of related actions.
  • Explicit requirement: If stakeholders call out a specific name, do not change it.
Location guidelines
  • Server actions belong under the
    app/
    directory so they can participate in the App Router tree.
  • Keep the file alongside the UI that invokes it unless shared across multiple routes.
  • Avoid placing actions in
    lib/
    or
    utils/
    unless they are triggered from multiple distant routes and remain server-only utilities.
Example placement
app/
├── actions.ts       ← Shared actions that support multiple routes
└── dashboard/
    └── action.ts    ← Route-specific action colocated with a single page
  • 匹配现有模式: 检查项目中是否已有actions文件,若有则扩展该文件
  • 单个与多个导出: 单个Action优先使用
    action.ts
    ,多个相关Action优先使用
    actions.ts
  • 明确要求: 若利益相关方指定了具体名称,请勿修改
位置规范
  • Server Action应放在
    app/
    目录下,以便参与App Router的路由树
  • 除非需在多个路由间共享,否则将文件与调用它的UI组件放在同一目录
  • 除非是从多个分散路由触发的纯服务器工具函数,否则避免将Action放在
    lib/
    utils/
    目录
示例结构
app/
├── actions.ts       ← 支持多个路由的共享Action
└── dashboard/
    └── action.ts    ← 与单个页面共存的路由专属Action

Example: Creating action.ts

示例:创建action.ts

typescript
// 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);
}
typescript
// app/action.ts(单个Action示例)
'use server';

export async function submitForm(formData: FormData) {
  const name = formData.get('name') as string;
  // 处理表单
  console.log('已提交:', name);
}

Example: Creating actions.ts

示例:创建actions.ts

typescript
// app/actions.ts (multiple related actions)
'use server';

export async function createPost(formData: FormData) {
  // ...
}

export async function deletePost(id: string) {
  // ...
}
Remember: When a project requirement spells out an exact filename, mirror it precisely.
typescript
// app/actions.ts(多个相关Action示例)
'use server';

export async function createPost(formData: FormData) {
  // ...
}

export async function deletePost(id: string) {
  // ...
}
注意: 当项目需求明确指定文件名时,必须完全匹配。

⚠️ CRITICAL: Server Actions Return Types - Form Actions MUST Return Void

⚠️ 重要提示:Server Action返回类型 - 表单Action必须返回Void

This is a TypeScript requirement, not optional. Even if you see code that returns data from form actions, that code is WRONG.
When using form action attribute:
<form action={serverAction}>
  • The function MUST have no return statement (implicitly returns void)
  • TypeScript will REJECT any return value, even
    return undefined
    or
    return null
  • IMPORTANT: If you see example code in the codebase that returns data from a form action, ignore it - it's an anti-pattern. Fix it by removing the return statement.
❌ WRONG (causes build error):
typescript
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>
✅ CORRECT - Option 1 (Simple form action, no response):
typescript
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>
✅ CORRECT - Option 2 (With useActionState for feedback):
typescript
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>
);
The key rule:
<form action={...}>
expects
void
. If you need to return data, use
useActionState
.
这是TypeScript的强制要求,而非可选规则。即使你看到有表单Action返回数据的代码,那也是错误的。
当使用表单的action属性时:
<form action={serverAction}>
  • 函数必须没有return语句(隐式返回void)
  • TypeScript会拒绝任何返回值,即使是
    return undefined
    return null
  • 重点: 若你在代码库中看到表单Action返回数据的示例代码,请忽略它——这是反模式。应通过移除return语句来修复。
❌ 错误示例(会导致构建错误):
typescript
export async function saveForm(formData: FormData) {
  'use server';

  const name = formData.get('name') as string;
  if (!name) throw new Error('名称为必填项');

  await db.save(name);
  return { success: true }; // ❌ 构建错误:类型不匹配
}

// 在组件中:
<form action={saveForm}>  {/* ❌ 期望返回void的函数 */}
  <input name="name" />
</form>
✅ 正确示例 - 选项1(简单表单Action,无返回值):
typescript
export async function saveForm(formData: FormData) {
  'use server';

  const name = formData.get('name') as string;

  // 验证 - 抛出错误而非返回错误
  if (!name) throw new Error('名称为必填项');

  await db.save(name);
  revalidatePath('/'); // 触发UI更新
  // 无return语句 - 隐式返回void
}

// 在组件中:
<form action={saveForm}>
  <input name="name" required />
  <button type="submit">保存</button>
</form>
✅ 正确示例 - 选项2(结合useActionState实现反馈):
typescript
export async function saveForm(prevState: any, formData: FormData) {
  'use server';

  const name = formData.get('name') as string;
  if (!name) return { error: '名称为必填项' };

  await db.save(name);
  return { success: true, message: '保存成功!' }; // ✅ 结合useActionState时允许返回数据
}

// 在组件中:
'use client';
const [state, action] = useActionState(saveForm, null);

return (
  <form action={action}>
    <input name="name" required />
    <button type="submit">保存</button>
    {state?.error && <p>{state.error}</p>}
    {state?.success && <p>{state.message}</p>}
  </form>
);
核心规则:
<form action={...}>
期望函数返回void。若需要返回数据,请使用
useActionState

Route Handlers (API Routes)

Route Handlers(API路由)

Basic Route Handler

基础Route Handler

Route Handlers replace API Routes from the Pages Router. Create them in
route.ts
or
route.js
files.
typescript
// 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
  });
}
Route Handler替代了Pages Router中的API路由。在
route.ts
route.js
文件中创建。
typescript
// 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: body
  });
}

Supported HTTP Methods

支持的HTTP方法

typescript
// 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) { }
typescript
// 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) { }

Dynamic Route Handlers

动态Route Handler

typescript
// 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 });
}
typescript
// 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 });
}

Request Headers and Cookies

请求头与Cookie

typescript
// 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);
}
typescript
// app/api/profile/route.ts
import { cookies, headers } from 'next/headers';

export async function GET(request: Request) {
  // 获取请求头
  const headersList = await headers();
  const authorization = headersList.get('authorization');

  // 获取Cookie
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('session-token');

  if (!sessionToken) {
    return Response.json({ error: '未授权' }, { status: 401 });
  }

  const user = await fetchUser(sessionToken.value);

  return Response.json(user);
}

Setting Cookies in Route Handlers

在Route Handler中设置Cookie

typescript
// 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 });
}
typescript
// 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: '无效凭据' }, { status: 401 });
  }

  // 设置Cookie
  const cookieStore = await cookies();
  cookieStore.set('session-token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 7, // 1周
    path: '/',
  });

  return Response.json({ success: true });
}

CORS Configuration

CORS配置

typescript
// 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',
    },
  });
}
typescript
// 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',
    },
  });
}

Streaming Responses

流式响应

typescript
// 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',
    },
  });
}
typescript
// 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

Server Actions enable server-side mutations without creating API endpoints.
Server Actions允许在不创建API端点的情况下实现服务端数据变更。

Basic Server Action (Without Return)

基础Server Action(无返回值)

typescript
// 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
}
Note: See "Using Server Actions in Forms" section below for patterns that return data vs. those that don't.
typescript
// 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');
  // 无return语句 - 结合表单的Server Action应返回void
}
注意: 请参考下方"在表单中使用Server Action"章节,了解返回数据与不返回数据的不同模式。

⚠️ CRITICAL: Server Actions File Organization

⚠️ 重要提示:Server Action文件组织

File Naming Precision:
  • When stakeholders specify a filename (e.g., “create a server action in a file called
    action.ts
    ”), mirror it exactly.
  • Common filenames:
    action.ts
    (singular) or
    actions.ts
    (plural)—choose the one that matches the brief or existing code.
  • Place the file in the appropriate directory: typically
    app/action.ts
    or
    app/actions.ts
    .
Two Patterns for 'use server' Directive:
Pattern 1: File-level (recommended for multiple actions):
typescript
// 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) { ... }
Pattern 2: Function-level (for single action or mixed file):
typescript
// 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 } });
}
Client Component Calling Server Action:
When a client component needs to call a server action (e.g., onClick, form submission):
  1. Create the server action in a SEPARATE file with 'use server' directive
  2. Import and use it in the client component
✅ CORRECT Pattern:
typescript
// 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>
  );
}
❌ WRONG - Mixing 'use server' and 'use client' in same file:
typescript
// 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
  // ...
}
文件名精度:
  • 当利益相关方指定文件名时(例如:"在名为
    action.ts
    的文件中创建Server Action"),严格匹配该名称
  • 常见文件名:
    action.ts
    (单数)或
    actions.ts
    (复数)——选择与需求或现有代码匹配的名称
  • 将文件放在合适的目录:通常为
    app/action.ts
    app/actions.ts
'use server'指令的两种模式:
模式1:文件级(推荐用于多个Action):
typescript
// app/actions.ts
'use server';  // 文件顶部声明 - 所有导出均为Server Action

export async function createPost(formData: FormData) { ... }
export async function updatePost(formData: FormData) { ... }
export async function deletePost(postId: string) { ... }
模式2:函数级(用于单个Action或混合文件):
typescript
// app/action.ts或其他文件
export async function createPost(formData: FormData) {
  'use server';  // 函数内部声明 - 仅该函数为Server Action

  const title = formData.get('title') as string;
  await db.posts.create({ data: { title } });
}
客户端组件调用Server Action:
当客户端组件需要调用Server Action时(例如:点击事件、表单提交):
  1. 在单独的文件中创建带有'use server'指令的Server Action
  2. 在客户端组件中导入并使用它
✅ 正确模式:
typescript
// app/actions.ts - Server Action文件
'use server';

import { cookies } from 'next/headers';

export async function updateUserPreference(key: string, value: string) {
  const cookieStore = await cookies();
  cookieStore.set(key, value);

  // 或执行其他服务端操作
  await db.userSettings.update({ [key]: value });
}

// app/InteractiveButton.tsx - 客户端组件
'use client';

import { updateUserPreference } from './actions';

export default function InteractiveButton() {
  const handleClick = () => {
    updateUserPreference('theme', 'dark');
  };

  return (
    <button onClick={handleClick}>
      更新偏好设置
    </button>
  );
}
❌ 错误模式 - 在同一文件中混合'use server'与'use client':
typescript
// app/CookieButton.tsx
'use client';  // 该文件为客户端组件

export async function setCookie() {
  'use server';  // 错误!客户端组件文件中不能包含Server Action
  // ...
}

Using Server Actions in Forms - Two Patterns

在表单中使用Server Action - 两种模式

Pattern 1: Simple Form Action (Returns void / Throws Errors)

模式1:简单表单Action(返回void / 抛出错误)

CRITICAL: When using form
action
attribute directly, the Server Action MUST return void (nothing). Do NOT return
{ success: true }
or any object.
VALIDATION RULE: Check all inputs and throw errors if validation fails. Do NOT return error objects.
⚠️ IMPORTANT: Even if you see example code in the codebase that returns
{ success: true }
from a form action, do NOT copy that pattern. That code is an anti-pattern. Always:
  1. Check/validate inputs
  2. Throw errors if validation fails (don't return error objects)
  3. Process the request
  4. Don't return anything (return void)
Correct pattern for form actions:
typescript
// 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>
  );
}
重要提示: 当直接使用表单的action属性时,Server Action必须返回void(无任何返回值)。请勿返回
{ success: true }
或任何对象。
验证规则: 检查所有输入项,若验证失败则抛出错误。请勿返回错误对象。
⚠️ 重点: 即使你在代码库中看到表单Action返回
{ success: true }
的示例代码,请勿复制该模式。这是反模式。应始终遵循以下步骤:
  1. 检查/验证输入项
  2. 若验证失败则抛出错误(而非返回错误对象)
  3. 处理请求
  4. 不返回任何内容(返回void)
表单Action的正确模式:
typescript
// 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) {
    throw new Error('标题与内容为必填项');
  }

  // 保存到数据库
  await db.posts.create({ data: { title, content } });

  // 重新验证路径或重定向 - 无需返回值
  revalidatePath('/posts');
}

// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="标题" required />
      <textarea name="content" placeholder="内容" required />
      <button type="submit">创建文章</button>
    </form>
  );
}

Pattern 2: Form with useActionState (Returns data)

模式2:结合useActionState的表单(返回数据)

When you need to display success/error messages, use
useActionState
:
typescript
// 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>
  );
}
Key difference:
  • Pattern 1: Form action only, Server Action returns void, use
    revalidatePath
  • Pattern 2: With
    useActionState
    , Server Action returns data for display
当需要显示成功/错误提示时,使用
useActionState
typescript
// 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: '标题与内容为必填项' };
  }

  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="标题" required />
      <textarea name="content" placeholder="内容" required />
      <button type="submit" disabled={isPending}>
        {isPending ? '创建中...' : '创建文章'}
      </button>
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state?.success && <p style={{ color: 'green' }}>文章创建成功!</p>}
    </form>
  );
}
核心区别:
  • 模式1: 仅作为表单Action,Server Action返回void,使用
    revalidatePath
  • 模式2: 结合
    useActionState
    ,Server Action返回数据用于UI展示

Form Validation Example - Multiple Fields

多字段表单验证示例

When validating multiple required fields, check them all together and throw if any are missing:
typescript
'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
}
This will:
  1. ✅ Pass TypeScript checks (returns void)
  2. ✅ Validate all inputs before processing
  3. ✅ Throw error if validation fails (prevents database save)
  4. ✅ Not return an error object (which would break form action typing)
验证多个必填字段时,同时检查所有字段,若有任何字段缺失则抛出错误:
typescript
'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;

  // 验证所有字段 - 若有缺失则抛出错误
  if (!name || !email || !message) {
    throw new Error('所有字段均为必填项');
  }

  // 保存到数据库
  console.log('保存联系消息:', { name, email, message });

  // 无返回值 - 隐式返回void
}
该示例将:
  1. ✅ 通过TypeScript检查(返回void)
  2. ✅ 在处理前验证所有输入项
  3. ✅ 若验证失败则抛出错误(阻止数据库保存)
  4. ✅ 不返回错误对象(避免破坏表单Action的类型约束)

Server Actions with Client Components

客户端组件调用Server Action

typescript
// 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>
  );
}
typescript
// 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="新用户名"
      />
      <button type="submit" disabled={loading}>
        {loading ? '更新中...' : '更新'}
      </button>
    </form>
  );
}

Server Actions with Validation (Throw Errors)

带验证的Server Action(抛出错误)

When using form actions directly, throw errors for validation failures (don't return error objects):
typescript
// 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
}
For returning validation state: If you need to return validation errors or show them in the UI, use
useActionState
(Pattern 2 above) instead.
当直接使用表单Action时,验证失败时抛出错误(而非返回错误对象):
typescript
// 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;

  // 验证 - 无效则抛出错误
  if (!title || !content) {
    throw new Error('标题与内容为必填项');
  }

  if (title.length > 100) {
    throw new Error('标题长度不能超过100个字符');
  }

  if (content.length < 10) {
    throw new Error('内容长度至少为10个字符');
  }

  // 保存到数据库
  const post = await db.posts.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  // 无返回值 - 表单Action返回void
}
返回验证状态: 若需要返回验证错误或在UI中展示,请使用上述的模式2(结合
useActionState
)。

Setting Cookies in Server Actions

在Server Action中设置Cookie

typescript
// 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 };
}
typescript
// 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年
    path: '/',
  });

  return { success: true };
}

Revalidation and Redirection

重新验证与重定向

typescript
// 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');
}
typescript
// 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 } });

  // 重新验证指定路径
  revalidatePath('/posts');

  // 或通过缓存标签重新验证
  revalidateTag('posts');

  // 删除后重定向
  redirect('/posts');
}

Step 0: Determine Parallel Route Scope

步骤0:确定并行路由的作用域

Before implementing parallel routes, identify WHERE they should live in your route structure.
在实现并行路由之前,先确定它们在路由结构中的位置。

Analyzing Route Context

分析路由上下文

Key Question: Is this feature for a specific page/section, or for the entire application?
  • Specific page/section → Create under that route directory
  • Entire application → Create at root level
核心问题: 该功能是针对特定页面/模块,还是整个应用?
  • 特定页面/模块 → 在该路由目录下创建
  • 整个应用 → 在根目录下创建

Route Scope Decision Process

路由作用域决策流程

When the requirement mentions a specific feature or page:
"Create a [feature-name] with parallel routes for X and Y"
→ Structure: app/[feature-name]/@x/ and app/[feature-name]/@y/
When the requirement covers app-wide layout:
"Create an app with parallel routes for X and Y"
→ Structure: app/@x/ and app/@y/
当需求提到特定功能或页面时:
"为X和Y创建带有并行路由的[功能名称]"
→ 结构:app/[feature-name]/@x/ 和 app/[feature-name]/@y/
当需求涉及应用全局布局时:
"为X和Y创建带有并行路由的应用"
→ 结构:app/@x/ 和 app/@y/

Common Scope Mistake

常见作用域错误

WRONG - Parallel routes at incorrect scope:
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
This makes the parallel routes global when they should be feature-specific.
CORRECT - Parallel routes properly scoped:
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
错误示例 - 并行路由作用域错误:
需求:"为X和Y创建带有分栏的[特定功能]"

app/
├── @x/              # ❌ 在根目录创建 - 影响整个应用!
├── @y/              # ❌ 作用域错误
└── layout.tsx       # ❌ 不必要地修改了根布局
这会将并行路由设置为全局,而实际上它们应该是功能专属的。
正确示例 - 并行路由作用域正确:
需求:"为X和Y创建带有分栏的[特定功能]"

app/
├── [specific-feature]/
│   ├── @x/          # ✅ 作用域限定为该功能
│   ├── @y/          # ✅ 仅影响该路由
│   └── layout.tsx   # ✅ 功能专属布局
└── layout.tsx       # 根布局保持不变

Decision Criteria

决策标准

  1. Analyze the requirements - Look for specific feature/page names
    • Mentions a specific noun/feature? → Create under
      app/[that-feature]/
    • General app-level description? → Determine if root or new route
  2. Consider URL structure - What URL should this live at?
    • /feature
      path → Use
      app/feature/@slots/
    • Root
      /
      path → Use
      app/@slots/
    • Nested
      /parent/feature
      → Use
      app/parent/feature/@slots/
  3. Think about scope impact - How much of the app is affected?
    • One feature/page only? → Scope to feature directory
    • Multiple related pages? → Scope to parent directory
    • Entire application? → Use root level
  1. 分析需求 - 查找特定功能/页面名称
    • 提到特定名词/功能? → 在
      app/[that-feature]/
      下创建
    • 通用应用级描述? → 确定是根目录还是新路由
  2. 考虑URL结构 - 该功能应位于哪个URL?
    • /feature
      路径 → 使用
      app/feature/@slots/
    • 根路径
      /
      → 使用
      app/@slots/
    • 嵌套路径
      /parent/feature
      → 使用
      app/parent/feature/@slots/
  3. 评估作用域影响 - 会影响应用的多少部分?
    • 仅一个功能/页面? → 作用域限定为功能目录
    • 多个相关页面? → 作用域限定为父目录
    • 整个应用? → 使用根目录

Practical Examples

实际示例

Example 1: Feature-specific parallel routes
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
Example 2: App-wide parallel routes
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
Example 3: Nested section parallel routes
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)
示例1:功能专属并行路由
场景:用户资料页面需要帖子和动态两个标签页

分析:
- "用户资料页面" = 特定功能
- 应位于/profile URL
- 仅影响资料页面

结构:
app/
├── profile/
│   ├── @posts/
│   │   └── page.tsx
│   ├── @activity/
│   │   └── page.tsx
│   └── layout.tsx        # 接受posts、activity插槽的布局
示例2:应用全局并行路由
场景:整个应用布局需要侧边栏和主内容插槽

分析:
- "应用布局" = 根级别
- 影响整个应用
- 应位于根目录

结构:
app/
├── @sidebar/
│   └── page.tsx
├── @main/
│   └── page.tsx
└── layout.tsx            # 带有插槽的根布局
示例3:嵌套模块并行路由
场景:管理后台添加包含图表和表格的分析视图

分析:
- "管理后台" = 现有模块
- "分析视图" = 子模块
- 应位于/admin/analytics URL

结构:
app/
├── admin/
│   ├── analytics/
│   │   ├── @charts/
│   │   │   └── page.tsx
│   │   ├── @tables/
│   │   │   └── page.tsx
│   │   └── layout.tsx    # 分析视图专属布局
│   └── layout.tsx        # 管理后台布局(未修改)

Quick Reference

快速参考

Requirement PatternRoute ScopeExample Structure
Feature-specific requirement
app/[feature]/
app/profile/@tab/
Section inside a parent area
app/[parent]/[section]/
app/admin/analytics/@view/
App-wide layout requirement
app/
app/@sidebar/
Page with multiple panels
app/[page]/
app/settings/@panel/
CRITICAL RULE: Always analyze the requirement for scope indicators before defaulting to root-level parallel routes.
需求模式路由作用域示例结构
特定功能需求
app/[feature]/
app/profile/@tab/
父模块下的子模块
app/[parent]/[section]/
app/admin/analytics/@view/
应用全局布局需求
app/
app/@sidebar/
带有多面板的页面
app/[page]/
app/settings/@panel/
重要规则: 在默认使用根级别并行路由之前,务必分析需求中的作用域指示信息。

Parallel Routes

并行路由

Parallel Routes allow rendering multiple pages in the same layout simultaneously.
并行路由允许在同一布局中同时渲染多个页面。

⚠️ IMPORTANT: Understand Route Scope First

⚠️ 重要提示:先理解路由作用域

Before creating parallel routes, review "Step 0: Determine Parallel Route Scope" above to identify the correct directory level.
Don't default to creating parallel routes at root level - scope them appropriately to the feature/page mentioned in the requirements.
在创建并行路由之前,请先阅读上方的"步骤0:确定并行路由的作用域",以确定正确的目录级别。
不要默认在根目录创建并行路由——根据需求中的功能/页面,将它们限定在合适的作用域内。

Creating Parallel Routes (Feature-Scoped)

创建并行路由(功能专属)

For feature-specific parallel routes (most common):
app/
├── [feature-name]/
│   ├── @slot1/
│   │   └── page.tsx
│   ├── @slot2/
│   │   └── page.tsx
│   ├── layout.tsx       # Feature layout accepting slot props
│   └── page.tsx         # Feature main page
对于功能专属的并行路由(最常见):
app/
├── [feature-name]/
│   ├── @slot1/
│   │   └── page.tsx
│   ├── @slot2/
│   │   └── page.tsx
│   ├── layout.tsx       # 接受插槽Props的功能布局
│   └── page.tsx         # 功能主页面

Creating Parallel Routes (Root-Level)

创建并行路由(根级别)

For app-wide parallel routes (less common):
app/
├── @slot1/
│   └── page.tsx
├── @slot2/
│   └── page.tsx
├── layout.tsx           # Root layout with slots
└── page.tsx
对于应用全局的并行路由(较少见):
app/
├── @slot1/
│   └── page.tsx
├── @slot2/
│   └── page.tsx
├── layout.tsx           # 带有插槽的根布局
└── page.tsx

Layout with Parallel Routes (Feature-Scoped Example)

带有并行路由的布局(功能专属示例)

For a feature with parallel routes:
typescript
// 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>
  );
}
对于带有并行路由的功能:
typescript
// app/[feature]/layout.tsx
export default function FeatureLayout({
  children,
  slot1,
  slot2,
}: {
  children: React.ReactNode;
  slot1: React.ReactNode;
  slot2: React.ReactNode;
}) {
  return (
    <div>
      <h1>功能页面</h1>
      <div className="main">{children}</div>
      <div className="slots">
        <div className="slot1">{slot1}</div>
        <div className="slot2">{slot2}</div>
      </div>
    </div>
  );
}

Layout with Parallel Routes (Root-Level Example)

带有并行路由的布局(根级别示例)

For app-wide parallel routes:
typescript
// 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>
  );
}
对于应用全局的并行路由:
typescript
// app/layout.tsx
export default function RootLayout({
  children,
  sidebar,
  main,
}: {
  children: React.ReactNode;
  sidebar: React.ReactNode;
  main: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <body>
        <div className="app-layout">
          <aside>{sidebar}</aside>
          <main>{main}</main>
          {children}
        </div>
      </body>
    </html>
  );
}

Default Parallel Route

默认并行路由

Create a
default.tsx
to handle unmatched routes or provide fallback UI:
typescript
// 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>;
}
创建
default.tsx
以处理未匹配的路由或提供回退UI:
typescript
// 功能专属:app/[feature]/@slot1/default.tsx
export default function Default() {
  return null; // 或默认UI
}

// 根级别:app/@sidebar/default.tsx
export default function Default() {
  return <div>默认侧边栏内容</div>;
}

Conditional Parallel Routes

条件渲染并行路由

Parallel routes can be conditionally rendered based on runtime conditions:
typescript
// 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>
  );
}
Note: This pattern works at any layout level (root or feature-scoped).
可以根据运行时条件有条件地渲染并行路由:
typescript
// app/[feature]/layout.tsx(或任何带有并行路由的布局)
export default function Layout({
  children,
  analytics,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
}) {
  const showAnalytics = true; // 可以基于用户权限、功能标志等动态设置

  return (
    <div>
      <main>{children}</main>
      {showAnalytics && <aside>{analytics}</aside>}
    </div>
  );
}
注意: 该模式适用于任何布局级别(根级别或功能专属)。

Intercepting Routes

拦截路由

Intercepting Routes allow you to load a route within the current layout while keeping the context of the current page.
拦截路由允许在当前布局中加载路由,同时保留当前页面的上下文。

Intercepting Route Conventions

拦截路由约定

  • (.)
    - Match segments on the same level
  • (..)
    - Match segments one level above
  • (..)(..)
    - Match segments two levels above
  • (...)
    - Match segments from the root
  • (.)
    - 匹配同一级别的路由段
  • (..)
    - 匹配上一级别的路由段
  • (..)(..)
    - 匹配上两级的路由段
  • (...)
    - 匹配根目录下的路由段

Modal Pattern with Intercepting Routes

基于拦截路由的模态框模式

app/
├── photos/
│   ├── [id]/
│   │   └── page.tsx        # Full photo page
│   └── page.tsx            # Photo gallery
├── @modal/
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx    # Modal photo view
└── layout.tsx
app/
├── photos/
│   ├── [id]/
│   │   └── page.tsx        # 完整照片页面
│   └── page.tsx            # 照片画廊
├── @modal/
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx    # 模态框照片视图
└── layout.tsx

Layout for Modal Pattern

模态框模式的布局

typescript
// app/layout.tsx
export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      {children}
      {modal}
    </div>
  );
}
typescript
// app/layout.tsx
export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      {children}
      {modal}
    </div>
  );
}

Modal Component

模态框组件

typescript
// 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;
}
typescript
// 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;
}

Client-Side Modal Component

客户端模态框组件

typescript
// 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>
  );
}
typescript
// 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}>关闭</button>
      {children}
    </dialog>
  );
}

Error Boundaries

错误边界

Basic Error Boundary

基础错误边界

typescript
// 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>
  );
}
typescript
// app/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>出现错误!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  );
}

Nested Error Boundaries

嵌套错误边界

typescript
// 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>
  );
}
typescript
// app/dashboard/error.tsx
'use client';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="dashboard-error">
      <h2>仪表盘错误</h2>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  );
}

Global Error Boundary

全局错误边界

typescript
// 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>
  );
}
typescript
// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h2>应用错误</h2>
        <p>{error.message}</p>
        <button onClick={reset}>重试</button>
      </body>
    </html>
  );
}

Not Found Boundary

404错误边界

typescript
// 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>;
}
typescript
// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div>
      <h2>页面未找到</h2>
      <p>无法找到请求的资源</p>
      <Link href="/">返回首页</Link>
    </div>
  );
}

// 程序化触发
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>;
}

Draft Mode

草稿模式

Draft Mode allows you to preview draft content from a headless CMS.
草稿模式允许预览来自无头CMS的草稿内容。

Enabling Draft Mode

启用草稿模式

typescript
// 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 || '/');
}
typescript
// 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');

  // 验证密钥
  if (secret !== process.env.DRAFT_SECRET) {
    return Response.json({ message: '无效令牌' }, { status: 401 });
  }

  // 启用草稿模式
  const draft = await draftMode();
  draft.enable();

  // 重定向到获取到的文章路径
  redirect(slug || '/');
}

Disabling Draft Mode

禁用草稿模式

typescript
// 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('/');
}
typescript
// 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('/');
}

Using Draft Mode in Pages

在页面中使用草稿模式

typescript
// 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>
  );
}
typescript
// 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;

  // 获取草稿或已发布内容
  const post = await getPost(params.slug, isDraft);

  return (
    <article>
      {isDraft && (
        <div className="draft-banner">
          <p>草稿模式已激活</p>
          <a href="/api/draft/disable">退出草稿模式</a>
        </div>
      )}
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

Streaming and Suspense

流式渲染与Suspense

Basic Streaming with Suspense

结合Suspense的基础流式渲染

typescript
// 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>
  );
}
typescript
// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>仪表盘</h1>

      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>

      <Suspense fallback={<RecentActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

async function Stats() {
  const stats = await fetchStats(); // 慢查询
  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>
  );
}

Nested Suspense Boundaries

嵌套Suspense边界

typescript
// 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>
  );
}
typescript
// 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 States with loading.tsx

结合loading.tsx实现加载状态

typescript
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="loading-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-body" />
    </div>
  );
}
typescript
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="loading-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-body" />
    </div>
  );
}

Streaming with Loading UI

结合加载UI的流式渲染

typescript
// 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>
  );
}
typescript
// 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>
  );
}

Advanced Patterns

高级模式

Optimistic Updates with Server Actions

结合Server Actions的乐观更新

typescript
// 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>
  );
}
typescript
// 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}>
      点赞数: {optimisticLikes}
    </button>
  );
}

Progressive Enhancement with Forms

表单的渐进增强

typescript
// 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>
  );
}
typescript
// 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 ? '创建中...' : '创建文章'}
    </button>
  );
}

export default function NewPost() {
  const [state, formAction] = useFormState(createPost, null);

  return (
    <form action={formAction}>
      <input name="title" placeholder="标题" required />
      <textarea name="content" placeholder="内容" 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>
  );
}

Summary

总结

  • Route Handlers - API endpoints in
    route.ts
    files with HTTP method exports
  • Server Actions - Server-side mutations with 'use server' directive
  • Parallel Routes - Multiple pages rendered simultaneously using
    @folder
    syntax
  • Intercepting Routes - Load routes in context using
    (.)
    syntax for modals
  • Error Boundaries - Handle errors with
    error.tsx
    and
    global-error.tsx
  • Draft Mode - Preview draft content from CMS
  • Streaming - Progressive rendering with Suspense boundaries
  • Cookies - Access and set cookies in Route Handlers and Server Actions
  • Revalidation - Invalidate cache with
    revalidatePath
    and
    revalidateTag
  • Route Handlers - 在
    route.ts
    文件中通过HTTP方法导出实现API路由
  • Server Actions - 通过'use server'指令实现服务端数据变更
  • 并行路由 - 使用
    @folder
    语法同时渲染多个页面
  • 拦截路由 - 使用
    (.)
    语法在当前上下文加载路由,实现模态框等模式
  • 错误边界 - 使用
    error.tsx
    global-error.tsx
    处理错误
  • 草稿模式 - 预览来自CMS的草稿内容
  • 流式渲染 - 结合Suspense边界实现渐进式渲染
  • Cookie - 在Route Handler和Server Action中访问与设置Cookie
  • 重新验证 - 使用
    revalidatePath
    revalidateTag
    使缓存失效