Next.js全栈开发指南
// 目录 · contents
引言 App Router 架构 文件系统路由 Layout 嵌套与组合 Server Components vs
Client Components 组合模式:Server
Component 包裹 Client Component 数据获取策略 Server Component 中直接
fetch Server Actions 带状态的 Server
Action(useActionState) API Routes Middleware Authentication 实战 部署方案 Vercel 部署(推荐) Docker 自托管部署 总结
引言
Next.js 已经从一个 SSR 框架发展为全栈 React 元框架。App Router
的引入带来了 Server Components、Streaming、Parallel Routes
等革命性特性。本文将全面介绍 Next.js 的核心概念和实战技巧,涵盖 App
Router 架构、数据获取策略、API
路由设计、中间件鉴权以及生产部署方案。
App Router 架构
文件系统路由
App Router 基于 app/ 目录的文件系统约定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 app/ ├── layout.tsx # Root layout (required) ├── page.tsx # Home page: / ├── loading.tsx # Loading UI ├── error.tsx # Error boundary ├── not-found.tsx # 404 page ├── dashboard/ │ ├── layout.tsx # Nested layout │ ├── page.tsx # /dashboard │ └── settings/ │ └── page.tsx # /dashboard/settings ├── blog/ │ ├── page.tsx # /blog │ └── [slug]/ │ └── page.tsx # /blog/:slug (dynamic) ├── api/ │ └── users/ │ └── route.ts # API: /api/users └── (marketing)/ # Route group (no URL impact) ├── about/ │ └── page.tsx # /about └── pricing/ └── page.tsx # /pricing
graph TD
A["app/layout.tsx (Root Layout)"] --> B["app/page.tsx (/)"]
A --> C["app/dashboard/layout.tsx"]
C --> D["app/dashboard/page.tsx (/dashboard)"]
C --> E["app/dashboard/settings/page.tsx"]
A --> F["app/blog/page.tsx (/blog)"]
A --> G["app/blog/[slug]/page.tsx"]
style A fill:#000,color:#fff
style C fill:#333,color:#fff
style D fill:#0070f3,color:#fff
style G fill:#0070f3,color:#fff
Layout 嵌套与组合
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import { Inter } from 'next/font/google' ;const inter = Inter ({ subsets : ['latin' ] });export default function RootLayout ({ children, }: { children: React.ReactNode; } ) { return ( <html lang ="zh-CN" > <body className ={inter.className} > <nav > Global Navigation</nav > {children} <footer > Global Footer</footer > </body > </html > ); }export default function DashboardLayout ({ children, }: { children: React.ReactNode; } ) { return ( <div className ="flex" > <aside className ="w-64" > <DashboardSidebar /> </aside > <main className ="flex-1" > {children}</main > </div > ); }
Server Components vs
Client Components
graph TB
subgraph Server Components
A[默认行为 - 无需标记]
B[直接访问数据库/文件系统]
C[使用 async/await]
D[不包含在 JS bundle 中]
E[不能使用 hooks/事件]
end
subgraph Client Components
F["'use client' 标记"]
G[使用 useState/useEffect]
H[事件处理 onClick 等]
I[浏览器 API window/document]
J[包含在 JS bundle 中]
end
A -.->|需要交互时| F
style A fill:#10b981,color:#fff
style F fill:#f59e0b,color:#000
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import { db } from '@/lib/database' ;async function UserList ( ) { const users = await db.user .findMany ({ orderBy : { createdAt : 'desc' }, take : 20 , }); return ( <ul > {users.map(user => ( <li key ={user.id} > {user.name} — {user.email} </li > ))} </ul > ); }'use client' ;import { useState } from 'react' ;function SearchFilter ({ onSearch }: { onSearch: (query: string ) => void } ) { const [query, setQuery] = useState ('' ); return ( <input value ={query} onChange ={(e) => { setQuery(e.target.value); onSearch(e.target.value); }} placeholder="搜索用户..." /> ); }
组合模式:Server
Component 包裹 Client Component
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { db } from '@/lib/database' ;import { DashboardCharts } from './charts' ; export default async function DashboardPage ( ) { const stats = await db.getStats (); const recentOrders = await db.order .findMany ({ take : 10 }); return ( <div > <h1 > Dashboard</h1 > {/* Pass server data as props to client component */} <DashboardCharts data ={stats} /> <RecentOrdersTable orders ={recentOrders} /> </div > ); }
数据获取策略
Server Component 中直接
fetch
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 async function ProductPage ({ params }: { params: { id: string } } ) { const product = await fetch (`https://api.example.com/products/${params.id} ` ) .then (res => res.json ()); const reviews = await fetch (`https://api.example.com/reviews/${params.id} ` , { next : { revalidate : 60 }, }).then (res => res.json ()); const inventory = await fetch (`https://api.example.com/inventory/${params.id} ` , { cache : 'no-store' , }).then (res => res.json ()); return ( <div > <ProductDetail product ={product} /> <Reviews reviews ={reviews} /> <InventoryStatus inventory ={inventory} /> </div > ); }
Server Actions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 'use server' ;import { db } from '@/lib/database' ;import { revalidatePath } from 'next/cache' ;import { z } from 'zod' ;const createPostSchema = z.object ({ title : z.string ().min (1 ).max (200 ), content : z.string ().min (10 ), });export async function createPost (formData: FormData ) { const parsed = createPostSchema.safeParse ({ title : formData.get ('title' ), content : formData.get ('content' ), }); if (!parsed.success ) { return { error : parsed.error .flatten ().fieldErrors }; } await db.post .create ({ data : parsed.data , }); revalidatePath ('/blog' ); }import { createPost } from './actions' ;export default function NewPostPage ( ) { return ( <form action ={createPost} > <input name ="title" placeholder ="Title" required /> <textarea name ="content" placeholder ="Content" required /> <button type ="submit" > Publish</button > </form > ); }
带状态的 Server
Action(useActionState)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 'use client' ;import { useActionState } from 'react' ;import { createPost } from './actions' ;function NewPostForm ( ) { const [state, formAction, isPending] = useActionState (createPost, { error : null , }); return ( <form action ={formAction} > <input name ="title" placeholder ="标题" required /> {state?.error?.title && <p className ="error" > {state.error.title}</p > } <textarea name ="content" placeholder ="内容" required /> {state?.error?.content && <p className ="error" > {state.error.content}</p > } <button type ="submit" disabled ={isPending} > {isPending ? '发布中...' : '发布文章'} </button > </form > ); }
API Routes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import { NextRequest , NextResponse } from 'next/server' ;import { db } from '@/lib/database' ;export async function GET (request: NextRequest ) { const { searchParams } = new URL (request.url ); const page = parseInt (searchParams.get ('page' ) || '1' ); const limit = parseInt (searchParams.get ('limit' ) || '20' ); const users = await db.user .findMany ({ skip : (page - 1 ) * limit, take : limit, select : { id : true , name : true , email : true }, }); const total = await db.user .count (); return NextResponse .json ({ data : users, pagination : { page, limit, total, pages : Math .ceil (total / limit) }, }); }export async function POST (request: NextRequest ) { try { const body = await request.json (); const user = await db.user .create ({ data : { name : body.name , email : body.email , }, }); return NextResponse .json (user, { status : 201 }); } catch (error) { return NextResponse .json ( { error : 'Failed to create user' }, { status : 500 } ); } }export async function GET ( request: NextRequest, { params }: { params: { id: string } } ) { const user = await db.user .findUnique ({ where : { id : params.id }, }); if (!user) { return NextResponse .json ({ error : 'User not found' }, { status : 404 }); } return NextResponse .json (user); }
Middleware
中间件在每个请求到达路由之前执行,适合鉴权、重定向、国际化等场景:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import { NextRequest , NextResponse } from 'next/server' ;import { verifyToken } from '@/lib/auth' ;export async function middleware (request: NextRequest ) { const { pathname } = request.nextUrl ; const publicPaths = ['/login' , '/register' , '/api/auth' ]; if (publicPaths.some (path => pathname.startsWith (path))) { return NextResponse .next (); } const token = request.cookies .get ('auth-token' )?.value ; if (!token) { const loginUrl = new URL ('/login' , request.url ); loginUrl.searchParams .set ('from' , pathname); return NextResponse .redirect (loginUrl); } try { const payload = await verifyToken (token); const response = NextResponse .next (); response.headers .set ('x-user-id' , payload.userId ); response.headers .set ('x-user-role' , payload.role ); if (pathname.startsWith ('/admin' ) && payload.role !== 'admin' ) { return NextResponse .redirect (new URL ('/unauthorized' , request.url )); } return response; } catch { return NextResponse .redirect (new URL ('/login' , request.url )); } }export const config = { matcher : [ '/((?!_next/static|_next/image|favicon.ico|api/health).*)' , ], };
sequenceDiagram
participant C as Client
participant M as Middleware
participant R as Route Handler
participant D as Database
C->>M: Request /dashboard
M->>M: Check auth token
alt Token Valid
M->>R: Forward request (with user headers)
R->>D: Query data
D-->>R: Return data
R-->>C: 200 OK + HTML/JSON
else Token Invalid
M-->>C: 302 Redirect to /login
end
Authentication 实战
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import { SignJWT , jwtVerify } from 'jose' ;import { cookies } from 'next/headers' ;const secret = new TextEncoder ().encode (process.env .JWT_SECRET );export async function createToken (userId: string , role: string ) { return new SignJWT ({ userId, role }) .setProtectedHeader ({ alg : 'HS256' }) .setExpirationTime ('7d' ) .sign (secret); }export async function verifyToken (token: string ) { const { payload } = await jwtVerify (token, secret); return payload as { userId : string ; role : string }; }export async function getCurrentUser ( ) { const cookieStore = await cookies (); const token = cookieStore.get ('auth-token' )?.value ; if (!token) return null ; try { return await verifyToken (token); } catch { return null ; } }export async function POST (request: NextRequest ) { const { email, password } = await request.json (); const user = await authenticateUser (email, password); if (!user) { return NextResponse .json ({ error : 'Invalid credentials' }, { status : 401 }); } const token = await createToken (user.id , user.role ); const response = NextResponse .json ({ success : true }); response.cookies .set ('auth-token' , token, { httpOnly : true , secure : process.env .NODE_ENV === 'production' , sameSite : 'lax' , maxAge : 60 * 60 * 24 * 7 , }); return response; }
部署方案
Vercel 部署(推荐)
1 2 3 4 5 6 7 8 npm i -g vercel vercel vercel --prod
Docker 自托管部署
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 FROM node:20 -alpine AS baseFROM base AS depsWORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable pnpm && pnpm install --frozen-lockfile FROM base AS builderWORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM base AS runnerWORKDIR /app ENV NODE_ENV=productionRUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown =nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown =nextjs:nodejs /app/.next/static ./.next/static USER nextjsEXPOSE 3000 ENV PORT=3000 CMD ["node" , "server.js" ]
1 2 3 4 5 6 7 const nextConfig = { output : 'standalone' , };module .exports = nextConfig;
总结
Next.js App Router 通过 Server Components
实现了真正的服务端优先架构,大幅减少了客户端 JavaScript 体积。Server
Actions
简化了表单处理和数据变更流程,而中间件提供了统一的请求拦截层。在实际项目中,建议默认使用
Server Components,仅在需要交互的部分标记
'use client',并充分利用 layout
嵌套实现页面框架的复用。部署方面,Vercel 提供最佳的开箱即用体验,而
Docker standalone 模式则满足自托管需求。