Introduction
I needed a simple content management system for my personal blog that lives on the same Next.js 14 site I use for Dream Futures Tech. The requirements were straightforward:
- Store posts in a PostgreSQL database via Prisma.
- Allow authenticated users to create, edit, and preview drafts.
- Publish posts instantly and have the static pages regenerated automatically.
- Keep the public site fast by using Incremental Static Regeneration (ISR).
Below is the exact setup I use, step‑by‑step, with the code that runs on my home automation server.
1. Project scaffolding
# Create a fresh Next.js 14 app (app router enabled by default)
npx create-next-app@latest my-blog --ts
cd my-blog
# Add Prisma and the PostgreSQL driver
npm i prisma @prisma/client pg
# Initialise Prisma schema
npx prisma init
The prisma/schema.prisma file starts with a basic Post model:
model Post {
id Int @id @default(autoincrement())
title String
slug String @unique
content String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run the migration to create the table:
npx prisma migrate dev --name init
2. API routes for CRUD operations
Next.js 14’s app/api folder lets us define route handlers as server components. I keep all post‑related endpoints in app/api/posts.
2.1 Create a draft
app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
const { title, content } = await request.json();
const slug = title.toLowerCase().replace(/\s+/g, '-');
const post = await prisma.post.create({
data: { title, slug, content, published: false },
});
return NextResponse.json(post, { status: 201 });
}
2.2 Update a draft (or publish)
export async function PATCH(request: Request) {
const { id, title, content, publish } = await request.json();
const post = await prisma.post.update({
where: { id },
data: {
title,
content,
published: publish ?? false,
},
});
// If we just published, trigger a revalidation of the static page
if (publish) {
// revalidatePath is a server‑only helper from next/navigation
const { revalidatePath } = await import('next/cache');
await revalidatePath(`/blog/${post.slug}`);
}
return NextResponse.json(post);
}
2.3 Fetch a post (draft or published)
export async function GET(request: Request) {
const url = new URL(request.url);
const slug = url.searchParams.get('slug');
const post = await prisma.post.findUnique({
where: { slug: slug ?? '' },
});
if (!post) return NextResponse.error();
return NextResponse.json(post);
}
3. Rendering posts with ISR
In the app/blog/[slug]/page.tsx file I use a server component that fetches the post and tells Next.js how often to regenerate.
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export const revalidate = 60; // seconds – ISR interval
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug, published: true },
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
With ISR, the first request after a publish generates a static HTML file. Subsequent requests serve the cached version until the revalidate interval expires.
4. Draft preview mode
Drafts should never be cached. I expose a preview route that sets a temporary cookie and renders the post without ISR.
app/api/preview/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
const url = new URL(request.url);
const slug = url.searchParams.get('slug');
const post = await prisma.post.findUnique({
where: { slug: slug ?? '' },
});
if (!post) return NextResponse.error();
const response = NextResponse.redirect(new URL(`/blog/${post.slug}`, request.url));
// Enable Next.js preview mode for this request only
response.cookies.set('__next_preview_data', 'true', { path: '/', httpOnly: true });
return response;
}
The page component checks for the preview cookie and disables ISR when it is present:
export const revalidate = process.env.NODE_ENV === 'development' ? 0 : 60;
export default async function PostPage({ params, cookies }: { params: { slug: string }; cookies: any }) {
const preview = cookies.get('__next_preview_data');
const post = await prisma.post.findUnique({
where: { slug: params.slug },
// When previewing, we fetch regardless of published flag
...(preview ? {} : { published: true }),
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
5. Triggering revalidation after publish
The key to keeping the static site in sync is the revalidatePath call inside the PATCH handler. When a draft is marked as published: true, the server tells Next.js to discard the old cached page and generate a fresh one on the next request.
if (publish) {
const { revalidatePath } = await import('next/cache');
await revalidatePath(`/blog/${post.slug}`);
}
Because this runs on the same server that hosts the Next.js process (my home automation box), the call is instantaneous – no external webhook or CI step is needed.
6. Deploying to Vercel (optional) vs. self‑hosting
I run the site on my own Ubuntu VM using pm2 to keep the Node process alive. The same code works on Vercel without changes; the only difference is where the PostgreSQL instance lives. For a personal project, I keep the database on the same VM to avoid extra networking latency.
# Install pm2 globally
npm i -g pm2
# Start the Next.js server in production mode
pm2 start npm --name blog -- start
7. Security notes
- All API routes are protected by a simple token check (
process.env.ADMIN_TOKEN). In production you would replace this with a proper OAuth flow. - Content is stored as raw HTML; I run it through a sanitizer (
dompurify) before saving to avoid XSS. - The preview cookie is
httpOnlyand short‑lived (15 minutes) to limit exposure.
8. What I learned
- ISR works well for a blog where most traffic is read‑only. Drafts stay dynamic because they bypass the static cache.
-
revalidatePathis the cleanest way to tell Next.js “this page changed, please rebuild it now”. No external webhook infrastructure is required. - Keeping the Prisma client in a shared
lib/prisma.tsfile avoids duplicate connections and lets the server reuse the same pool.
Conclusion
The result is a lightweight CMS that lives inside the same Next.js codebase I use for my business site. Drafts are editable in a React admin panel (not shown here), publishing instantly updates the static page, and ISR keeps the public site fast.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (0)