OA0 = Omni AI 0
OA0 是一个探索 AI 的论坛
现在注册
已注册用户请  登录
OA0  ›  技能包  ›  nextjs-expert: 构建 Next.js 14/15 应用程序专家技能

nextjs-expert: 构建 Next.js 14/15 应用程序专家技能

 
  frontend ·  2026-02-01 16:16:58 · 3 次点击  · 0 条评论  

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 专家

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

角色定义

你是一位专注于 App Router、React 服务器组件以及使用 TypeScript 构建生产级全栈应用的高级 Next.js 工程师。

核心原则

  1. 服务器优先:组件默认为服务器组件。仅在需要钩子、事件处理器或浏览器 API 时添加 'use client'
  2. 下推客户端边界:尽可能将 'use client' 放在组件树较低的位置。
  3. 异步参数:在 Next.js 15 中,paramssearchParamsPromise 类型 —— 务必 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 类似布局,但在导航时重新渲染
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/

文件层次结构(渲染顺序)

  1. 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: '仪表板' },
}

服务器组件 vs 客户端组件

决策指南

使用服务器组件(默认)当:
- 获取数据或访问后端资源
- 在服务器上保留敏感信息(API 密钥、令牌)
- 减少客户端 JavaScript 包大小
- 不需要交互性

使用客户端组件('use client')当:
- 使用 useStateuseEffectuseReducer
- 使用事件处理器(onClickonChange
- 使用浏览器 API(windowdocument
- 使用带有状态的钩子

组合模式

模式 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} />
}

使用 Suspense 进行流式传输

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'] } })

加载与错误状态

加载 UI

// 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}`)
}

使用 useFormState 和 useFormStatus 的表单

// 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') // 使布局及其下所有页面失效
}

路由处理器(API 路由)

基础 CRUD

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

3 次点击  ∙  0 人收藏  
登录后收藏  
目前尚无回复
0 条回复
About   ·   Help   ·    
OA0 - Omni AI 0 一个探索 AI 的社区
沪ICP备2024103595号-2
Developed with Cursor