本章目标构建一个名为 "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 深度集成的零配置部署体验。部署流程分为几个步骤:
-
推送代码到 GitHub
创建 GitHub 仓库,将项目代码 push 到 main 分支。 -
导入项目到 Vercel
登录 Vercel,点击"Add New Project",选择 GitHub 仓库,Vercel 自动检测 Next.js 并配置构建设置。 -
配置环境变量
在 Vercel 项目设置中添加所有.env中的变量:DATABASE_URL、AUTH_SECRET、GITHUB_ID、GITHUB_SECRET、NEXTAUTH_URL等。 -
配置生产数据库
推荐使用 Vercel Postgres(内置连接池)、Neon 或 Supabase。添加DATABASE_URL后在部署日志中确认prisma migrate deploy成功执行。 -
触发部署
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 写作辅助功能)。