DEV Community

the bomber
the bomber

Posted on

Building a Next.js 14 Blog CMS with Prisma, Drafts, ISR, and revalidatePath

Introduction

I’m Kaleb, a web developer in Pompano Beach, Florida, running Dream Futures Tech. A few months ago I rebuilt my own business site into a lightweight content‑management system (CMS) using Next.js 14’s app router, Prisma, and Incremental Static Regeneration (ISR). The goal was simple: write blog posts locally, keep drafts private, publish with a single click, and have the static pages regenerate automatically.

Below I walk through the pieces I assembled – the Prisma schema, the API routes for drafts and publishing, the ISR configuration, and the revalidatePath call that forces a fresh render after each publish. All code is what I actually run on my home automation server; I’m not fabricating benchmarks or client data.

1. Setting up Prisma

First I added Prisma to the project and created a SQLite database (perfect for a small site). The schema lives in prisma/schema.prisma:

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

 generator client {
   provider = "prisma-client-js"
 }

 model Post {
   id        Int      @id @default(autoincrement())
   slug      String   @unique
   title     String
   content   String
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
   published Boolean  @default(false)
   // Drafts are just unpublished rows
 }
Enter fullscreen mode Exit fullscreen mode

Run npx prisma migrate dev --name init to create the DB and npx prisma generate to get the client. I keep the client in a lib/prisma.ts helper:

import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

2. API Routes – Drafts and Publishing

Next.js 14’s app router lets us colocate route handlers with pages. I created two routes under app/api/posts.

2.1 Create / Update Draft (POST)

app/api/posts/route.ts

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function POST(req: Request) {
  const { slug, title, content } = await req.json();
  const post = await prisma.post.upsert({
    where: { slug },
    update: { title, content },
    create: { slug, title, content, published: false },
  });
  return NextResponse.json(post);
}
Enter fullscreen mode Exit fullscreen mode

This endpoint is used by my tiny admin UI (a React form that lives in app/admin/page.tsx). Because published stays false, the post never appears on the public side.

2.2 Publish (PUT)

app/api/posts/publish/route.ts

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function PUT(req: Request) {
  const { slug } = await req.json();
  const post = await prisma.post.update({
    where: { slug },
    data: { published: true },
  });
  // Force Next.js to regenerate the static page for this slug
  await revalidatePath(`/blog/${slug}`);
  return NextResponse.json(post);
}
Enter fullscreen mode Exit fullscreen mode

revalidatePath is the key: after marking the row as published, we tell the runtime to invalidate the cached static page. The next request will trigger a fresh render.

3. Public Blog Pages with ISR

The public side lives under app/blog/[slug]/page.tsx. Because I want static generation but also the ability to update after a publish, I use generateStaticParams and revalidate.

import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";

export const revalidate = 60; // fallback ISR interval (seconds)

export async function generateStaticParams() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    select: { slug: true },
  });
  return posts.map(p => ({ slug: p.slug }));
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug },
  });
  if (!post || !post.published) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

revalidate = 60 tells Next.js to treat the page as ISR with a 60‑second stale‑while‑revalidate window. In practice the revalidatePath call after publishing makes the page fresh immediately, but the interval protects against missed calls.

4. Admin UI – Tiny but Functional

I kept the admin interface minimal: a form that POSTs to /api/posts for drafts and a button that PUTs to /api/posts/publish. The UI lives in app/admin/page.tsx and uses the built‑in fetch API.

"use client";
import { useState } from "react";

export default function Admin() {
  const [slug, setSlug] = useState("");
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

  const saveDraft = async () => {
    await fetch("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ slug, title, content }),
    });
    alert("Draft saved");
  };

  const publish = async () => {
    await fetch("/api/posts/publish", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ slug }),
    });
    alert("Published!");
  };

  return (
    <section>
      <h2>New Post</h2>
      <input placeholder="slug" value={slug} onChange={e => setSlug(e.target.value)} />
      <input placeholder="title" value={title} onChange={e => setTitle(e.target.value)} />
      <textarea placeholder="content" value={content} onChange={e => setContent(e.target.value)} />
      <button onClick={saveDraft}>Save Draft</button>
      <button onClick={publish}>Publish</button>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Because the admin pages are client‑side only ("use client"), they don’t affect the static generation pipeline.

5. Deploying to My Home Automation Server

I run the Next.js app inside a Docker container on a Raspberry Pi 4 that also hosts a small Home Assistant instance. The Dockerfile is straightforward:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/public ./public
COPY prisma ./prisma
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

I expose port 3000 and reverse‑proxy it with Caddy. The SQLite file lives on a bind‑mounted volume so it survives container restarts.

6. What Worked, What Didn’t

  • Draft workflow – Using a single published flag kept the schema tiny. I considered a separate Draft model, but that added unnecessary joins.
  • revalidatePath – It works reliably on Vercel and on my self‑hosted Node runtime. The only hiccup was that the first request after a publish still hit the cache if the request arrived before the async revalidation finished. Adding a tiny client‑side refresh (router.refresh()) after the publish button mitigated the UX glitch.
  • ISR interval – Setting revalidate = 60 is a judgment call; for a low‑traffic blog it balances freshness and server load. If traffic spikes, lowering the interval is cheap because the page is already cached.
  • SQLite – Perfect for a personal site. If the blog grows beyond a few hundred posts, migrating to Postgres would be the next step.

7. Takeaways

  1. Next.js 14 app router + Prisma gives a clean separation between data and rendering without pulling in a heavyweight CMS.
  2. Drafts are just a boolean – No need for complex versioning when you control the publishing flow.
  3. revalidatePath is the simplest way to force ISR regeneration right after a state change.
  4. Self‑hosting on a modest Raspberry Pi works fine for a small business site; Docker keeps the environment reproducible.

The result is a fast, SEO‑friendly blog that I can edit from any device, and the codebase stays under 300 lines of TypeScript.

I build sites and automations for small businesses at dreamfuturestech.com.

Top comments (0)