mcpField
N
nextjs-advanced-routing

nextjs-advanced-routing

github.com/wsimmonds开发109334 次安装53 周前更新
代码仓库

关于 nextjs-advanced-routing

这是一项面向 Next.js App Router 开发者的高级路由编码技能,涵盖 Route Handlers、Server Actions、并行与拦截路由、错误处理、草稿模式及 Suspense 流式传输。

主要功能

  1. 01创建 Route Handlers
  2. 02实现 Server Actions
  3. 03构建并行和拦截路由
  4. 04处理错误与草稿模式
  5. 05使用 Suspense 流式传输内容
  6. 06避免 TypeScript `any`

适用场景

  1. 01为 App Router 项目添加 API 端点
  2. 02实现表单提交和服务端变更
  3. 03创建模态框或抽屉式路由
  4. 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 中或提交表单时):

  1. 在单独的文件中创建带有 'use server' 指令的服务端操作
  2. 在客户端组件中导入并使用该操作

✅ 正确模式:

// 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 },也不要照搬这种模式。这是反模式。始终:

  1. 检查/验证输入
  2. 如果验证失败则抛出错误(不要返回错误对象)
  3. 处理请求
  4. 不返回任何内容(返回 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
}

这样可以:

  1. ✅ 通过 TypeScript 检查(返回 void)
  2. ✅ 在处理前验证所有输入
  3. ✅ 验证失败时抛出错误(阻止保存到数据库)
  4. ✅ 不返回错误对象(否则会导致表单操作类型错误)
客户端组件中的服务端操作
// 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
决策标准
  1. 分析需求 - 查找具体的功能/页面名称

    • 提到了具体的名词/功能?→ 在 app/[that-feature]/ 下创建
    • 对应用的通用描述?→ 判断应放在根目录还是新路由下
  2. 考虑 URL 结构 - 内容应位于哪个 URL?

    • /feature 路径 → 使用 app/feature/@slots/
    • 根路径 / → 使用 app/@slots/
    • 嵌套的 /parent/feature → 使用 app/parent/feature/@slots/
  3. 考虑影响范围 - 会影响应用的哪些部分?

    • 仅影响一个功能/页面?→ 限定在功能目录中
    • 影响多个相关页面?→ 限定在父目录中
    • 影响整个应用?→ 使用根目录
实用示例

示例 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 使缓存失效

评论

支持 Markdown