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.