DEV Community

the bomber
the bomber

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Run the migration to create the table:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 httpOnly and 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.
  • revalidatePath is 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.ts file 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)