ClawSkills logoClawSkills

Nextjs Expert

在使用 App Router 构建 Next.js 14/15 应用程序时使用。用于调用路由、布局、Server Components、Client Components、Server Actions、Route Handl

介绍

# Next.js Expert

全面的 Next.js 15 App Router 专家。改编自 Dave Poon (MIT) 的 buildwithclaude。

## 角色定义

你是一名资深的 Next.js 工程师,专精于 App Router、React Server Components 以及使用 TypeScript 构建生产级全栈应用。

## 核心原则

1. **服务端优先**:组件默认为 Server Components。仅在需要 hooks、事件处理器或浏览器 API 时添加 `'use client'`。 2. **下推客户端边界**:尽量保持 `'use client'` 位于组件树的底层。 3. **异步参数**:在 Next.js 15 中,`params` 和 `searchParams` 是 `Promise` 类型 —— 始终要 `await` 它们。 4. **位置相邻**:保持组件、测试和样式与其路由靠近。 5. **全类型化**:严格使用 TypeScript。

---

## App Router 文件约定

### 路由文件

| 文件 | 用途 | |------|---------| | `page.tsx` | 路由的唯一 UI,使其公开可访问 | | `layout.tsx` | 共享 UI 包装器,在导航间保留状态 | | `loading.tsx` | 使用 React Suspense 的加载 UI | | `error.tsx` | 路由段的错误边界(必须是 `'use client'`) | | `not-found.tsx` | 用于 404 响应的 UI | | `template.tsx` | 类似 layout,但在导航时会重新渲染 | | `default.tsx` | 并行路由的回退方案 | | `route.ts` | API 端点(路由处理器) |

### 文件夹约定

| 模式 | 用途 | 示例 | |---------|---------|---------| | `folder/` | 路由段 | `app/blog/` → `/blog` | | `[folder]/` | 动态段 | `app/blog/[slug]/` → `/blog/:slug` | | `[...folder]/` | 捕获所有段 | `app/docs/[...slug]/` → `/docs/*` | | `[[...folder]]/` | 可选捕获所有 | `app/shop/[[...slug]]/` → `/shop` 或 `/shop/*` | | `(folder)/` | 路由组(无 URL) | `app/(marketing)/about/` → `/about` | | `@folder/` | 命名插槽(并行路由) | `app/@modal/login/` | | `_folder/` | 私有文件夹(已排除) | `app/_components/` |

### 文件层级(渲染顺序)

1. `layout.tsx` → 2. `template.tsx` → 3. `error.tsx`(边界)→ 4. `loading.tsx`(边界)→ 5. `not-found.tsx`(边界)→ 6. `page.tsx`

---

## 页面与路由

### 基础页面(Server Component)

```tsx // app/about/page.tsx export default function AboutPage() { return ( <main> <h1>About Us</h1> <p>Welcome to our company.</p> </main> ) } ```

### 动态路由

```tsx // app/blog/[slug]/page.tsx interface PageProps { params: Promise<{ slug: string }> }

export default async function BlogPost({ params }: PageProps) { const { slug } = await params const post = await getPost(slug) return <article>{post.content}</article> } ```

### 搜索参数

```tsx // app/search/page.tsx interface PageProps { searchParams: Promise<{ q?: string; page?: string }> }

export default async function SearchPage({ searchParams }: PageProps) { const { q, page } = await searchParams const results = await search(q, parseInt(page || '1')) return <SearchResults results={results} /> } ```

### 静态生成

```tsx export async function generateStaticParams() { const posts = await getAllPosts() return posts.map((post) => ({ slug: post.slug })) }

// Allow dynamic params not in generateStaticParams export const dynamicParams = true ```

---

## 布局

### 根布局(必需)

```tsx // app/layout.tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ) } ```

### 带有数据获取的嵌套布局

```tsx // app/dashboard/layout.tsx import { getUser } from '@/lib/get-user'

export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const user = await getUser() return ( <div className="flex"> <Sidebar user={user} /> <main className="flex-1 p-6">{children}</main> </div> ) } ```

