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
}
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();
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);
}
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);
}
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>
);
}
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>
);
}
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"]
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
publishedflag kept the schema tiny. I considered a separateDraftmodel, 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 = 60is 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
- Next.js 14 app router + Prisma gives a clean separation between data and rendering without pulling in a heavyweight CMS.
- Drafts are just a boolean – No need for complex versioning when you control the publishing flow.
-
revalidatePathis the simplest way to force ISR regeneration right after a state change. - 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)