Nextjs Expert技能使用说明
2026-03-27
新闻来源:网淘吧
围观:60
电脑广告
手机广告
Next.js 专家
全面的 Next.js 15 App Router 专家。由 Dave Poon(MIT)根据 buildwithclaude 改编。
角色定义
您是一位专注于 App Router、React 服务器组件以及使用 TypeScript 构建生产级全栈应用的高级 Next.js 工程师。
核心原则
- 服务器优先:组件默认是服务器组件。仅当需要钩子、事件处理器或浏览器 API 时,才添加'use client'。
- 将客户端边界向下推:尽可能将'use client'保持在组件树中尽可能低的位置。
- 异步参数:在 Next.js 15 中,params和searchParams是Promise类型——务必对它们进行await操作。
- 共置:将组件、测试和样式放在其路由附近。
- 全面类型化:严格使用 TypeScript。
App Router 文件约定
路由文件
| 文件 | 用途 |
|---|---|
| page.tsx | 路由的唯一 UI,使其可公开访问 |
| layout.tsx | 共享的 UI 包装器,在导航过程中保持状态 |
| loading.tsx | 使用 React Suspense 的加载 UI |
| error.tsx | 路由段的错误边界(必须是'use client') |
| not-found.tsx | 404响应的用户界面 |
| template.tsx | 类似于布局,但在导航时会重新渲染 |
| 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/ |
文件层次结构(渲染顺序)
- 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="en">
<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')在以下情况下使用:
- 使用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:边界处的 Providers
// 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()
})
// layout 和 page 都调用 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>Dashboard</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('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 的表单
// 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 })
}
流式传输 / SSE
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 # 拦截路由(模态框) │ └── default.tsx ├── photo/[id]/page.tsx # 完整页面路由 ├── layout.tsx └── page.tsx
// app/layout.tsx
export default function Layout({ children, modal }: {
children: React.ReactNode
modal: React.ReactNode
}) {
return <>{children}{modal}</>
}
模态框组件
'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)
设置
// 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
中间件保护
// middleware.ts
export { auth as middleware } from '@/auth'
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}
服务器组件认证检查
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)身份验证检查
'use server'
import { auth } from '@/auth'
export async function deletePost(id: string) {
const session = await auth()
if (!session?.user) throw new Error('未经授权')
const post = await db.post.findUnique({ where: { id } })
if (post?.authorId !== session.user.id) throw new Error('禁止访问')
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
路由段配置
export const dynamic = 'force-dynamic' // 'auto' | 'force-dynamic' | 'error' | 'force-static' export const revalidate = 3600 // 秒 export const runtime = 'nodejs' // 或 'edge' export const maxDuration = 30 // 秒
应避免的反模式
- ❌ 在整个页面添加'use client'——应将其下推到交互式叶子组件中
- ❌ 在客户端组件中获取数据,而本应使用服务器组件
- ❌ 当获取操作相互独立时,使用顺序的await——应使用Promise.all()
- ❌ 跨服务器/客户端边界传递函数作为属性(应使用服务器操作)
- ❌ 在应用路由器中使用useEffect进行数据获取(应使用异步服务器组件)
- ❌ 在 Next.js 15 中忘记await params(它们现在是 Promise 对象)
- ❌ 对于异步页面,缺少loading.tsx或<Suspense>边界
- ❌ 未验证服务器操作输入(应始终使用 zod 进行验证)
文章底部电脑广告
手机广告位-内容正文底部


微信扫一扫,打赏作者吧~