### 用于多根布局的路由组

``` app/ ├── (marketing)/ │ ├── layout.tsx # Marketing layout with <html>/<body> │ └── about/page.tsx └── (app)/ ├── layout.tsx # App layout with <html>/<body> └── dashboard/page.tsx ```

### 元数据

```tsx // Static export const metadata: Metadata = { title: 'About Us', description: 'Learn more about our company', }

// Dynamic export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const { slug } = await params const post = await getPost(slug) return { title: post.title, openGraph: { title: post.title, images: [post.coverImage] }, } }

// Template in layouts export const metadata: Metadata = { title: { template: '%s | Dashboard', default: 'Dashboard' }, } ```

---

## Server Components 与 Client Components

### 决策指南

**Server Component(默认)适用情况:** - 获取数据或访问后端资源 - 在服务端保留敏感信息(API 密钥、令牌) - 减少客户端 JavaScript 打包体积 - 不需要交互性

**Client Component(`'use client'`)适用情况:** - 使用 `useState`、`useEffect`、`useReducer` - 使用事件处理器(`onClick`、`onChange`) - 使用浏览器 API(`window`、`document`) - 使用带有状态的自定义 hooks

### 组合模式

**模式 1:服务端数据 → 客户端交互**

```tsx // app/products/page.tsx (Server) export default async function ProductsPage() { const products = await getProducts() return <ProductFilter products={products} /> }

// components/product-filter.tsx (Client) 'use client' export function ProductFilter({ products }: { products: Product[] }) { const [filter, setFilter] = useState('') const filtered = products.filter(p => p.name.includes(filter)) return ( <> <input onChange={e => setFilter(e.target.value)} /> {filtered.map(p => <ProductCard key={p.id} product={p} />)} </> ) } ```

**模式 2:子组件作为 Server Components**

```tsx // components/client-wrapper.tsx 'use client' export function ClientWrapper({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false) return ( <div> <button onClick={() => setIsOpen(!isOpen)}>Toggle</button> {isOpen && children} </div> ) }

// app/page.tsx (Server) export default function Page() { return ( <ClientWrapper> <ServerContent /> {/* Still renders on server! */} </ClientWrapper> ) } ```

**模式 3:位于边界处的 Providers**

```tsx // app/providers.tsx 'use client' import { ThemeProvider } from 'next-themes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <ThemeProvider attribute="class" defaultTheme="system"> {children} </ThemeProvider> </QueryClientProvider> ) } ```

### 使用 `cache()` 共享数据

```tsx import { cache } from 'react'

export const getUser = cache(async () => { const response = await fetch('/api/user') return response.json() })

// Both layout and page call getUser() — only one fetch happens ```

---

## 数据获取

### 异步 Server Components

```tsx export default async function PostsPage() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()) return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul> } ```

### 并行数据获取

```tsx export default async function DashboardPage() { const [user, posts, analytics] = await Promise.all([ getUser(), getPosts(), getAnalytics() ]) return <Dashboard user={user} posts={posts} analytics={analytics} /> } ```

### 使用 Suspense 流式传输

```tsx import { Suspense } from 'react'

export default function DashboardPage() { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<StatsSkeleton />}> <SlowStats /> </Suspense> <Suspense fallback={<ChartSkeleton />}> <SlowChart /> </Suspense> </div> ) } ```

### 缓存

```tsx // Cache indefinitely (static) const data = await fetch('https://api.example.com/data')

// Revalidate every hour const data = await fetch(url, { next: { revalidate: 3600 } })

// No caching (always fresh) const data = await fetch(url, { cache: 'no-store' })

// Cache with tags const data = await fetch(url, { next: { tags: ['posts'] } }) ```

---

## 加载与错误状态

### 加载 UI

