arrow_back返回文章列表
技术

第 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 完全可以充当一个小型后端。

我们来写两个端点:

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 },
    )
  }
}

这里有几个点可以注意:

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/... 下加几个页面:

这里我们不引入 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>
  )
}

到这里,一个最小可用的“博客后台”就跑起来了:
你可以在浏览器里:


四、这一篇背后想传达的“Next.js + Prisma 心智模型”

稍微总结一下这一篇想建立的几个原则:

  1. Prisma 对你来说就是“数据访问层”,不要直接暴露给 UI

    • 用 API Route / Server Component 把 Prisma Client 关在“服务端世界”;

    • 对前端暴露的是整理好的 JSON,而不是数据库结构原样。

  2. App Router 下,一般模式是:Server Component 负责读,Client Component 负责写

    • 读:在 Server Component 里直接 blogPrisma.xxxfetch('/api/...')

    • 写:在 Client Component 里调用 /api/...,交给 API Route 中的 Prisma 来修改数据。

  3. 关系查询尽量靠近后端完成

    • 比如“文章 + 作者 + 分类 + 评论数”,最好在 API 里就组装好;

    • 前端只负责展示,不负责跨多表拼装。

  4. 一开始就用“单例 PrismaClient + 精确 select”的方式写

    • 避免连接数暴涨;

    • 避免把无关字段传到前端,后面做性能优化会轻松很多。