Most content systems get built backwards. We obsess over how to display content, optimize loading times, and make everything look perfect for visitors. But here's what we forget: content changes. A lot. And when you treat editing as an afterthought, you end up with systems that feel like walking through a minefield every time someone needs to fix a typo.
Think about the last time you hit "publish" and immediately realized you made a mistake. Or when your marketing team wanted to schedule updates but you had no way to preview them. Or that moment when legal reviewed your content and asked for changes, but you'd already pushed it live. These aren't edge cases - they're daily realities for anyone managing content at scale.
Let's talk about building systems where editing is a first-class feature, not something you bolt on later when things break.
Why Most CMSs Get Editing Wrong
Walk into most content management systems and you'll find a "Save" button that does... something. Does it publish? Does it create a draft? Will it overwrite someone else's work? Nobody really knows until they click it and hope for the best.
This happens because we design content systems around the happy path: create content once, publish it, done. But real content has a lifecycle. It gets drafted, reviewed, edited again, approved by three different people, scheduled for launch, and then immediately needs an emergency fix because someone spotted an error.
The problem isn't that developers don't care about editing. It's that we treat the database as the source of truth, when really, we need version history to be the source of truth.
Traditional approach:
- Content exists in one place
- Edits overwrite the original
- "Undo" is whatever you remember
- Publishing is a scary one-way door
Better approach:
- Content exists as a series of versions
- Edits create new versions
- History is always available
- Publishing is just changing which version is visible
Making Editing a First-Class Feature
Here's what it means to treat editing as seriously as publishing:
Every change gets tracked
Not just "who edited this and when" but "what exactly changed." When someone edits a blog post title, you should be able to see the old title, the new title, and ideally, why they changed it. This isn't paranoia, it's how you prevent the 2am panic when you need to figure out what broke.
CREATE TABLE content_versions (
id SERIAL PRIMARY KEY,
content_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
metadata JSONB,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
change_note TEXT,
FOREIGN KEY (content_id) REFERENCES content(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);
-- Every edit creates a new version
-- Nothing gets overwritten
-- You can always go back
This table structure does something important: it makes versions cheap. Want to save someone's work? Create a new version. Want to try something experimental? Create a version. Made a mistake? Just point to an earlier version.
Draft and published states are separate entities
Here's where most systems trip up. They have a single piece of content that's either published or not. But what happens when you want to edit published content? You either edit it live (scary) or unpublish it while you work (also scary).
Better solution: drafts and published versions are completely separate. When you edit published content, you're creating a new draft. The published version stays untouched until you explicitly choose to replace it.
class ContentVersion {
constructor(contentId) {
this.contentId = contentId;
this.versionNumber = null;
this.state = 'draft'; // draft, review, scheduled, published, archived
this.publishedAt = null;
this.scheduledFor = null;
}
async publish() {
// Mark current published version as archived
await this.archiveCurrentPublished();
// This version becomes the published one
this.state = 'published';
this.publishedAt = new Date();
await this.save();
}
async schedulePublish(date) {
this.state = 'scheduled';
this.scheduledFor = date;
await this.save();
}
}
With this setup, you get:
- Multiple drafts can exist for one piece of content
- Published content stays live while you work on updates
- You can preview drafts without affecting what visitors see
- Scheduling becomes straightforward
Building Version History That Works
Version history sounds simple until you build it. Here's what actually matters:
Store complete snapshots, not diffs
There's a temptation to save only what changed between versions. Don't do it. Storage is cheap, and reconstructing content from a chain of diffs is expensive and error-prone.
Store the complete state of the content in every version. Yes, it uses more database space. Yes, it's worth it. When you need to restore version 47 from six months ago, you just load that row. No reconstruction, no risk of corrupted history.
Version everything that matters
Content isn't just the body text. It's:
- The title
- Metadata tags
- Categories and relationships
- Images and media references
- SEO fields
- Custom attributes
If changing it affects what visitors see or how content behaves, version it.
const contentSnapshot = {
version: 23,
content: {
title: "Updated Title",
body: "Main content here...",
excerpt: "Short description",
slug: "updated-title",
metadata: {
author: "Jane Doe",
tags: ["web-dev", "cms"],
category: "backend-apis",
seoTitle: "Custom SEO Title",
seoDescription: "Meta description"
},
media: {
featuredImage: "/uploads/image-123.jpg",
gallery: ["/uploads/img1.jpg", "/uploads/img2.jpg"]
}
},
timestamp: "2026-01-01T14:30:00Z",
author: "user_456",
changeNote: "Updated title and fixed typos in introduction"
};
Make comparing versions easy
Version history without comparison is just a list of timestamps. Build comparison tools that show exactly what changed between any two versions. Line-by-line diffs for text, side-by-side for structured data.
Users shouldn't need to open two browser tabs and play spot-the-difference.
The Rollback Problem
Rollbacks sound simple: just go back to an earlier version. But there's nuance here that catches people off guard.
Two types of rollback
Soft rollback: Create a new version based on an old version. This keeps the history intact. Someone can see you rolled back and understand why.
async function softRollback(contentId, targetVersionNumber) {
// Get the version we want to restore
const targetVersion = await getVersion(contentId, targetVersionNumber);
// Create a NEW version with that content
const newVersion = {
...targetVersion.content,
versionNumber: await getNextVersionNumber(contentId),
changeNote: `Rolled back to version ${targetVersionNumber}`
};
await saveVersion(contentId, newVersion);
return newVersion;
}
Hard rollback: Delete or hide all versions after a certain point. This is rare and dangerous. Only use it when something truly shouldn't exist in your history (leaked secrets, legal issues, etc.).
Rollback isn't always clean
What happens when you rollback version 30 to restore version 20, but version 25 fixed a critical security issue? You just reintroduced the problem.
This is why change notes matter. When someone creates a version, they should say what changed. When someone wants to rollback, they should see those notes and make an informed decision.
Better yet, allow partial rollbacks: restore just the title, or just the body, while keeping other changes. This requires storing content as structured data, not one big blob.
Draft vs Published vs Everything In Between
Real content systems have more than two states. Here's a typical lifecycle:
- Draft: Work in progress, only visible to the author
- Review: Ready for someone else to look at
- Approved: Passed review, ready to schedule or publish
- Scheduled: Will automatically publish at a specific time
- Published: Live and visible to everyone
- Archived: No longer published but kept for reference
Each state needs different permissions and behaviors:
const statePermissions = {
draft: {
canView: ['author', 'editor', 'admin'],
canEdit: ['author'],
canDelete: ['author', 'admin']
},
review: {
canView: ['author', 'reviewer', 'editor', 'admin'],
canEdit: ['editor', 'admin'],
canApprove: ['editor', 'admin'],
canReject: ['editor', 'admin']
},
published: {
canView: ['public'],
canEdit: [], // Creates new draft instead
canUnpublish: ['editor', 'admin']
}
};
Handling simultaneous edits
Two people editing the same content creates problems. Traditional systems lock the content - only one person can edit at a time. This works but feels terrible when someone forgets to save and locks content for hours.
Better approach: let both people edit, but warn them when conflicts exist. When both try to publish, show them what the other person changed and let them merge manually.
async function detectConflicts(baseVersion, editVersion, otherEditVersion) {
const conflicts = [];
// Check each field
for (const field in baseVersion.content) {
const baseValue = baseVersion.content[field];
const myValue = editVersion.content[field];
const theirValue = otherEditVersion.content[field];
// Both changed the same field differently
if (myValue !== baseValue && theirValue !== baseValue && myValue !== theirValue) {
conflicts.push({
field,
baseValue,
myValue,
theirValue
});
}
}
return conflicts;
}
Editorial Workflows That Don't Get In The Way
Approval processes exist for good reasons: legal compliance, brand consistency, quality control. But most workflow implementations are so rigid that people find creative ways to bypass them.
Good editorial workflow is flexible:
Define workflows per content type
Blog posts might need one reviewer. Legal documents might need three. Press releases might need approval from legal, marketing, and executives. Don't force everything through the same pipeline.
const workflowDefinitions = {
'blog-post': {
steps: [
{ role: 'editor', action: 'review' }
],
skipable: true // Author can publish directly if urgent
},
'press-release': {
steps: [
{ role: 'legal', action: 'approve' },
{ role: 'marketing', action: 'approve' },
{ role: 'executive', action: 'approve' }
],
requireAll: true,
skipable: false
},
'documentation': {
steps: [
{ role: 'technical-reviewer', action: 'review' }
],
skipable: false
}
};
Allow workflow overrides
Sometimes you need to publish now. Maybe there's breaking news, or a critical bug fix, or a legal requirement to remove something immediately. Your workflow should have an emergency override that:
- Requires additional permissions
- Logs who used it and why
- Notifies relevant people
- Still creates proper version history
Make the workflow visible
Nobody should have to ask "where is this content in the approval process?" Build a clear status view that shows:
- What step it's at
- Who needs to act next
- How long it's been waiting
- Any blockers or comments
This transparency prevents content from sitting in someone's inbox for weeks while everyone assumes someone else is handling it.
Scheduling and Automatic Publishing
Scheduling sounds straightforward until you think about timezones, server failures, and what happens when scheduled content has broken links.
Store schedules as future events, not magic flags
Don't just set a "publish_at" timestamp and hope something checks it. Create explicit scheduled events that get processed:
class ScheduledAction {
constructor(contentId, versionId, action, scheduledFor) {
this.id = generateId();
this.contentId = contentId;
this.versionId = versionId;
this.action = action; // 'publish', 'unpublish', 'archive'
this.scheduledFor = scheduledFor;
this.status = 'pending'; // pending, completed, failed
this.attempts = 0;
this.lastError = null;
}
async execute() {
try {
this.attempts++;
if (this.action === 'publish') {
await publishVersion(this.contentId, this.versionId);
} else if (this.action === 'unpublish') {
await unpublishContent(this.contentId);
}
this.status = 'completed';
await this.save();
} catch (error) {
this.status = 'failed';
this.lastError = error.message;
await this.save();
// Notify someone it failed
await notifyScheduleFailure(this);
}
}
}
Handle failures gracefully
What if the server restarts during scheduled publishing? What if the database is temporarily unavailable? Your scheduler needs to:
- Retry failed actions
- Skip schedules that are too far past due
- Log everything
- Alert humans when things go wrong
Preview scheduled changes
Let users preview what will go live before it's scheduled. Show them exactly what visitors will see, including any dependencies like images or linked content.
Schema Design for Version-Aware Systems
Here's how this all comes together in a database schema that supports proper versioning:
-- Main content table (lightweight)
CREATE TABLE content (
id SERIAL PRIMARY KEY,
content_type VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by INTEGER NOT NULL REFERENCES users(id)
);
-- All versions of all content
CREATE TABLE content_versions (
id SERIAL PRIMARY KEY,
content_id INTEGER NOT NULL REFERENCES content(id),
version_number INTEGER NOT NULL,
state VARCHAR(20) NOT NULL DEFAULT 'draft',
data JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by INTEGER NOT NULL REFERENCES users(id),
change_note TEXT,
UNIQUE(content_id, version_number)
);
-- Track which version is currently published
CREATE TABLE published_versions (
content_id INTEGER PRIMARY KEY REFERENCES content(id),
version_id INTEGER NOT NULL REFERENCES content_versions(id),
published_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_by INTEGER NOT NULL REFERENCES users(id)
);
-- Scheduled actions
CREATE TABLE scheduled_actions (
id SERIAL PRIMARY KEY,
content_id INTEGER NOT NULL REFERENCES content(id),
version_id INTEGER REFERENCES content_versions(id),
action VARCHAR(20) NOT NULL,
scheduled_for TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_by INTEGER NOT NULL REFERENCES users(id),
executed_at TIMESTAMP,
error_message TEXT
);
-- Workflow tracking
CREATE TABLE workflow_steps (
id SERIAL PRIMARY KEY,
version_id INTEGER NOT NULL REFERENCES content_versions(id),
step_name VARCHAR(100) NOT NULL,
required_role VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
completed_by INTEGER REFERENCES users(id),
completed_at TIMESTAMP,
comments TEXT
);
CREATE INDEX idx_content_versions_lookup ON content_versions(content_id, state);
CREATE INDEX idx_scheduled_actions_pending ON scheduled_actions(scheduled_for, status)
WHERE status = 'pending';
This schema separates concerns:
contenttable is just an ID and metadatacontent_versionsholds all the actual content datapublished_versionstracks what's live- Everything else is workflow and scheduling
Performance Considerations
Version history can get big fast. A blog post that gets edited 100 times has 100 complete snapshots in the database. Here's how to handle it:
Pagination and lazy loading
Don't load all versions at once. Show the most recent 20, then paginate if someone wants to dig deeper.
Archive old versions
Versions older than a year probably don't need to be in your main database. Move them to cold storage but keep them accessible if needed.
Index wisely
-- Fast lookup of latest version
CREATE INDEX idx_latest_version
ON content_versions(content_id, version_number DESC);
-- Fast lookup of published content
CREATE INDEX idx_published_content
ON published_versions(published_at DESC);
-- Fast workflow queries
CREATE INDEX idx_pending_workflow
ON workflow_steps(version_id, status)
WHERE status = 'pending';
What This Enables
When you build a system that treats editing as seriously as publishing, you get some powerful capabilities:
Confidence: Teams can experiment knowing they can rollback if needed
Transparency: Everyone can see what changed, when, and why
Compliance: Full audit trail of all content changes
Collaboration: Multiple people can work without stepping on each other
Quality: Proper review processes without blocking urgency
Recovery: Deleted content can be restored, mistakes can be fixed
Getting Started
If you're building a new content system or refactoring an existing one, start with these pieces:
Design your version table first. Everything else builds on this.
Implement draft/published separation before adding workflow complexity.
Build rollback functionality early. You'll use it during development.
Add scheduling once basic versioning works.
Layer workflow on top when you understand your team's needs.
Don't try to build the perfect system upfront. Build something that tracks versions reliably, then add features as you need them.
For more on building backend systems that handle content well, check out Designing Search for Blogs (Beyond LIKE Queries) where we cover making content discoverable.
The difference between a content system that people tolerate and one they love often comes down to how well it handles editing. Because in the real world, content is never truly finished. It evolves. And your system should evolve with it.
References:

