Chapter 10

实战:全栈 Blog 平台

整合所有章节知识,从零构建包含认证、CRUD、评论、部署的完整应用

🏗️

本章目标构建一个名为 "DevLog" 的技术博客平台,功能包括:GitHub OAuth 登录 / Markdown 文章发布 / 标签系统 / 评论(带乐观更新)/ 分页 / 动态 SEO / 部署到 Vercel。所有代码基于前九章的知识,是一次完整的综合实战。

10.1 项目架构设计

在动手写代码之前,先进行整体架构设计。良好的架构设计可以避免后期大规模重构,提升代码的可维护性和扩展性。

devlog/
├── app/
│   ├── (marketing)/              营销组路由(无侧边栏)
│   │   ├── layout.tsx
│   │   ├── page.tsx            → /(博客首页)
│   │   └── blog/
│   │       ├── page.tsx        → /blog(文章列表)
│   │       └── [slug]/
│   │           └── page.tsx    → /blog/:slug(文章详情)
│   │
│   ├── (dashboard)/              后台管理路由
│   │   ├── layout.tsx            需认证的布局
│   │   └── dashboard/
│   │       ├── page.tsx        → /dashboard(写作台)
│   │       ├── posts/
│   │       │   ├── page.tsx    → /dashboard/posts(文章管理)
│   │       │   └── new/
│   │       │       └── page.tsx→ /dashboard/posts/new(写文章)
│   │       └── settings/
│   │           └── page.tsx    → /dashboard/settings
│   │
│   ├── api/
│   │   ├── auth/[...nextauth]/route.ts
│   │   └── webhooks/revalidate/route.ts
│   │
│   └── layout.tsx                根布局
│
├── components/                   共享组件
│   ├── ui/                       基础 UI 组件
│   └── features/                 业务组件
│
├── lib/
│   ├── db.ts                     Prisma Client 单例
│   ├── auth.ts                   Auth.js 配置
│   └── utils.ts                  工具函数
│
└── prisma/
    └── schema.prisma
STRUCTURE

10.2 数据库 Schema

博客平台需要用户、文章、评论和标签四个核心数据模型,以及用于 Auth.js 的账号关联模型。

// prisma/schema.prisma — 完整博客 Schema

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  bio           String?
  role          Role      @default(USER)
  createdAt     DateTime  @default(now())

  accounts  Account[]
  sessions  Session[]
  posts     Post[]
  comments  Comment[]

  @@map("users")
}

model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  excerpt     String
  content     String    @db.Text
  coverImage  String?
  published   Boolean   @default(false)
  viewCount   Int       @default(0)
  publishedAt DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  authorId  String
  author    User      @relation(fields: [authorId], references: [id])
  comments  Comment[]
  tags      PostTag[]

  @@index([authorId])
  @@index([published, publishedAt])
  @@map("posts")
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  createdAt DateTime @default(now())

  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  postId    String
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@map("comments")
}

model Tag {
  id    String    @id @default(cuid())
  name  String    @unique
  slug  String    @unique
  posts PostTag[]

  @@map("tags")
}

model PostTag {
  postId String
  tagId  String
  post   Post   @relation(fields: [postId], references: [id])
  tag    Tag    @relation(fields: [tagId], references: [id])

  @@id([postId, tagId])
}

// Auth.js 需要的模型
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])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

enum Role { USER EDITOR ADMIN }
PRISMA

10.3 博客首页与文章列表

// app/(marketing)/blog/page.tsx
import { Suspense } from 'react'
import { db } from '@/lib/db'
import { PostCard, PostCardSkeleton } from '@/components/features/post-card'
import { Pagination } from '@/components/ui/pagination'
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: '文章列表',
  description: '探索最新技术文章',
}

const PAGE_SIZE = 9

interface Props {
  searchParams: Promise<{ page?: string; tag?: string }>
}

