Next.js 15 全栈开发实战

前言

💡 痛点: 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 部署。

相关推荐
fox_lht1 小时前
15.3.改进我们之前的输入、输出项目
开发语言·后端·学习·rust
java1234_小锋2 小时前
LangChain4j 开发Java Agent智能体- 多模态支持
java·开发语言·langchain4j
凡人叶枫2 小时前
Effective C++ 条款23:宁以 non-member、non-friend 替换 member 函数
linux·开发语言·c++·嵌入式开发
张忠琳2 小时前
【Go 1.26.4】Golang Channel 深度解析
开发语言·后端·golang
盈建云系统2 小时前
B2B产品展示网站怎么做?从产品目录到询盘表单,企业获客页面搭建流程
开发语言·网站搭建·开发网站
不会C语言的男孩2 小时前
Linux 系统编程 · 第 4 章:文件属性与元数据
linux·c语言·开发语言
kernelcraft2 小时前
Boto3:Python 操作 AWS 的官方 SDK
开发语言·python·其他·aws
D3bugRealm2 小时前
cryptography:Python 开发者的加密标准库
开发语言·python·其他
Rain5092 小时前
2.1 Nest.js 项目初始化与模块化架构
开发语言·前端·javascript·后端·架构·数据分析·node.js