name: nextjs-expert
version: 1.0.0
description: 当使用 App Router 构建 Next.js 14/15 应用时调用。适用于路由、布局、服务器组件、客户端组件、服务器操作、路由处理器、身份验证、中间件、数据获取、缓存、重新验证、流式传输、Suspense、加载状态、错误边界、动态路由、并行路由、拦截路由或任何 Next.js 架构问题。
triggers:
- Next.js
- Next
- nextjs
- App Router
- Server Components
- Client Components
- Server Actions
- use server
- use client
- Route Handler
- middleware
- layout.tsx
- page.tsx
- loading.tsx
- error.tsx
- revalidatePath
- revalidateTag
- NextAuth
- Auth.js
- generateStaticParams
- generateMetadata
role: specialist
scope: implementation
output-format: code
全面的 Next.js 15 App Router 专家。基于 Dave Poon 的 buildwithclaude (MIT) 改编。
你是一位专注于 App Router、React 服务器组件以及使用 TypeScript 构建生产级全栈应用的高级 Next.js 工程师。
'use client'。'use client' 放在组件树较低的位置。params 和 searchParams 是 Promise 类型 —— 务必 await 它们。| 文件 | 用途 |
|---|---|
page.tsx |
路由的唯一 UI,使其可公开访问 |
layout.tsx |
共享的 UI 包装器,在导航间保持状态 |
loading.tsx |
使用 React Suspense 的加载 UI |
error.tsx |
路由段的错误边界(必须是 'use client') |
not-found.tsx |
404 响应的 UI |
template.tsx |
类似布局,但在导航时重新渲染 |
default.tsx |
并行路由的回退 UI |
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/ |
layout.tsx → 2. template.tsx → 3. error.tsx(边界) → 4. loading.tsx(边界) → 5. not-found.tsx(边界) → 6. page.tsx// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>关于我们</h1>
<p>欢迎来到我们公司。</p>
</main>
)
}
// 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>
}
// 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} />
}
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
// 允许不在 generateStaticParams 中的动态参数
export const dynamicParams = true
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
)
}
// 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 # 营销布局,包含 <html>/<body>
│ └── about/page.tsx
└── (app)/
├── layout.tsx # 应用布局,包含 <html>/<body>
└── dashboard/page.tsx
// 静态
export const metadata: Metadata = {
title: '关于我们',
description: '了解更多关于我们公司的信息',
}
// 动态
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] },
}
}
// 布局中的模板
export const metadata: Metadata = {
title: { template: '%s | 仪表板', default: '仪表板' },
}
使用服务器组件(默认)当:
- 获取数据或访问后端资源
- 在服务器上保留敏感信息(API 密钥、令牌)
- 减少客户端 JavaScript 包大小
- 不需要交互性
使用客户端组件('use client')当:
- 使用 useState、useEffect、useReducer
- 使用事件处理器(onClick、onChange)
- 使用浏览器 API(window、document)
- 使用带有状态的钩子
模式 1:服务器数据 → 客户端交互
// app/products/page.tsx (服务器)
export default async function ProductsPage() {
const products = await getProducts()
return <ProductFilter products={products} />
}
// components/product-filter.tsx (客户端)
'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:子组件作为服务器组件
// components/client-wrapper.tsx
'use client'
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>切换</button>
{isOpen && children}
</div>
)
}
// app/page.tsx (服务器)
export default function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* 仍在服务器上渲染! */}
</ClientWrapper>
)
}
模式 3:在边界处提供 Provider
// 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() 共享数据import { cache } from 'react'
export const getUser = cache(async () => {
const response = await fetch('/api/user')
return response.json()
})
// 布局和页面都调用 getUser() —— 只发生一次获取
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>
}
export default async function DashboardPage() {
const [user, posts, analytics] = await Promise.all([
getUser(), getPosts(), getAnalytics()
])
return <Dashboard user={user} posts={posts} analytics={analytics} />
}
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
<h1>仪表板</h1>
<Suspense fallback={<StatsSkeleton />}>
<SlowStats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SlowChart />
</Suspense>
</div>
)
}
// 无限期缓存(静态)
const data = await fetch('https://api.example.com/data')
// 每小时重新验证
const data = await fetch(url, { next: { revalidate: 3600 } })
// 不缓存(始终获取最新)
const data = await fetch(url, { cache: 'no-store' })
// 带标签的缓存
const data = await fetch(url, { next: { tags: ['posts'] } })
// 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>
)
}
// 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">出错了!</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">
重试
</button>
</div>
)
}
// 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>
}
// 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('未授权')
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}`)
}
// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</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>
)
}
'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>添加</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.id === 'temp' ? 'opacity-50' : ''}>{todo.title}</li>
))}
</ul>
</>
)
}
'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}`) // 通过缓存标签使失效
revalidatePath('/posts') // 使特定页面失效
revalidatePath(`/posts/${id}`) // 使动态路由失效
revalidatePath('/posts', 'layout') // 使布局及其下所有页面失效
}
// 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 })
}
// 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: '未找到' }, { 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 })
}
```tsx
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {