前言
💡 痛点: Next.js 14/15 的 App Router 和 Server Components 颠覆了传统 React 开发模式?Server Actions 如何替代 API Routes?RSC(React Server Components)和客户端组件如何选择?
🎯 解决方案: 从项目初始化→App Router→Server/Client 组件→Server Actions→数据库集成→认证→部署,系统掌握 Next.js 15 全栈开发。
#mermaid-svg-zdyjnoeN3f9RBsoU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zdyjnoeN3f9RBsoU .error-icon{fill:#552222;}#mermaid-svg-zdyjnoeN3f9RBsoU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zdyjnoeN3f9RBsoU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zdyjnoeN3f9RBsoU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zdyjnoeN3f9RBsoU .marker.cross{stroke:#333333;}#mermaid-svg-zdyjnoeN3f9RBsoU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zdyjnoeN3f9RBsoU p{margin:0;}#mermaid-svg-zdyjnoeN3f9RBsoU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zdyjnoeN3f9RBsoU .cluster-label text{fill:#333;}#mermaid-svg-zdyjnoeN3f9RBsoU .cluster-label span{color:#333;}#mermaid-svg-zdyjnoeN3f9RBsoU .cluster-label span p{background-color:transparent;}#mermaid-svg-zdyjnoeN3f9RBsoU .label text,#mermaid-svg-zdyjnoeN3f9RBsoU span{fill:#333;color:#333;}#mermaid-svg-zdyjnoeN3f9RBsoU .node rect,#mermaid-svg-zdyjnoeN3f9RBsoU .node circle,#mermaid-svg-zdyjnoeN3f9RBsoU .node ellipse,#mermaid-svg-zdyjnoeN3f9RBsoU .node polygon,#mermaid-svg-zdyjnoeN3f9RBsoU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zdyjnoeN3f9RBsoU .rough-node .label text,#mermaid-svg-zdyjnoeN3f9RBsoU .node .label text,#mermaid-svg-zdyjnoeN3f9RBsoU .image-shape .label,#mermaid-svg-zdyjnoeN3f9RBsoU .icon-shape .label{text-anchor:middle;}#mermaid-svg-zdyjnoeN3f9RBsoU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zdyjnoeN3f9RBsoU .rough-node .label,#mermaid-svg-zdyjnoeN3f9RBsoU .node .label,#mermaid-svg-zdyjnoeN3f9RBsoU .image-shape .label,#mermaid-svg-zdyjnoeN3f9RBsoU .icon-shape .label{text-align:center;}#mermaid-svg-zdyjnoeN3f9RBsoU .node.clickable{cursor:pointer;}#mermaid-svg-zdyjnoeN3f9RBsoU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zdyjnoeN3f9RBsoU .arrowheadPath{fill:#333333;}#mermaid-svg-zdyjnoeN3f9RBsoU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zdyjnoeN3f9RBsoU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zdyjnoeN3f9RBsoU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zdyjnoeN3f9RBsoU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zdyjnoeN3f9RBsoU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zdyjnoeN3f9RBsoU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zdyjnoeN3f9RBsoU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zdyjnoeN3f9RBsoU .cluster text{fill:#333;}#mermaid-svg-zdyjnoeN3f9RBsoU .cluster span{color:#333;}#mermaid-svg-zdyjnoeN3f9RBsoU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zdyjnoeN3f9RBsoU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zdyjnoeN3f9RBsoU rect.text{fill:none;stroke-width:0;}#mermaid-svg-zdyjnoeN3f9RBsoU .icon-shape,#mermaid-svg-zdyjnoeN3f9RBsoU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zdyjnoeN3f9RBsoU .icon-shape p,#mermaid-svg-zdyjnoeN3f9RBsoU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zdyjnoeN3f9RBsoU .icon-shape .label rect,#mermaid-svg-zdyjnoeN3f9RBsoU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zdyjnoeN3f9RBsoU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zdyjnoeN3f9RBsoU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zdyjnoeN3f9RBsoU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Next.js 15 架构
渲染模式
数据层
请求生命周期
SSR
服务端渲染
浏览器请求
Middleware
(Edge Runtime)
React Server Components
(RSC)
Prisma
ORM
Redis
缓存
SSG
静态生成
ISR
增量静态再生成
Client Components
= 'use client'
Server Actions
表单/突变
CDN
静态资源
PPR
部分预渲染
Next.js 15 新特性一览:
| 特性 | 说明 | 性能/体验提升 |
|---|---|---|
| 部分预渲染 (PPR) | 动态和静态内容混合渲染 | 首屏更快 |
| 缓存变更 | fetch 默认 no-store,需显式缓存 |
更可预测 |
| Server Actions 增强 | use server 函数支持流式响应 |
更好 UX |
| TurboPack 稳定 | 替代 Webpack,构建速度 10x | 开发体验 |
| Server Components | 服务端组件默认,零客户端 JS | 更小 Bundle |
| Layouts 嵌套 | 嵌套布局自动保留状态 | SPA 体验 |
一、项目初始化与目录结构
1.1 创建项目
bash
# Next.js 15 项目创建(TypeScript + Tailwind CSS)
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*" \
--turbopack \
--yes
cd my-app
# 核心依赖
npm install prisma @prisma/client bcryptjs jose zod
npm install @tanstack/react-query zustand
npm install lucide-react clsx
npm install -D prisma
1.2 目录结构
bash
my-app/
├── prisma/
│ └── schema.prisma # Prisma 数据模型
├── public/ # 静态资源
├── src/
│ ├── app/ # App Router(核心)
│ │ ├── (auth)/ # 路由组(无 URL 前缀)
│ │ │ ├── login/
│ │ │ └── register/
│ │ ├── (main)/ # 主布局组
│ │ │ ├── layout.tsx # 主布局(侧边栏)
│ │ │ ├── page.tsx # 首页 → /
│ │ │ ├── dashboard/
│ │ │ └── settings/
│ │ ├── api/ # API Routes(可选)
│ │ │ └── users/
│ │ │ └── route.ts
│ │ ├── layout.tsx # 根布局
│ │ ├── not-found.tsx # 404 页面
│ │ └── error.tsx # 错误边界
│ ├── components/ # 组件
│ │ ├── ui/ # 基础 UI(Button/Input)
│ │ ├── forms/ # 表单组件
│ │ └── layout/ # 布局组件
│ ├── lib/ # 工具库
│ │ ├── db.ts # Prisma 客户端
│ │ ├── auth.ts # 认证工具
│ │ ├── utils.ts # 工具函数
│ │ └── validators/ # Zod 校验
│ ├── actions/ # Server Actions
│ │ ├── auth-actions.ts
│ │ └── user-actions.ts
│ └── types/ # TypeScript 类型
├── .env.local # 本地环境变量
├── .env.example
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── package.json
二、App Router 核心
2.1 布局与嵌套
tsx
// ===== 根布局(app/layout.tsx)=====
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from '@/components/providers';
const inter = Inter({ subsets: ['latin'] });
// 全局元数据(所有页面生效)
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App', // 页面标题模板
},
description: 'Next.js 15 全栈应用',
openGraph: {
type: 'website',
locale: 'zh_CN',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
// ===== 主布局组(app/(main)/layout.tsx)=====
// 路由组 (main) 不影响 URL
// 嵌套 layout 自动合并,共享状态不丢失
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}
// ===== 页面(app/(main)/page.tsx)=====
// 首页默认服务端渲染
// RSC:数据获取直接在服务端,不需要 useEffect
export default async function HomePage() {
// 直接在服务端查询数据库
const stats = await db.statistics.findUnique({
where: { id: 1 },
});
const recentPosts = await db.post.findMany({
take: 10,
orderBy: { createdAt: 'desc' },
include: { author: { select: { name: true } } },
});
return (
<div className="space-y-6">
<StatsGrid stats={stats} />
<PostList posts={recentPosts} />
</div>
);
}
2.2 动态路由与静态生成
tsx
// ===== 动态路由(app/posts/[slug]/page.tsx)=====
// 静态参数(构建时预渲染)
export async function generateStaticParams() {
const posts = await db.post.findMany({
select: { slug: true },
});
return posts.map((post) => ({
slug: post.slug,
}));
}
// generateStaticParams + fetch 缓存 = SSG
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
// 直接在 RSC 中查询
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true, tags: true },
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// ===== ISR 增量静态再生成 =====
// 静态页面,每 60 秒重新验证
export const revalidate = 60;
export default async function BlogListPage() {
// 这个页面会被缓存,每 60 秒在后台重新生成
const posts = await db.post.findMany({ ... });
return <PostList posts={posts} />;
}
// ===== PPR 部分预渲染(Next.js 15)=====
// 部分预渲染:静态外壳 + 动态岛屿
// npm install next@canary 尝鲜
export const experimental_ppr = true;
// 静态部分立即响应
// 动态部分流式加载
// → 用户看到完整页面骨架,内容逐步填充
export default async function DashboardPage() {
return (
<div>
{/* 静态部分 → 立即渲染 */}
<DashboardHeader />
{/* 动态部分 → 流式渲染 */}
<Suspense fallback={<RevenueSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<OrdersTable />
</Suspense>
</div>
);
}
三、Server Components vs Client Components
3.1 选择原则
tsx
// ===== RSC(Server Components)=====
// 默认,所有组件都是服务端组件
// 特点:
// ✅ 直接访问数据库/文件系统
// ✅ API 密钥不会泄露到客户端
// ✅ 减小客户端 JS Bundle
// ✅ 可以是 async 函数(直接 await)
// ❌ 不能使用 useState/useEffect
// ❌ 不能使用浏览器 API
// ✅ 正确使用 RSC
async function UserProfile({ userId }: { userId: string }) {
// 服务端直接查询
const user = await db.user.findUnique({
where: { id: userId },
select: { name: true, email: true, posts: true },
});
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
{/* 嵌套客户端组件 */}
<FollowButton userId={userId} initialFollowing={false} />
</div>
);
}
// ===== Client Components(='use client')=====
// 必须使用 useState/useEffect/useCallback
// 特点:
// ✅ 交互逻辑(表单、点击、动画)
// ✅ 浏览器 API(localStorage/Geolocation)
// ✅ 第三方客户端库
// ❌ 不能直接访问数据库
// ❌ 减小 Bundle 需要更多优化
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export function FollowButton({
userId,
initialFollowing
}: {
userId: string;
initialFollowing: boolean;
}) {
const [following, setFollowing] = useState(initialFollowing);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleFollow = async () => {
setLoading(true);
// 调用 Server Action
await toggleFollow(userId);
setFollowing(!following);
setLoading(false);
// 刷新当前页面 RSC
router.refresh();
};
return (
<button
onClick={handleFollow}
disabled={loading}
className={following ? 'bg-blue-500' : 'bg-gray-200'}
>
{loading ? '加载中...' : following ? '已关注' : '关注'}
</button>
);
}
// ===== 混合使用模式 =====
// ✅ 模式1:数据在 RSC 获取,UI 交互在 Client Component
// app/dashboard/page.tsx(RSC)
export default async function Dashboard() {
const data = await fetchDashboardData(); // RSC 获取数据
return <DashboardUI data={data} />; // 传给客户端组件
}
// components/dashboard-ui.tsx(Client)
'use client';
export function DashboardUI({ data }: { data: DashboardData }) {
// 只处理交互逻辑
const [filter, setFilter] = useState('all');
return <div>...</div>;
}
// ✅ 模式2:只在叶子节点使用 Client Component
// 大型表单只在提交按钮处使用 Client Component
// ✅ 模式3:组合模式(RSC 传 props,Client 接收)
3.2 数据获取与缓存
tsx
// ===== 数据获取方式 =====
// 方式1:Prisma 直接查询(RSC 内)
async function getUserData() {
return await db.user.findMany({
where: { active: true },
orderBy: { createdAt: 'desc' },
});
}
// 方式2:fetch with Next.js 缓存
async function getRemoteData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // 1 小时重新验证
// next: { tags: ['users'] }, // 按标签重新验证
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
return res.json();
}
// 方式3:React Cache(请求去重)
import { cache } from 'react';
// 同一请求多次调用只执行一次
export const getUser = cache(async (id: string) => {
return await db.user.findUnique({ where: { id } });
});
// 页面中使用
export default async function Page({ params }: { params: { id: string } }) {
// getUser 被调用两次但只查一次数据库
const user = await getUser(params.id);
const settings = await getUser(params.id);
return <div>{user.name}</div>;
}
// ===== 按标签重新验证 =====
// 在 Server Action 或 API Route 中
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.post.create({ data: { title } });
// 重新验证标记为 'posts' 的缓存
revalidateTag('posts');
}
// fetch 时标记
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
四、Server Actions
4.1 基础用法
tsx
// ===== Server Action 定义(app/actions/form-actions.ts)=====
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
// Zod 表单校验
const CreatePostSchema = z.object({
title: z.string().min(1, '标题不能为空').max(100),
content: z.string().min(10, '内容至少 10 个字符'),
published: z.boolean().default(false),
});
export type CreatePostState = {
errors?: {
title?: string[];
content?: string[];
};
message?: string;
};
export async function createPost(
prevState: CreatePostState,
formData: FormData
): Promise<CreatePostState> {
// 1. 验证用户
const session = await auth();
if (!session?.user?.id) {
return { message: '未登录' };
}
// 2. 校验数据
const validatedFields = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: '数据校验失败',
};
}
// 3. 操作数据库
try {
await db.post.create({
data: {
title: validatedFields.data.title,
content: validatedFields.data.content,
published: validatedFields.data.published,
authorId: session.user.id,
},
});
} catch (error) {
return { message: '数据库错误' };
}
// 4. 清除缓存 + 跳转
revalidatePath('/posts');
redirect('/posts');
}
// ===== 在表单中使用 =====
// components/post-form.tsx
'use client';
import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions/form-actions';
export function PostForm() {
const initialState: CreatePostState = {};
const [state, formAction] = useFormState(createPost, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label>标题</label>
<input
name="title"
type="text"
className="border p-2 w-full"
/>
{state.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
</div>
<div>
<label>内容</label>
<textarea
name="content"
rows={10}
className="border p-2 w-full"
/>
{state.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
<label className="flex items-center gap-2">
<input type="checkbox" name="published" />
<span>立即发布</span>
</label>
{state.message && (
<p className="text-red-500">{state.message}</p>
)}
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
创建文章
</button>
</form>
);
}
4.2 文件上传与渐进增强
tsx
// ===== 文件上传 Server Action =====
// app/actions/upload-actions.ts
'use server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function uploadAvatar(
prevState: { message: string; url?: string },
formData: FormData
): Promise<{ message: string; url?: string }> {
const session = await auth();
if (!session?.user?.id) {
return { message: '请先登录' };
}
const file = formData.get('avatar') as File;
if (!file || file.size === 0) {
return { message: '请选择文件' };
}
// 校验文件类型和大小
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return { message: '只支持 JPG/PNG/WebP 格式' };
}
if (file.size > 5 * 1024 * 1024) {
return { message: '文件大小不能超过 5MB' };
}
// 生成唯一文件名
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const ext = file.name.split('.').pop();
const filename = `${session.user.id}-${Date.now()}.${ext}`;
const uploadDir = path.join(process.cwd(), 'public', 'avatars');
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, filename), buffer);
// 更新数据库
const avatarUrl = `/avatars/${filename}`;
await db.user.update({
where: { id: session.user.id },
data: { avatar: avatarUrl },
});
revalidatePath('/settings');
return { message: '上传成功', url: avatarUrl };
}
// ===== 带上传进度的客户端组件 =====
'use client';
import { useState, useRef } from 'react';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '上传中...' : '上传头像'}
</button>
);
}
export function AvatarUploader() {
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const formRef = useRef<HTMLFormElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
}
};
return (
<form
ref={formRef}
action={async (formData) => {
setUploading(true);
setProgress(0);
// 模拟进度
const interval = setInterval(() => {
setProgress(p => Math.min(p + 10, 90));
}, 100);
await uploadAvatar({ message: '' }, formData);
clearInterval(interval);
setProgress(100);
setUploading(false);
}}
>
{preview && (
<img src={preview} alt="预览" className="w-24 h-24 rounded-full object-cover" />
)}
<input
type="file"
name="avatar"
accept="image/*"
onChange={handleFileChange}
className="file:mr-4 file:py-2 file:px-4 file:rounded"
/>
{uploading && (
<div className="w-full bg-gray-200 rounded h-2">
<div
className="bg-blue-500 h-2 rounded transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
<SubmitButton />
</form>
);
}
五、数据库集成(Prisma ORM)
5.1 Schema 设计
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String // bcrypt 哈希
avatar String?
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
accounts Account[] // OAuth 账户
sessions Session[] // 会话
@@map("users")
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String @db.Text
published Boolean @default(false)
viewCount Int @default(0)
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
tags Tag[]
categories Category[]
@@index([authorId])
@@index([published, createdAt])
@@map("posts")
}
model Tag {
id String @id @default(cuid())
name String @unique
slug String @unique
posts Post[]
@@map("tags")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
// 部署时迁移
// npx prisma migrate dev --name init
// npx prisma generate
5.2 Prisma 客户端封装
typescript
// lib/db.ts
import { PrismaClient } from '@prisma/client';
// 全局单例(避免开发模式热重载重复创建)
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
// ===== Prisma 工具函数 =====
// 分页查询
export async function paginate<T extends keyof PrismaClient>(
model: T,
args: Parameters<PrismaClient[T]>[0] extends never ? any : any
) {
const { page = 1, pageSize = 10 } = args;
const [data, total] = await Promise.all([
db[model as string].findMany({
...args,
take: pageSize,
skip: (page - 1) * pageSize,
}),
db[model as string].count({ where: (args as any).where }),
]);
return {
data,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
// 软删除(逻辑删除)
export async function softDelete(id: string) {
return db.post.update({
where: { id },
data: { deletedAt: new Date() },
});
}
// 乐观锁更新
export async function updateWithOptimisticLock(
id: string,
expectedVersion: number,
data: Partial<Post>
) {
return db.$transaction(async (tx) => {
const result = await tx.post.updateMany({
where: { id, version: expectedVersion },
data: { ...data, version: expectedVersion + 1 },
});
if (result.count === 0) {
throw new Error('数据已被修改,请刷新后重试');
}
return tx.post.findUnique({ where: { id } });
});
}
六、认证(NextAuth.js v5)
6.1 配置与 Provider
typescript
// lib/auth.ts(NextAuth v5)
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import Credentials from 'next-auth/providers/credentials';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { LoginSchema } from '@/lib/validators/auth';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: 'jwt' },
pages: {
signIn: '/login',
error: '/login',
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Credentials({
name: 'credentials',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
const validated = LoginSchema.safeParse(credentials);
if (!validated.success) return null;
const { email, password } = validated.data;
const user = await db.user.findUnique({ where: { email } });
if (!user || !user.password) return null;
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
image: user.avatar,
};
},
}),
],
callbacks: {
async jwt({ token, user, account }) {
if (user) {
token.id = user.id;
}
if (account?.provider === 'credentials') {
token.provider = 'credentials';
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.provider = token.provider as string;
}
return session;
},
},
events: {
async createUser({ user }) {
// 新用户创建时发送欢迎邮件
await sendWelcomeEmail(user.email!);
},
},
});
// ===== 保护路由 =====
// app/(main)/layout.tsx
export default async function MainLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session) {
redirect('/login');
}
return <>{children}</>;
}
// ===== 获取当前用户 =====
// 在 RSC 中直接使用
export default async function ProfilePage() {
const session = await auth();
// session.user.id / session.user.email
const user = await db.user.findUnique({
where: { id: session!.user.id },
select: { name: true, email: true, avatar: true },
});
return <UserProfile user={user} />;
}
6.2 中间件保护
typescript
// middleware.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isOnAuthPage = req.nextUrl.pathname.startsWith('/login')
|| req.nextUrl.pathname.startsWith('/register');
const isOnApiAuth = req.nextUrl.pathname.startsWith('/api/auth');
const isPublic = req.nextUrl.pathname === '/';
// 已登录用户访问登录页 → 跳首页
if (isLoggedIn && isOnAuthPage) {
return NextResponse.redirect(new URL('/', req.url));
}
// 未登录用户访问受保护页 → 跳登录
if (!isLoggedIn && !isOnAuthPage && !isOnApiAuth && !isPublic) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', req.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
});
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)'],
};
七、样式与 UI 组件
7.1 Tailwind CSS 配置
typescript
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: 'class', // 支持 dark mode
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
500: '#0ea5e9',
900: '#0c4a6e',
},
},
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
mono: ['var(--font-jetbrains)', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
slideUp: { from: { transform: 'translateY(10px)' }, to: { transform: 'translateY(0)' } },
},
},
},
plugins: [
require('@tailwindcss/typography'), // prose 类
require('@tailwindcss/forms'), // 表单样式
require('tailwindcss-animate'), // 动画
],
};
export default config;
7.2 基础 UI 组件
tsx
// components/ui/button.tsx
import { forwardRef, ButtonHTMLAttributes } from 'react';
import { clsx } from 'clsx';
import { Loader2 } from 'lucide-react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg' | 'icon';
loading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', loading, disabled, children, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || loading}
className={clsx(
// 基础样式
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
// 变体
{
'bg-primary-500 text-white hover:bg-primary-900': variant === 'primary',
'bg-gray-100 text-gray-900 hover:bg-gray-200': variant === 'secondary',
'border border-gray-300 bg-transparent hover:bg-gray-100': variant === 'outline',
'bg-transparent hover:bg-gray-100': variant === 'ghost',
'bg-red-500 text-white hover:bg-red-600': variant === 'destructive',
},
// 尺寸
{
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4 text-sm': size === 'md',
'h-12 px-6 text-base': size === 'lg',
'h-10 w-10 p-0': size === 'icon',
},
className
)}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
);
}
);
Button.displayName = 'Button';
export { Button };
八、部署与优化
8.1 构建优化
typescript
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 实验性特性
experimental: {
// 部分预渲染(PPR)
ppr: true,
// 松弛的 React 严格模式
reactStrictMode: true,
},
// 图片优化
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.unsplash.com' },
{ protocol: 'https', hostname: 'avatars.githubusercontent.com' },
],
formats: ['image/avif', 'image/webp'], // AVIF 优先
deviceSizes: [640, 750, 828, 1080, 1200],
},
// 编译优化
swcMinify: true,
// 压缩
compress: true,
// 重定向
async redirects() {
return [
{ source: '/blog/:slug', destination: '/posts/:slug', permanent: true },
];
},
// 请求头
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
],
},
];
},
};
export default nextConfig;
8.2 Docker 部署
dockerfile
# Dockerfile(多阶段构建)
FROM node:20-alpine AS base
WORKDIR /app
# 依赖安装阶段
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci
# 构建阶段
FROM deps AS builder
COPY . .
RUN npx prisma generate
RUN npm run build
# 生产镜像
FROM base AS runner
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"]
yaml
# docker-compose.prod.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: always
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://user:pass@db:5432/mydb
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: https://myapp.com
depends_on:
db:
condition: service_healthy
db:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_DB: mydb
POSTGRES_USER: user
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: always
volumes:
- redis-data:/data
volumes:
postgres-data:
redis-data:
8.3 Vercel 部署
bash
# 部署到 Vercel(推荐方式)
npm i -g vercel
vercel
# 或 GitHub 集成(自动部署)
# Push 到 main 分支 → 自动构建 + 预览部署
# 环境变量(在 Vercel Dashboard 设置)
# DATABASE_URL
# NEXTAUTH_SECRET
# NEXTAUTH_URL
# GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
# GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
typescript
// vercel.json(可选)
{
"regions": ["iad1", "hnd1"], // 多区域部署
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}
]
}
九、总结
技术全景
| 层 | 核心概念 | 关键点 |
|---|---|---|
| App Router | layout/page/loading/error | 嵌套布局自动复用状态 |
| RSC | 服务端组件默认 | 直接 DB 查询,零客户端 JS |
| Client Component | use client |
交互逻辑,表单/动画/状态 |
| Server Actions | use server |
替代 API Routes,渐进增强 |
| 缓存 | revalidate + tags | 按标签精准失效 |
| Prisma | 类型安全 ORM | Schema → 迁移 → 客户端 |
| NextAuth v5 | JWT Session | OAuth + Credentials |
| 样式 | Tailwind CSS | 原子化 + 暗模式 + 主题 |
| 部署 | Docker + Vercel | 独立模式 + 边缘网络 |
RSC vs Client Component 决策树
组件需要交互?
├── 否 → 默认 RSC ✅
│ ├── 需要访问 DB/FS/API?→ RSC ✅
│ └── 需要 Server Actions?→ RSC ✅
└── 是 → 需要浏览器 API(localStorage/Geolocation)?
├── 是 → Client Component ✅
└── 否 → 可以包装为 Client 叶子节点
父级保持 RSC
最佳实践
| 实践 | 说明 |
|---|---|
| 默认 RSC | 能用 RSC 就不用 Client Component |
| 类型安全 | Prisma 生成类型贯穿全栈 |
| Server Actions | 表单 + 突变用 Server Actions 替代 API Routes |
| 渐进增强 | 表单即使无 JS 也能提交 |
| 流式渲染 | Suspense + 骨架屏避免白屏 |
| PPR | Next.js 15 部分预渲染优化首屏 |
| 图片优化 | next/image 自动格式转换(AVIF/WebP) |
| 环境变量 | 敏感值在 Server Components 中用 process.env |
本文涵盖 Next.js 15 全栈开发完整知识:App Router 目录结构与嵌套布局、RSC 与 Client Component 选择原则与混合模式、Server Actions 表单与文件上传、Prisma ORM Schema 设计 + 分页/软删除/乐观锁、NextAuth v5 OAuth + Credentials 认证、中间件路由保护、Tailwind CSS 配置与 UI 组件库、Docker 多阶段构建 + Vercel 部署。