Building a Custom CMS with Prisma and Next.js
A look into how combining Next.js App Router with Prisma ORM creates a rapid, type-safe development environment for custom content management systems.
Author

The power of Prisma
Prisma makes database modelling intuitive. You describe your data once, in a schema file, and get a fully typed client out of it — one that knows your tables, your columns, and your relations, and refuses to compile when you ask for a field that does not exist.
Combined with Next.js Server Components you can query that client directly from the component that renders the data. No API layer, no serialisation step, no network hop inside your own application.
Why not just use WordPress?
It is a fair question, and for a blog the honest answer is often "you could".
It stops being the right answer the moment your content is not articles. A property agency has listings with prices, locations and photo galleries. A restaurant has a menu that changes. A workshop has jobs moving through states. Modelling any of those in a system designed for posts means either bending it out of shape with custom fields, or installing three plugins that each want a subscription and none of which agree about anything.
A custom CMS starts from the shape of the actual business:
model Project {
id String @id @default(cuid())
title String
slug String @unique
summary String
coverImageUrl String?
featured Boolean @default(false)
publishedAt DateTime?
updatedAt DateTime @updatedAt
features ProjectFeature[]
technologies ProjectTechnology[]
}
That is the whole definition. slug is unique because two projects sharing a URL is a bug, and now the database enforces it rather than hoping the form does.
Reading data with no API in between
In the App Router a page is an async server function, so the query lives in the page:
// src/app/(website)/projects/page.tsx
import { prisma } from "@/lib/db/client";
export default async function ProjectsPage() {
const projects = await prisma.project.findMany({
where: { publishedAt: { not: null } },
orderBy: [{ featured: "desc" }, { publishedAt: "desc" }],
include: { technologies: true },
});
return (
<ul>
{projects.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
projects is fully typed, p.technologies is typed, and none of it was hand-written. Rename title to name in the schema and every place that read it fails to compile immediately — instead of rendering undefined to a customer six weeks later.
The half that decides whether it gets used
Building the public site is the easy part. A CMS lives or dies on whether the owner actually opens it, and that comes down to two things: the editing screens matching the words they already use for their own business, and nothing being frightening to click.
Writes go through Server Actions, and each one guards itself:
"use server";
export async function updateProject(id: string, formData: FormData) {
const adminId = await getAdminId();
if (!adminId) throw new Error("Unauthorised");
const parsed = ProjectSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, error: "Some fields need attention." };
}
await prisma.project.update({ where: { id }, data: parsed.data });
revalidatePath("/projects");
revalidatePath(`/projects/${parsed.data.slug}`);
return { ok: true, error: null };
}
Two details worth copying. The authorisation check sits inside the action, not in the component that renders the button — a Server Action is a real HTTP endpoint, and the UI is not what protects it. And the revalidatePath calls are what make the public page update the moment the owner hits save, with no rebuild and no cache to explain to them.
What it costs, honestly
A custom CMS is more work up front than installing a theme. It is worth it when the content has structure, when someone will edit it weekly, or when the site needs to grow into bookings, accounts or a dashboard later. It is not worth it for five pages that will never change — for that, five pages is the right answer.
The upside is control. There is no monthly platform fee and no plugin that breaks on update, and the editing screens are built around how the business actually works.
This is the approach behind most of the web development work I do for businesses in Tripoli — product catalogues, bookings and order tracking, with an admin panel staff can use without training. The Server Actions doing the writing above are covered in more depth in the guide to Next.js Server Actions.