George Ongoro.

Insights, engineering, and storytelling. Exploring the intersection of technology and creativity to build the future of the web.

Navigation

Home FeedFor YouAboutContactRSS FeedUse my articles on your site

Legal

Privacy PolicyTerms of ServiceAdmin Portal

Stay Updated

Get the latest engineering insights delivered to your inbox.

© 2026 George Ongoro. All rights reserved.

System Online
    Homebackend-apis

    Designing Content Systems for Edits, Not Just Publishing

    January 1, 202613 min read
    backend-apis
    Designing Content Systems for Edits, Not Just Publishing
    Cover image for Designing Content Systems for Edits, Not Just Publishing

    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:

    1. Draft: Work in progress, only visible to the author
    2. Review: Ready for someone else to look at
    3. Approved: Passed review, ready to schedule or publish
    4. Scheduled: Will automatically publish at a specific time
    5. Published: Live and visible to everyone
    6. 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:

    • content table is just an ID and metadata
    • content_versions holds all the actual content data
    • published_versions tracks 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:

    1. Design your version table first. Everything else builds on this.

    2. Implement draft/published separation before adding workflow complexity.

    3. Build rollback functionality early. You'll use it during development.

    4. Add scheduling once basic versioning works.

    5. 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:

    • Optimizely CMS Content Versioning
    • Strapi Draft & Publish Features
    • Liquibase Database Version Control Guide
    • Wikipedia: Schema Migration
    George Ongoro
    George Ongoro

    Blog Author & Software Engineer

    I'm George Ongoro, a passionate software engineer focusing on full-stack development. This blog is where I share insights, engineering deep dives, and personal growth stories. Let's build something great!

    View Full Bio

    Related Posts

    Comments (0)

    Join the Discussion

    Please login to join the discussion