export default async function BlogListPage({ searchParams }: Props) {
  const { page = '1', tag } = await searchParams
  const currentPage = Math.max(1, Number(page))

  const where = {
    published:  true,
    ...(tag ? { tags: { some: { tag: { slug: tag } } } } : {})
  }

  const [posts, total] = await Promise.all([
    db.post.findMany({
      where,
      skip:    (currentPage - 1) * PAGE_SIZE,
      take:    PAGE_SIZE,
      orderBy: { publishedAt: 'desc' },
      select: {
        id: true, title: true, slug: true, excerpt: true,
        coverImage: true, publishedAt: true, viewCount: true,
        author: { select: { name: true, image: true } },
        tags:   { include: { tag: true } },
        _count: { select: { comments: true } },
      }
    }),
    db.post.count({ where }),
  ])

  return (
    <div>
      <div className="post-grid">
        {posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
      <Pagination
        currentPage={currentPage}
        totalPages={Math.ceil(total / PAGE_SIZE)}
      />
    </div>
  )
}
TSX

10.4 文章详情页与评论系统

// app/(marketing)/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { unstable_cache } from 'next/cache'
import { Suspense } from 'react'
import { marked } from 'marked'          // 服务端,不进 Bundle
import { CommentSection } from '@/components/features/comment-section'
import { db } from '@/lib/db'

const getPost = unstable_cache(
  async (slug: string) => db.post.findUnique({
    where:   { slug, published: true },
    include: { author: true, tags: { include: { tag: true } } }
  }),
  ['post'],
  { tags: ['posts'], revalidate: 3600 }
)

export async function generateStaticParams() {
  const posts = await db.post.findMany({
    where:  { published: true },
    select: { slug: true },
  })
  return posts.map(p => ({ slug: p.slug }))
}

export default async function PostPage({ params }) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()

  // 浏览量递增(异步,不阻塞页面渲染)
  db.post.update({
    where: { id: post.id },
    data:  { viewCount: { increment: 1 } }
  }).catch(console.error)

  const html = await marked(post.content) // 服务端 Markdown 渲染

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />

      {/* 评论区独立加载 */}
      <Suspense fallback={<p>加载评论...</p>}>
        <CommentSection postId={post.id} />
      </Suspense>
    </article>
  )
}
TSX

评论组件(含乐观更新)

'use client'

import { useOptimistic, useTransition } from 'react'
import { addComment } from '@/lib/actions/comment'

interface Comment {
  id: string
  content: string
  author: { name: string | null; image: string | null }
  createdAt: Date
}

export function CommentList({
  postId,
  initialComments,
  userId,
}: {
  postId: string
  initialComments: Comment[]
  userId?: string
}) {
  const [isPending, startTransition] = useTransition()
  const [comments, addOptimisticComment] = useOptimistic(
    initialComments,
    (state, newComment: Comment) => [newComment, ...state]
  )

  async function handleSubmit(formData: FormData) {
    const content = formData.get('content') as string
    if (!content.trim()) return

    startTransition(async () => {
      // 乐观更新:立即在 UI 显示新评论
      addOptimisticComment({
        id:        `optimistic-${Date.now()}`,
        content,
        author:    { name: '你', image: null },
        createdAt: new Date(),
      })

      // 服务端实际创建评论
      await addComment(postId, content)
    })
  }

  return (
    <section>
      <h2>{comments.length} 条评论</h2>

      {userId && (
        <form action={handleSubmit}>
          <textarea name="content" placeholder="写下你的想法..." required />
          <button type="submit" disabled={isPending}>
            {isPending ? '发布中...' : '发布评论'}
          </button>
        </form>
      )}

      {comments.map(comment => (
        <div key={comment.id}
          style={{ opacity: comment.id.startsWith('optimistic') ? 0.6 : 1 }}>
          <p>{comment.author.name}</p>
          <p>{comment.content}</p>
        </div>
      ))}
    </section>
  )
}
TSX

10.5 写作台(后台管理)

// app/(dashboard)/layout.tsx — 后台布局(需认证)
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

export default async function DashboardLayout({ children }) {
  const session = await auth()

  if (!session?.user) {
    redirect('/login?callbackUrl=/dashboard')
  }

  return (
    <div className="dashboard-layout">
      <DashboardNav user={session.user} />
      <main>{children}</main>
    </div>
  )
}
TSX
// lib/actions/post.ts — 文章 CRUD Server Actions
'use server'