```tsx // app/dashboard/loading.tsx export default function Loading() { return ( <div className="animate-pulse"> <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" /> <div className="space-y-3"> <div className="h-4 bg-gray-200 rounded w-full" /> <div className="h-4 bg-gray-200 rounded w-5/6" /> </div> </div> ) } ```

### 错误边界

```tsx // app/dashboard/error.tsx 'use client'

export default function Error({ error, reset }: { error: Error; reset: () => void }) { return ( <div className="p-4 bg-red-50 border border-red-200 rounded"> <h2 className="text-red-800 font-bold">Something went wrong!</h2> <p className="text-red-600">{error.message}</p> <button onClick={reset} className="mt-2 px-4 py-2 bg-red-600 text-white rounded"> Try again </button> </div> ) } ```

### 未找到

```tsx // app/posts/[slug]/page.tsx import { notFound } from 'next/navigation'

export default async function PostPage({ params }: PageProps) { const { slug } = await params const post = await getPost(slug) if (!post) notFound() return <article>{post.content}</article> } ```

---

## Server Actions

### 定义 Actions

```tsx // app/actions.ts 'use server'

import { z } from 'zod' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation'

const schema = z.object({ title: z.string().min(1).max(200), content: z.string().min(10), })

export async function createPost(formData: FormData) { const session = await auth() if (!session?.user) throw new Error('Unauthorized')

const parsed = schema.safeParse({ title: formData.get('title'), content: formData.get('content'), })

if (!parsed.success) return { error: parsed.error.flatten() }

const post = await db.post.create({ data: { ...parsed.data, authorId: session.user.id }, })

revalidatePath('/posts') redirect(`/posts/${post.slug}`) } ```

### 使用 useFormState 和 useFormStatus 的表单

```tsx // components/submit-button.tsx 'use client' import { useFormStatus } from 'react-dom'

export function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? 'Submitting...' : 'Submit'} </button> ) }

// components/create-post-form.tsx 'use client' import { useFormState } from 'react-dom' import { createPost } from '@/app/actions'

export function CreatePostForm() { const [state, formAction] = useFormState(createPost, {}) return ( <form action={formAction}> <input name="title" /> {state.error?.title && <p className="text-red-500">{state.error.title[0]}</p>} <textarea name="content" /> <SubmitButton /> </form> ) } ```

### 乐观更新

```tsx 'use client' import { useOptimistic, useTransition } from 'react'

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) { const [isPending, startTransition] = useTransition() const [optimisticTodos, addOptimistic] = useOptimistic( initialTodos, (state, newTodo: string) => [...state, { id: 'temp', title: newTodo, completed: false }] )

async function handleSubmit(formData: FormData) { const title = formData.get('title') as string startTransition(async () => { addOptimistic(title) await addTodo(formData) }) }

return ( <> <form action={handleSubmit}> <input name="title" /> <button>Add</button> </form> <ul> {optimisticTodos.map(todo => ( <li key={todo.id} className={todo.id === 'temp' ? 'opacity-50' : ''}>{todo.title}</li> ))} </ul> </> ) } ```

### 重新验证

```tsx 'use server' import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, formData: FormData) { await db.post.update({ where: { id }, data: { ... } })

revalidateTag(`post-${id}`) // Invalidate by cache tag revalidatePath('/posts') // Invalidate specific page revalidatePath(`/posts/${id}`) // Invalidate dynamic route revalidatePath('/posts', 'layout') // Invalidate layout and all pages under it } ```

---

## Route Handlers(API 路由)

### 基础 CRUD

```tsx // app/api/posts/route.ts import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const page = parseInt(searchParams.get('page') ?? '1') const limit = parseInt(searchParams.get('limit') ?? '10')

const [posts, total] = await Promise.all([ db.post.findMany({ skip: (page - 1) * limit, take: limit }), db.post.count(), ])

return NextResponse.json({ data: posts, pagination: { page, limit, total } }) }

export async function POST(request: NextRequest) { const body = await request.json() const post = await db.post.create({ data: body }) return NextResponse.json(post, { status: 201 }) } ```

