技術3 閱讀

第 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 指南相同:

text
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 改成單獨的輸出目錄,例如:

text
// 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(在專案根目錄):

text
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

text
// 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

text
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

text
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):

text
// 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

text
'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

text
// 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

text
'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」的方式寫

    • 避免連線數暴漲;

    • 避免把無關欄位傳到前端,後面做效能最佳化會輕鬆很多。

SHARE

分享

分享這篇文章。