Frontend · #nextjs#react#fullstack#ssr

Next.js全栈开发指南

2025.06.04 7 min 2.7k
// 目录 · contents

引言

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
// app/layout.tsx — Root Layout
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>
);
}

// app/dashboard/layout.tsx — Nested Layout
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
// Server Component (default) — no 'use client' directive
import { db } from '@/lib/database';

async function UserList() {
// Direct database access — runs only on server
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>
);
}

// Client Component — needs 'use client' for interactivity
'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
// app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/database';
import { DashboardCharts } from './charts'; // Client Component

export default async function DashboardPage() {
// Fetch data on server
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
// Automatic request deduplication and caching
async function ProductPage({ params }: { params: { id: string } }) {
// Default: cached (equivalent to force-cache)
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then(res => res.json());

// Revalidate every 60 seconds (ISR)
const reviews = await fetch(`https://api.example.com/reviews/${params.id}`, {
next: { revalidate: 60 },
}).then(res => res.json());

// Always fresh (no cache)
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
// app/actions.ts
'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');
}

// Usage in a Server Component form
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
// app/api/users/route.ts
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 }
);
}
}

// app/api/users/[id]/route.ts — Dynamic API route
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
// middleware.ts (root of project)
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';

export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;

// Public routes — skip auth
const publicPaths = ['/login', '/register', '/api/auth'];
if (publicPaths.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}

// Check authentication
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);

// Add user info to headers for downstream use
const response = NextResponse.next();
response.headers.set('x-user-id', payload.userId);
response.headers.set('x-user-role', payload.role);

// Role-based access control
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: [
// Match all paths except static files and api health
'/((?!_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
// lib/auth.ts
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;
}
}

// app/api/auth/login/route.ts
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, // 7 days
});

return response;
}

部署方案

Vercel 部署(推荐)

1
2
3
4
5
6
7
8
# Install Vercel CLI
npm i -g vercel

# Deploy
vercel

# Production deploy
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
# Dockerfile
FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN 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 nextjs
EXPOSE 3000
ENV PORT=3000

CMD ["node", "server.js"]
1
2
3
4
5
6
7
// next.config.js — enable standalone output
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};

module.exports = nextConfig;

总结

Next.js App Router 通过 Server Components 实现了真正的服务端优先架构,大幅减少了客户端 JavaScript 体积。Server Actions 简化了表单处理和数据变更流程,而中间件提供了统一的请求拦截层。在实际项目中,建议默认使用 Server Components,仅在需要交互的部分标记 'use client',并充分利用 layout 嵌套实现页面框架的复用。部署方面,Vercel 提供最佳的开箱即用体验,而 Docker standalone 模式则满足自托管需求。

作者 · authorzt
发布 · date2025-06-04
篇幅 · length2.7k 字 · 7 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论