import { auth }          from '@/auth'
import { db }            from '@/lib/db'
import { revalidateTag } from 'next/cache'
import { redirect }     from 'next/navigation'
import { generateSlug } from '@/lib/utils'
import { CreatePostSchema } from '@/lib/schemas'

export async function createPost(prevState: ActionState, formData: FormData) {
  const session = await auth()
  if (!session?.user) return { error: '请先登录' }

  const parsed = CreatePostSchema.safeParse({
    title:   formData.get('title'),
    content: formData.get('content'),
    excerpt: formData.get('excerpt'),
    tags:    formData.getAll('tags'),
  })

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors }
  }

  const { title, content, excerpt, tags } = parsed.data
  const slug = await generateSlug(title)

  const post = await db.post.create({
    data: {
      title, content, excerpt, slug,
      authorId: session.user.id,
      tags: {
        create: tags.map(tagId => ({ tag: { connect: { id: tagId } } }))
      }
    }
  })

  revalidateTag('posts')
  redirect(`/dashboard/posts/${post.id}/edit`)
}

export async function publishPost(postId: string) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')

  await db.post.update({
    where: { id: postId, authorId: session.user.id },
    data:  { published: true, publishedAt: new Date() }
  })

  revalidateTag('posts')
}
TS

10.6 部署到 Vercel

Vercel 是 Next.js 的官方部署平台,提供了与 Next.js 深度集成的零配置部署体验。部署流程分为几个步骤:

  1. 推送代码到 GitHub
    创建 GitHub 仓库,将项目代码 push 到 main 分支。
  2. 导入项目到 Vercel
    登录 Vercel,点击"Add New Project",选择 GitHub 仓库,Vercel 自动检测 Next.js 并配置构建设置。
  3. 配置环境变量
    在 Vercel 项目设置中添加所有 .env 中的变量: DATABASE_URLAUTH_SECRETGITHUB_IDGITHUB_SECRETNEXTAUTH_URL 等。
  4. 配置生产数据库
    推荐使用 Vercel Postgres(内置连接池)、Neon 或 Supabase。添加 DATABASE_URL 后在部署日志中确认 prisma migrate deploy 成功执行。
  5. 触发部署
    Push 到 main 分支后,Vercel 自动触发 CI/CD 流程:安装依赖 → 运行 prisma generate → 构建 Next.js → 部署到全球 CDN。
// package.json — 生产部署脚本
{
  "scripts": {
    "build": "prisma generate && next build",
    "postinstall": "prisma generate"
  }
}
JSON
💡

Vercel 的 ISR 与按需重验证Vercel 对 Next.js 的 ISR(增量静态再生成)和 revalidatePath / revalidateTag 有原生支持,可以从 CDN 边缘节点精确失效单个页面的缓存,无需全量重建。这是 Vercel 相比自托管的核心优势之一。

10.7 课程总结

恭喜完成 Next.js 全栈开发教程!回顾本课程的核心知识点:

📁

App Router

文件系统路由、嵌套布局、动态路由、路由组、平行与拦截路由

🖥️

Server Components

RSC 原理、use client/server、边界划分、Hydration 机制

数据获取与缓存

fetch 缓存语义、unstable_cache、Suspense 流式渲染、并行请求

🔄

Server Actions

表单处理、useFormStatus、useActionState、useOptimistic 乐观更新

🗄️

Prisma ORM

Schema 设计、migrate、类型安全 CRUD、关系查询、连接池

🔐

Auth.js v5

OAuth、Credentials、Session 策略、路由保护、middleware

🌐

API & 中间件

Route Handlers、Webhook 处理、Edge Runtime、CORS 配置

🚀

性能与 SEO

next/image、next/font、Metadata API、Bundle 分析、Core Web Vitals

🎓

下一步学习建议学完本教程后,推荐继续深入:Tailwind CSS v4(样式层)、tRPC(类型安全 API)、Turborepo(Monorepo 管理)、Playwright(E2E 测试)、以及 Vercel AI SDK(为 Blog 添加 AI 写作辅助功能)。