Chapter 10 ยท Capstone: The Data Layer for a Small Blog API
This chapter brings the whole course together in one small working application: a JSON API for the blog,
built with Express and Prisma. It uses the final schema, every kind of query from Chapters 5–9, and proper
handling of Prisma's errors. Each section names the chapter it comes from.
Step 1: The Final Schema (Chapters 3, 7, 8)
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "sqlite"
}
enum Role {
READER
AUTHOR
ADMIN
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
role Role @default(READER)
profile Profile?
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Profile {
id Int @id @default(autoincrement())
bio String?
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Post {
id Int @id @default(autoincrement())
title String
slug String @unique
content String?
published Boolean @default(false)
viewCount Int @default(0)
publishedAt DateTime?
authorId Int
author User @relation(fields: [authorId], references: [id])
tags Tag[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt@@index([authorId])
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
}
model Comment {
id Int @id @default(autoincrement())
body String
createdAt DateTime @default(now())
postId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
authorId Int?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)@@index([postId])@@index([authorId])
}
Search and tag filters are only added when present, so an empty query can't drop the published
rule (Chapter 6). select keeps author emails out of the response (Chapter 9), and the authors and
tags are loaded with the list, so there's no N+1 problem. The same where feeds both the page and the
count.
findFirst is used rather than findUnique because the condition includes
published, which isn't unique: a draft with this slug should look like it doesn't exist.
Express 5 passes errors from async route handlers to the error handler automatically, so one
function can translate Prisma's error codes for every route:
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === "P2002") return res.status(409).json({ error: "That value is already taken" });
if (err.code === "P2025") return res.status(404).json({ error: "Not found" });
if (err.code === "P2003") return res.status(409).json({ error: "Other records still depend on this one" });
}
console.error(err);
res.status(500).json({ error: "Something went wrong" });
});
const server = app.listen(3000, () => console.log("Blog API on http://localhost:3000"));
// Close the database connection cleanly on shutdown
process.on("SIGTERM", () => server.close(() => prisma.$disconnect()));
Request
Prisma result
HTTP response
Create a post with a slug that exists
P2002 unique constraint
409 Conflict
Publish or view a slug that doesn't exist
P2025 record not found
404 Not Found
Create a post for an unknown author email
connect finds no user (P2025)
404 Not Found
Delete a user who still has posts
Restricted by the foreign key (P2003)
409 Conflict
Trying It Out
npx tsx server.ts
# in another terminalcurl -X POST localhost:3000/posts -H "Content-Type: application/json" \
-d '{"title":"Hello API","slug":"hello-api","authorEmail":"alan@example.com","tags":["prisma","api"]}'curl -X POST localhost:3000/posts/hello-api/publish
curl"localhost:3000/posts?tag=prisma"curl localhost:3000/posts/hello-api
Where Each Part Came From
Chapter
Used in this capstone
1 — Where Prisma Fits
The reason for a typed, schema-first data layer
2 — Project Setup
The Prisma 7 client with a driver adapter, in one shared file
Author, profile and comment relations; Restrict, Cascade, SetNull
8 — Relations II
Tags, connect and connectOrCreate nested writes
9 — Reading Related Data
Nested select, relation filters, no N+1, query logging
What's Deliberately Missing
Authentication and permissions — anyone can create or publish posts. A real API must check who's asking.
Input validation beyond basic checks — a library such as Zod would validate request bodies properly.
Transactions, aggregation, raw SQL, testing, performance and production migrations — the subjects of Prisma Intermediate/Advanced, which starts where this course ends.
Hands-On Exercises
Exercise 1
Add POST /posts/:slug/comments, which lets a guest or a registered user (by optional email) add a comment to a published post. Return 404 for missing or unpublished posts.
Add GET /authors/:email, returning the author's name, bio and their five most-viewed published posts, without exposing their email in the response body.