第 6 篇:Next.js + Prisma + blog-demo 的全栈实战

前几篇,我们主要站在“后端视角”用 Prisma 搭好了 blog-demo 的数据模型和业务链路。
这一篇,我们把这套模型接到 Next.js 上,做一个最小但完整的“个人博客后台”:
列出所有文章
创建新文章(带分类)
查看单篇文章详情和评论
给文章添加评论
不做鉴权、不做 UI 花活,重点是:怎样在 Next.js App Router 里,优雅地使用 Prisma 来跑 blog-demo 的这些关系查询和写入。
一、项目结构:在 Next.js 里安顿好 Prisma 和 blog-demo
我们假定你已经有一个 Next.js 16.x 项目(第 2 篇那种),接下来专门为 blog-demo 建一个子目录结构。整体思路和 Prisma 官方的 Next.js 指南相同:
my-next-app/
├─ app/
│ ├─ api/
│ │ ├─ posts/
│ │ │ └─ route.ts # 文章列表 & 创建
│ │ └─ posts/
│ │ └─ [id]/
│ │ └─ comments/
│ │ └─ route.ts # 为某文章添加评论
│ ├─ blog/
│ │ ├─ page.tsx # 文章列表页
│ │ └─ new/
│ │ └─ page.tsx # 新建文章页
│ └─ blog/
│ └─ [id]/
│ └─ page.tsx # 文章详情页
├─ lib/
│ └─ prisma-blog.ts # blog-demo 专用 PrismaClient
├─ prisma/
│ └─ blog.schema.prisma # blog-demo 的 schema
├─ prisma.config.ts
└─ .env
你可以把 blog-demo 的 schema 放在单独文件 prisma/blog.schema.prisma,也可以和原有 schema 合并,这里为了讲解清晰就单独放。
1.1 Prisma 配置:额外一个 schema + client 输出目录
prisma/blog.schema.prisma 内容,就是第 4 篇里那份 blog-demo 模型,generator 改成单独的输出目录,例如:
// prisma/blog.schema.prisma
generator blogClient {
provider = "prisma-client"
output = "../generated/blog"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 后面照搬 blog-demo 的 User/Profile/Post/Comment/Category/PostCategory 模型
prisma.config.ts 里可以配置多个 schema 或使用默认的 schema,Prisma 7 的配置方式比较灵活,这里假设你以 blog-demo 作为当前主 schema 就可以。
执行迁移 & 生成 Client(在项目根目录):
pnpm prisma migrate dev --name init_blog
pnpm prisma generate
这样就会在 generated/blog 下生成 blog-demo 对应的 Prisma Client。
1.2 在 Next.js 里创建 PrismaClient 单例
lib/prisma-blog.ts:
// lib/prisma-blog.ts
import { PrismaClient } from '../generated/blog'
import { PrismaPg } from '@prisma/adapter-pg'
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL is not set')
}
const adapter = new PrismaPg({ connectionString })
const globalForPrisma = globalThis as unknown as {
blogPrisma?: PrismaClient
}
export const blogPrisma =
globalForPrisma.blogPrisma ??
new PrismaClient({
adapter,
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.blogPrisma = blogPrisma
}
这一段和我们以前讲的模式一样,确保在开发环境(有热重载)时不会不断 new PrismaClient。
二、API 层:用 App Router 写 blog-demo 的 REST 接口
Next.js App Router 推荐使用 app/api/.../route.ts 作为 API Route,结合 Prisma 完全可以充当一个小型后端。
我们来写两个端点:
GET /api/posts:列出文章(含作者、评论数、分类)POST /api/posts:创建文章(含分类)POST /api/posts/[id]/comments:为文章添加评论
2.1 /api/posts:文章列表 & 创建
app/api/posts/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { blogPrisma } from '@/lib/prisma-blog'
// GET /api/posts
export async function GET() {
try {
const posts = await blogPrisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
createdAt: true,
author: {
select: {
id: true,
name: true,
},
},
categories: {
select: {
category: {
select: { id: true, name: true, slug: true },
},
},
},
_count: {
select: { comments: true },
},
},
})
// 把 categories 从 PostCategory[] 映射成 Category[]
const result = posts.map((p) => ({
id: p.id,
title: p.title,
createdAt: p.createdAt,
author: p.author,
commentCount: p._count.comments,
categories: p.categories.map((pc) => pc.category),
}))
return NextResponse.json(result)
} catch (e) {
console.error(e)
return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 })
}
}
// POST /api/posts
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { title, content, authorId, categorySlugs } = body
if (!title || !authorId) {
return NextResponse.json(
{ error: 'title 和 authorId 必填' },
{ status: 400 },
)
}
const post = await blogPrisma.$transaction(async (tx) => {
// 1. 创建文章
const created = await tx.post.create({
data: {
title,
content,
published: true,
author: { connect: { id: authorId } },
},
})
// 2. 分类处理(可选)
if (Array.isArray(categorySlugs) && categorySlugs.length > 0) {
const categories = await tx.category.findMany({
where: { slug: { in: categorySlugs } },
})
if (categories.length > 0) {
await tx.postCategory.createMany({
data: categories.map((c) => ({
postId: created.id,
categoryId: c.id,
})),
skipDuplicates: true,
})
}
}
// 3. 返回文章 + 分类
const withCategories = await tx.post.findUnique({
where: { id: created.id },
include: {
author: { select: { id: true, name: true } },
categories: {
include: { category: true },
},
},
})
return withCategories
})
if (!post) {
return NextResponse.json({ error: '创建文章失败' }, { status: 500 })
}
// 映射出更友好的结构
const result = {
id: post.id,
title: post.title,
content: post.content,
createdAt: post.createdAt,
author: post.author,
categories: post.categories.map((pc) => pc.category),
}
return NextResponse.json(result, { status: 201 })
} catch (e: any) {
console.error(e)
return NextResponse.json(
{ error: e.message || 'Failed to create post' },
{ status: 500 },
)
}
}
这里有几个点可以注意:
GET 里大量使用
select和_count,只返回列表页真正需要的数据;POST 用
$transaction把创建 Post 和关联 Category 的操作包在一起;为了前端使用方便,我们在返回 JSON 前做了一层“映射”,把
PostCategory[]展平成Category[]。
2.2 /api/posts/[id]/comments:为文章添加评论
app/api/posts/[id]/comments/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { blogPrisma } from '@/lib/prisma-blog'
type RouteParams = {
params: {
id: string
}
}
// POST /api/posts/[id]/comments
export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const postId = Number(params.id)
const body = await req.json()
const { authorId, content } = body
if (!content || !authorId || !Number.isFinite(postId)) {
return NextResponse.json(
{ error: 'content, authorId, postId 必填且有效' },
{ status: 400 },
)
}
const comment = await blogPrisma.$transaction(async (tx) => {
const [post, user] = await Promise.all([
tx.post.findUnique({ where: { id: postId }, select: { id: true } }),
tx.user.findUnique({ where: { id: authorId }, select: { id: true } }),
])
if (!post) throw new Error('文章不存在')
if (!user) throw new Error('用户不存在')
return await tx.comment.create({
data: {
content,
postId,
authorId,
},
include: {
author: { select: { id: true, name: true } },
},
})
})
return NextResponse.json(comment, { status: 201 })
} catch (e: any) {
console.error(e)
const message = e.message === '文章不存在' || e.message === '用户不存在'
? e.message
: 'Failed to create comment'
const status = message === e.message ? 400 : 500
return NextResponse.json({ error: message }, { status })
}
}
这基本就是第 5 篇里 safeAddComment 的 API 化版本,用事务顺带做存在性检查。
三、页面层:用 App Router 展示 blog-demo
API 写好了,接下来在 app/blog/... 下加几个页面:
/blog:文章列表/blog/new:新建文章/blog/[id]:文章详情 + 评论
这里我们不引入 UI 库,只用最简单的 HTML + Tailwind 类名(如果你在创建项目时选择了 Tailwind)。
3.1 文章列表页:/blog/page.tsx
app/blog/page.tsx(Server Component):
// app/blog/page.tsx
import Link from 'next/link'
type PostListItem = {
id: number
title: string
createdAt: string
author: {
id: number
name: string
}
commentCount: number
categories: { id: number; name: string; slug: string }[]
}
async function fetchPosts(): Promise<PostListItem[]> {
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/posts`, {
cache: 'no-store',
})
if (!res.ok) {
throw new Error('Failed to fetch posts')
}
return res.json()
}
export default async function BlogListPage() {
const posts = await fetchPosts()
return (
<main className="mx-auto max-w-3xl px-4 py-8 space-y-4">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-bold">博客文章</h1>
<Link
href="/blog/new"
className="rounded bg-blue-600 px-3 py-1 text-sm text-white"
>
新建文章
</Link>
</header>
<ul className="space-y-3">
{posts.map((post) => (
<li
key={post.id}
className="rounded border px-4 py-3 flex justify-between gap-4"
>
<div className="space-y-1">
<Link
href={`/blog/${post.id}`}
className="text-lg font-medium hover:underline"
>
{post.title}
</Link>
<div className="text-xs text-gray-500 space-x-2">
<span>作者:{post.author.name}</span>
<span>评论:{post.commentCount}</span>
<span>
分类:
{post.categories.length === 0
? '无'
: post.categories.map((c) => c.name).join(' / ')}
</span>
</div>
</div>
<span className="text-xs text-gray-400">
{new Date(post.createdAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
</main>
)
}
注意这里使用了 process.env.NEXT_PUBLIC_APP_URL 作为基准 URL,你可以在 .env.local 里设置为 http://localhost:3000,部署后改成真实域名。
也可以用 fetch('/api/posts')(相对路径),在 App Router 的 Server Component 中,这是被允许的,只是本地开发要注意端口匹配。
3.2 新建文章页:/blog/new/page.tsx
新建文章页以 Client Component 的方式写,方便处理表单。app/blog/new/page.tsx:
'use client'
import { FormEvent, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
type Category = {
id: number
name: string
slug: string
}
export default function NewPostPage() {
const router = useRouter()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [authorId, setAuthorId] = useState<number>(1) // 简化:硬编码作者
const [categories, setCategories] = useState<Category[]>([])
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
// 简单从 API 拉分类列表,你可以自己写 /api/categories
async function fetchCategories() {
try {
const res = await fetch('/api/categories')
if (!res.ok) return
const data = await res.json()
setCategories(data)
} catch (e) {
console.error(e)
}
}
fetchCategories()
}, [])
function toggleCategory(slug: string) {
setSelectedSlugs((prev) =>
prev.includes(slug)
? prev.filter((s) => s !== slug)
: [...prev, slug],
)
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
content,
authorId,
categorySlugs: selectedSlugs,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || '创建失败')
}
router.push(`/blog/${data.id}`)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<main className="mx-auto max-w-2xl px-4 py-8 space-y-4">
<h1 className="text-2xl font-bold">新建文章</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm mb-1">标题</label>
<input
className="w-full rounded border px-3 py-2 text-sm"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm mb-1">内容</label>
<textarea
className="w-full rounded border px-3 py-2 text-sm min-h-[120px]"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</div>
<div>
<label className="block text-sm mb-1">分类(可多选)</label>
<div className="flex flex-wrap gap-2">
{categories.map((c) => {
const active = selectedSlugs.includes(c.slug)
return (
<button
type="button"
key={c.id}
onClick={() => toggleCategory(c.slug)}
className={`rounded border px-2 py-1 text-xs ${
active
? 'bg-blue-600 text-white border-blue-600'
: 'text-gray-700'
}`}
>
{c.name}
</button>
)
})}
</div>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-60"
>
{loading ? '提交中...' : '发布'}
</button>
</form>
</main>
)
}
这里假定你有一个 /api/categories 用于列出分类,写法和前面的 /api/posts 很类似,就不重复展开了。
3.3 文章详情页:/blog/[id]/page.tsx
最后写一个详情页,展示文章 + 评论列表 + 新增评论表单。
app/blog/[id]/page.tsx:
// app/blog/[id]/page.tsx
import { blogPrisma } from '@/lib/prisma-blog'
import CommentForm from './comment-form'
type PageParams = { params: { id: string } }
export default async function PostDetailPage({ params }: PageParams) {
const postId = Number(params.id)
const post = await blogPrisma.post.findUnique({
where: { id: postId },
include: {
author: { select: { id: true, name: true } },
categories: {
include: { category: true },
},
comments: {
orderBy: { createdAt: 'desc' },
include: {
author: { select: { id: true, name: true } },
},
},
},
})
if (!post) {
return <main className="p-8">文章不存在</main>
}
const categories = post.categories.map((pc) => pc.category)
return (
<main className="mx-auto max-w-3xl px-4 py-8 space-y-4">
<article className="space-y-2">
<h1 className="text-2xl font-bold">{post.title}</h1>
<div className="text-xs text-gray-500 space-x-2">
<span>作者:{post.author.name}</span>
<span>
发布于:{post.createdAt.toLocaleString()}
</span>
<span>
分类:
{categories.length === 0
? '无'
: categories.map((c) => c.name).join(' / ')}
</span>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-relaxed">
{post.content}
</p>
</article>
<section className="space-y-3 border-t pt-4">
<h2 className="text-lg font-semibold">
评论({post.comments.length})
</h2>
<CommentForm postId={post.id} />
<ul className="space-y-2">
{post.comments.map((c) => (
<li key={c.id} className="rounded border px-3 py-2">
<div className="text-xs text-gray-500 mb-1">
{c.author.name} · {c.createdAt.toLocaleString()}
</div>
<p className="text-sm">{c.content}</p>
</li>
))}
</ul>
</section>
</main>
)
}
评论表单用 Client Component 来写:app/blog/[id]/comment-form.tsx:
'use client'
import { FormEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
export default function CommentForm({ postId }: { postId: number }) {
const router = useRouter()
const [content, setContent] = useState('')
const [authorId, setAuthorId] = useState<number>(1) // 简化:当前登录用户 id
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authorId, content }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || '评论失败')
}
setContent('')
router.refresh() // 重新渲染当前 Server Component
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-2">
<textarea
className="w-full rounded border px-3 py-2 text-sm min-h-[80px]"
placeholder="写下你的评论..."
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-3 py-1 text-xs text-white disabled:opacity-60"
>
{loading ? '提交中...' : '发表评论'}
</button>
</form>
)
}
到这里,一个最小可用的“博客后台”就跑起来了:
你可以在浏览器里:
打开
/blog看列表;去
/blog/new创建文章;点进文章详情页看评论并发评论。
四、这一篇背后想传达的“Next.js + Prisma 心智模型”
稍微总结一下这一篇想建立的几个原则:
Prisma 对你来说就是“数据访问层”,不要直接暴露给 UI
用 API Route / Server Component 把 Prisma Client 关在“服务端世界”;
对前端暴露的是整理好的 JSON,而不是数据库结构原样。
App Router 下,一般模式是:Server Component 负责读,Client Component 负责写
读:在 Server Component 里直接
blogPrisma.xxx或fetch('/api/...');写:在 Client Component 里调用
/api/...,交给 API Route 中的 Prisma 来修改数据。
关系查询尽量靠近后端完成
比如“文章 + 作者 + 分类 + 评论数”,最好在 API 里就组装好;
前端只负责展示,不负责跨多表拼装。
一开始就用“单例 PrismaClient + 精确 select”的方式写
避免连接数暴涨;
避免把无关字段传到前端,后面做性能优化会轻松很多。