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":
List all articles
Create a new article (with categories)
View article details and comments
Add comments to articles
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:
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.:
// 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):
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:
// 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:
GET /api/posts: List articles (with author, comment count, categories)POST /api/posts: Create an article (with categories)POST /api/posts/[id]/comments: Add a comment to an article
2.1 /api/posts: Article list & creation
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 },
},
},
})
// 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:
The GET uses
selectand_countheavily to return only the data needed for the list page;The POST uses
$transactionto bundle the creation of the Post and the association of Categories;For frontend convenience, we perform a "mapping" layer before returning JSON, flattening
PostCategory[]intoCategory[].
2.2 /api/posts/[id]/comments: Add a comment to an article
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 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/...:
/blog: Article list/blog/new: Create new article/blog/[id]: Article details + comments
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):
// 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:
'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:
// 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:
'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:
Open
/blogto see the list;Go to
/blog/newto create an article;Click into an article detail page to view comments and post a comment.
4. The "Next.js + Prisma mental model" behind this article
Let's summarize a few principles this article aims to establish:
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.
Under App Router, the typical pattern is: Server Components handle reading, Client Components handle writing
Reading: In Server Components, directly use
blogPrisma.xxxorfetch('/api/...');Writing: In Client Components, call the
/api/...endpoint, letting the API Route use Prisma to modify data.
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.
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.
Follow on Google
Add HeyBinyang as a preferred source on Google
If you'd like to keep finding my updates through Google, you can mark this site as a preferred source and make it easier to spot in relevant reading flows.
SHARE
Share
Share this article.