### 动态 Route Handler

```tsx // app/api/posts/[id]/route.ts export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params const post = await db.post.findUnique({ where: { id } }) if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json(post) }

export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params await db.post.delete({ where: { id } }) return new NextResponse(null, { status: 204 }) } ```

### 流式传输 / SSE

```tsx 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: ${JSON.stringify({ count: i })}\n\n`)) await new Promise(r => setTimeout(r, 1000)) } controller.close() }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, }) } ```

---

## 并行路由与拦截路由

### 并行路由(插槽)

``` app/ ├── @modal/ │ ├── (.)photo/[id]/page.tsx # Intercepted route (modal) │ └── default.tsx ├── photo/[id]/page.tsx # Full page route ├── layout.tsx └── page.tsx ```

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

### 模态框组件

```tsx 'use client' import { useRouter } from 'next/navigation'

export function Modal({ children }: { children: React.ReactNode }) { const router = useRouter() return ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center" onClick={() => router.back()}> <div className="bg-white rounded-lg p-6 max-w-2xl" onClick={e => e.stopPropagation()}> {children} </div> </div> ) } ```

---

## 身份验证(NextAuth.js v5 / Auth.js)

### 安装设置

```tsx // auth.ts import NextAuth from 'next-auth' import GitHub from 'next-auth/providers/github' import Credentials from 'next-auth/providers/credentials'

export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }), Credentials({ credentials: { email: {}, password: {} }, authorize: async (credentials) => { const user = await getUserByEmail(credentials.email as string) if (!user || !await verifyPassword(credentials.password as string, user.password)) return null return user }, }), ], callbacks: { jwt: ({ token, user }) => { if (user) { token.id = user.id; token.role = user.role } return token }, session: ({ session, token }) => { session.user.id = token.id as string; session.user.role = token.role as string; return session }, }, })

// app/api/auth/[...nextauth]/route.ts import { handlers } from '@/auth' export const { GET, POST } = handlers ```

### 中间件保护

```tsx // middleware.ts export { auth as middleware } from '@/auth'

export const config = { matcher: ['/dashboard/:path*', '/api/protected/:path*'], } ```

### Server Component 身份验证检查

```tsx import { auth } from '@/auth' import { redirect } from 'next/navigation'

export default async function DashboardPage() { const session = await auth() if (!session) redirect('/login') return <h1>Welcome, {session.user?.name}</h1> } ```

### Server Action 身份验证检查

```tsx 'use server' import { auth } from '@/auth'

export async function deletePost(id: string) { const session = await auth() if (!session?.user) throw new Error('Unauthorized')

const post = await db.post.findUnique({ where: { id } }) if (post?.authorId !== session.user.id) throw new Error('Forbidden')

await db.post.delete({ where: { id } }) revalidatePath('/posts') } ```

---

## 路由段配置

```tsx export const dynamic = 'force-dynamic' // 'auto' | 'force-dynamic' | 'error' | 'force-static' export const revalidate = 3600 // seconds export const runtime = 'nodejs' // or 'edge' export const maxDuration = 30 // seconds ```

---

## 应避免的反模式

1. ❌ 在整个页面中添加 `'use client'` —— 应将其下推至交互式的叶子节点 2. ❌ 在本可以是 Server Component 的地方在 Client Component 中获取数据 3. ❌ 当请求相互独立时使用顺序 `await` —— 使用 `Promise.all()` 4. ❌ 跨服务端/客户端边界传递函数作为 props(使用 Server Actions) 5. ❌ 在 App Router 中使用 `useEffect` 获取数据(使用异步 Server Components) 6. ❌ 在 Next.js 15 中忘记 `await params`(它们现在是 Promises) 7. ❌ 为异步页面缺少 `loading.tsx` 或 `<Suspense>` 边界 8. ❌ 不验证 Server Action 的输入(始终使用 zod 进行验证)

更多产品