Tech3 views

Part 6: Next.js + Prisma + blog-demo Full-Stack Hands-On

In the previous articles, we have mainly taken a "backend perspective" to build the data model and business logic of blog-demo using Prisma.

In this article, we will connect this model to Next.js to create a minimal but complete "personal blog backend":

We won't implement authentication or fancy UI; the focus is:How to elegantly use Prisma in the Next.js App Router to perform these relational queries and writes for blog-demo.


1. Project structure: Setting up Prisma and blog-demo in Next.js

Assuming you already have a Next.js 16.x project (like the one in article 2), we will create a subdirectory structure specifically for blog-demo. The overall approach is the same as the official Prisma Next.js guide:

text
my-next-app/
├─ app/
│  ├─ api/
│  │  ├─ posts/
│  │  │  └─ route.ts        # Article list & creation
│  │  └─ posts/
│  │     └─ [id]/
│  │        └─ comments/
│  │           └─ route.ts   # Add comments to an article
│  ├─ blog/
│  │  ├─ page.tsx            # Article list page
│  │  └─ new/
│  │     └─ page.tsx         # New article page
│  └─ blog/
│     └─ [id]/
│        └─ page.tsx         # Article detail page
├─ lib/
│  └─ prisma-blog.ts         # blog-demo specific PrismaClient
├─ prisma/
│  └─ blog.schema.prisma     # blog-demo schema
├─ prisma.config.ts
└─ .env

You can place the blog-demo schema in a separate file prisma/blog.schema.prisma, or merge it with the existing schema. For clarity, we keep it separate here.

1.1 Prisma configuration: additional schema + client output directory

The content of prisma/blog.schema.prisma is the blog-demo model from article 4, with the generator changed to a separate output directory, e.g.:

text
// prisma/blog.schema.prisma
generator blogClient {
  provider = "prisma-client"
  output   = "../generated/blog"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Then copy the blog-demo User/Profile/Post/Comment/Category/PostCategory models

You can configure multiple schemas in prisma.config.ts or use the default schema. Prisma 7's configuration is quite flexible; here we assume you set blog-demo as the main schema.

Run migration & generate Client (in project root):

text
pnpm prisma migrate dev --name init_blog
pnpm prisma generate

This will generate the blog-demo Prisma Client in generated/blog.

1.2 Creating a PrismaClient singleton in Next.js

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
}

This follows the same pattern we've seen before, ensuring we don't repeatedly create new PrismaClient instances during development (with hot reloading).


2. API layer: Writing blog-demo REST endpoints with App Router

Next.js App Router recommends using app/api/.../route.ts as API Routes, which combined with Prisma can act as a small backend.

Let's write two endpoints:

2.1 /api/posts: Article list & creation

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

    // Map categories from PostCategory[] to 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 and authorId are required' },
        { status: 400 },
      )
    }

    const post = await blogPrisma.$transaction(async (tx) => {
      // 1. Create the article
      const created = await tx.post.create({
        data: {
          title,
          content,
          published: true,
          author: { connect: { id: authorId } },
        },
      })

      // 2. Handle categories (optional)
      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. Return article + categories
      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: 'Failed to create article' }, { status: 500 })
    }

    // Map to a friendlier structure
    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 },
    )
  }
}

A few points to note:

2.2 /api/posts/[id]/comments: Add a comment to an article

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 are required and valid' },
        { 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('Article does not exist')
      if (!user) throw new Error('User does not exist')

      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 === 'Article does not exist' || e.message === 'User does not exist'
      ? e.message
      : 'Failed to create comment'
    const status = message === e.message ? 400 : 500

    return NextResponse.json({ error: message }, { status })
  }
}

This is essentially the API version of safeAddComment from article 5, using a transaction to perform existence checks.


3. Page layer: Displaying blog-demo with App Router

Now that the API is ready, let's add some pages under app/blog/...:

Here we won't introduce any UI library; we'll use plain HTML + Tailwind classes (if you selected Tailwind when creating the project).

3.1 Article list page: /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">Blog Articles</h1>
        <Link
          href="/blog/new"
          className="rounded bg-blue-600 px-3 py-1 text-sm text-white"
        >
          New Article
        </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>Author: {post.author.name}</span>
                <span>Comments: {post.commentCount}</span>
                <span>
                  Categories:
                  {post.categories.length === 0
                    ? 'None'
                    : 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>
  )
}

Note that we use process.env.NEXT_PUBLIC_APP_URL as the base URL; you can set it to http://localhost:3000 in .env.local, and change to your real domain after deployment.

You can also use fetch('/api/posts') (relative path); in App Router Server Components, this is allowed, but in local development make sure the port matches.

3.2 Create article page: /blog/new/page.tsx

We write the create article page as a Client Component to handle the form easily.
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) // Simplified: hardcoded author
  const [categories, setCategories] = useState<Category[]>([])
  const [selectedSlugs, setSelectedSlugs] = useState<string[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    // Simple fetch for categories from API; you can write /api/categories yourself
    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 || 'Creation failed')
      }
      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">New Article</h1>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm mb-1">Title</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">Content</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">Categories (multi-select)</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 ? 'Submitting...' : 'Publish'}
        </button>
      </form>
    </main>
  )
}

Here we assume you have an /api/categories endpoint to list categories; the implementation is similar to /api/posts so we won't repeat it.

3.3 Article detail page: /blog/[id]/page.tsx

Finally, write a detail page that shows the article, comment list, and a form to add comments.

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">Article does not exist</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>Author: {post.author.name}</span>
          <span>
            Published: {post.createdAt.toLocaleString()}
          </span>
          <span>
            Categories:
            {categories.length === 0
              ? 'None'
              : 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">
          Comments ({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>
  )
}

The comment form is written as a 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) // Simplified: current user 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 || 'Comment failed')
      }
      setContent('')
      router.refresh() // Re-render the current 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="Write your comment..."
        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 ? 'Submitting...' : 'Post Comment'}
      </button>
    </form>
  )
}

At this point, a minimal functional "blog backend" is up and running:
You can:


4. The "Next.js + Prisma mental model" behind this article

Let's summarize a few principles this article aims to establish:

  1. Prisma is your "data access layer" — do not expose it directly to the UI

    • Use API Routes / Server Components to keep the Prisma Client inside the "server world";

    • Expose well-structured JSON to the frontend, not the raw database structure.

  2. Under App Router, the typical pattern is: Server Components handle reading, Client Components handle writing

    • Reading: In Server Components, directly use blogPrisma.xxx or fetch('/api/...');

    • Writing: In Client Components, call the /api/... endpoint, letting the API Route use Prisma to modify data.

  3. Try to complete relational queries as close to the backend as possible

    • For example, "article + author + categories + comment count" should be assembled in the API;

    • The frontend only displays, and is not responsible for cross-table joins.

  4. Start by writing with a "singleton PrismaClient + precise select"

    • Avoid explosion of connections;

    • Avoid sending unnecessary fields to the frontend, making performance optimization much easier later.

SHARE

Share

Share this article.