Skip to main content
Back to Blog
Tutorials
4 min read
November 28, 2024

How to Implement Content Versioning and Drafts in Next.js

Build a content versioning system with drafts, revisions, diff viewing, and publish workflows in Next.js.

Ryel Banfield

Founder & Lead Developer

Content versioning lets editors save drafts, review changes, and roll back mistakes. Here is how to build it.

Step 1: Database Schema

// db/schema.ts
import { pgTable, text, timestamp, integer, boolean, jsonb } from "drizzle-orm/pg-core";

export const posts = pgTable("posts", {
  id: text("id").primaryKey(),
  slug: text("slug").notNull().unique(),
  currentVersionId: text("current_version_id"),
  publishedVersionId: text("published_version_id"),
  status: text("status", { enum: ["draft", "published", "archived"] })
    .notNull()
    .default("draft"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
  authorId: text("author_id").notNull(),
});

export const postVersions = pgTable("post_versions", {
  id: text("id").primaryKey(),
  postId: text("post_id")
    .references(() => posts.id)
    .notNull(),
  versionNumber: integer("version_number").notNull(),
  title: text("title").notNull(),
  content: text("content").notNull(),
  excerpt: text("excerpt"),
  metadata: jsonb("metadata"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  authorId: text("author_id").notNull(),
  changeMessage: text("change_message"),
});

Step 2: Version Service

// lib/versioning.ts
import { db } from "@/db";
import { posts, postVersions } from "@/db/schema";
import { eq, desc } from "drizzle-orm";

export async function createDraft(data: {
  slug: string;
  title: string;
  content: string;
  excerpt?: string;
  authorId: string;
}) {
  const postId = crypto.randomUUID();
  const versionId = crypto.randomUUID();

  await db.transaction(async (tx) => {
    await tx.insert(posts).values({
      id: postId,
      slug: data.slug,
      currentVersionId: versionId,
      authorId: data.authorId,
      status: "draft",
    });

    await tx.insert(postVersions).values({
      id: versionId,
      postId,
      versionNumber: 1,
      title: data.title,
      content: data.content,
      excerpt: data.excerpt,
      authorId: data.authorId,
      changeMessage: "Initial draft",
    });
  });

  return postId;
}

export async function saveVersion(data: {
  postId: string;
  title: string;
  content: string;
  excerpt?: string;
  authorId: string;
  changeMessage?: string;
}) {
  const latestVersion = await db
    .select({ versionNumber: postVersions.versionNumber })
    .from(postVersions)
    .where(eq(postVersions.postId, data.postId))
    .orderBy(desc(postVersions.versionNumber))
    .limit(1);

  const nextVersion = (latestVersion[0]?.versionNumber ?? 0) + 1;
  const versionId = crypto.randomUUID();

  await db.transaction(async (tx) => {
    await tx.insert(postVersions).values({
      id: versionId,
      postId: data.postId,
      versionNumber: nextVersion,
      title: data.title,
      content: data.content,
      excerpt: data.excerpt,
      authorId: data.authorId,
      changeMessage: data.changeMessage ?? `Version ${nextVersion}`,
    });

    await tx
      .update(posts)
      .set({ currentVersionId: versionId, updatedAt: new Date() })
      .where(eq(posts.id, data.postId));
  });

  return versionId;
}

export async function publishVersion(postId: string, versionId: string) {
  await db
    .update(posts)
    .set({
      publishedVersionId: versionId,
      status: "published",
      updatedAt: new Date(),
    })
    .where(eq(posts.id, postId));
}

export async function revertToVersion(postId: string, versionId: string, authorId: string) {
  const version = await db
    .select()
    .from(postVersions)
    .where(eq(postVersions.id, versionId))
    .limit(1);

  if (!version[0]) throw new Error("Version not found");

  return saveVersion({
    postId,
    title: version[0].title,
    content: version[0].content,
    excerpt: version[0].excerpt ?? undefined,
    authorId,
    changeMessage: `Reverted to version ${version[0].versionNumber}`,
  });
}

export async function getVersionHistory(postId: string) {
  return db
    .select()
    .from(postVersions)
    .where(eq(postVersions.postId, postId))
    .orderBy(desc(postVersions.versionNumber));
}

Step 3: API Routes

// app/api/posts/[id]/versions/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getVersionHistory, saveVersion, publishVersion, revertToVersion } from "@/lib/versioning";

export async function GET(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const versions = await getVersionHistory(id);
  return NextResponse.json(versions);
}

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  const { action, ...data } = body;

  if (action === "save") {
    const versionId = await saveVersion({ postId: id, ...data });
    return NextResponse.json({ versionId });
  }

  if (action === "publish") {
    await publishVersion(id, data.versionId);
    return NextResponse.json({ success: true });
  }

  if (action === "revert") {
    const versionId = await revertToVersion(id, data.versionId, data.authorId);
    return NextResponse.json({ versionId });
  }

  return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}

Step 4: Version History UI

"use client";

import { useState } from "react";
import { Clock, RotateCcw, Eye, Check } from "lucide-react";

interface Version {
  id: string;
  versionNumber: number;
  title: string;
  content: string;
  changeMessage: string;
  createdAt: string;
  authorId: string;
}

interface VersionHistoryProps {
  versions: Version[];
  currentVersionId: string;
  publishedVersionId: string | null;
  onRevert: (versionId: string) => void;
  onPreview: (version: Version) => void;
  onPublish: (versionId: string) => void;
}

export function VersionHistory({
  versions,
  currentVersionId,
  publishedVersionId,
  onRevert,
  onPreview,
  onPublish,
}: VersionHistoryProps) {
  return (
    <div className="space-y-1">
      <h3 className="mb-3 text-sm font-semibold">Version History</h3>
      {versions.map((version) => {
        const isCurrent = version.id === currentVersionId;
        const isPublished = version.id === publishedVersionId;

        return (
          <div
            key={version.id}
            className={`group flex items-start gap-3 rounded-lg p-3 ${
              isCurrent ? "bg-blue-50 dark:bg-blue-950/20" : "hover:bg-gray-50 dark:hover:bg-gray-800"
            }`}
          >
            <div className="mt-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 text-xs font-medium dark:bg-gray-800">
              {version.versionNumber}
            </div>

            <div className="flex-1">
              <p className="text-sm font-medium">
                {version.changeMessage}
                {isCurrent && (
                  <span className="ml-2 rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
                    Current
                  </span>
                )}
                {isPublished && (
                  <span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700 dark:bg-green-900 dark:text-green-300">
                    Published
                  </span>
                )}
              </p>
              <p className="text-xs text-gray-400">
                <Clock className="mr-1 inline h-3 w-3" />
                {new Date(version.createdAt).toLocaleString()}
              </p>
            </div>

            <div className="hidden gap-1 group-hover:flex">
              <button
                onClick={() => onPreview(version)}
                className="rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700"
                title="Preview"
              >
                <Eye className="h-4 w-4" />
              </button>
              {!isCurrent && (
                <button
                  onClick={() => onRevert(version.id)}
                  className="rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700"
                  title="Revert to this version"
                >
                  <RotateCcw className="h-4 w-4" />
                </button>
              )}
              {!isPublished && (
                <button
                  onClick={() => onPublish(version.id)}
                  className="rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700"
                  title="Publish this version"
                >
                  <Check className="h-4 w-4" />
                </button>
              )}
            </div>
          </div>
        );
      })}
    </div>
  );
}

Step 5: Simple Diff Viewer

"use client";

interface DiffViewerProps {
  oldText: string;
  newText: string;
}

export function DiffViewer({ oldText, newText }: DiffViewerProps) {
  const oldLines = oldText.split("\n");
  const newLines = newText.split("\n");

  // Simple line-by-line comparison
  const maxLines = Math.max(oldLines.length, newLines.length);
  const diff: { type: "same" | "added" | "removed"; line: string }[] = [];

  for (let i = 0; i < maxLines; i++) {
    const oldLine = oldLines[i];
    const newLine = newLines[i];

    if (oldLine === newLine) {
      diff.push({ type: "same", line: oldLine ?? "" });
    } else {
      if (oldLine !== undefined) {
        diff.push({ type: "removed", line: oldLine });
      }
      if (newLine !== undefined) {
        diff.push({ type: "added", line: newLine });
      }
    }
  }

  return (
    <div className="overflow-x-auto rounded-lg border font-mono text-xs dark:border-gray-700">
      {diff.map((entry, idx) => (
        <div
          key={idx}
          className={`px-4 py-0.5 ${
            entry.type === "added"
              ? "bg-green-50 text-green-800 dark:bg-green-950/20 dark:text-green-400"
              : entry.type === "removed"
              ? "bg-red-50 text-red-800 dark:bg-red-950/20 dark:text-red-400"
              : ""
          }`}
        >
          <span className="mr-3 inline-block w-4 text-right opacity-50">
            {entry.type === "added" ? "+" : entry.type === "removed" ? "-" : " "}
          </span>
          {entry.line || " "}
        </div>
      ))}
    </div>
  );
}

Summary

  • Each edit creates a new version, preserving full history
  • Separate draft and published states
  • Revert to any previous version instantly
  • Simple diff viewer shows changes between versions

Need a Custom CMS?

We build content management systems with versioning, workflows, and collaboration features. Contact us to discuss your project.

content versioningdraftsCMSNext.jsdatabasetutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles