How Instatic Manages Live Editing Versus Drafts in the Content Workspace

Instatic isolates draft edits inside a React hook and renders live previews through a draft-aware server endpoint, so the public site remains unchanged until the editor explicitly publishes.

The CoreBunch/Instatic repository implements a draft-first workflow inside its Content workspace, letting editors iterate on posts, pages, and custom cells without risking the live site. By isolating unsaved changes in client-side state and merging them only inside a preview pipeline, Instatic ensures that live editing versus drafts remains strictly separated until publication.

Draft State Lives in a Local React Hook

Instatic's content workspace centers on the useContentEntryDraft hook located in src/admin/pages/content/hooks/useContentEntryDraft.ts. This hook mirrors the selected DataRow fields—title, slug, seoTitle, body, and customCells—inside a set of useState values that serve as the editable draft copy.

When the user selects a new entry, the applySelectedEntry callback (lines 57–64) hydrates the hook's state using read*Cell helpers from src/core/data/cells.ts. All subsequent edits mutate this local draft store instead of the database record, keeping the published version intact.

Detecting Unsaved Changes with isDirty

The hook exposes an isDirty flag that compares the current hook values against the originally saved row (lines 86–97). This boolean disables the Save button when no changes exist and prevents accidental overwrites.

Persisting Progress as a Draft

Editors persist their work by calling handleSaveDraft, which builds a payload and sends it to saveCmsDataRowDraft from src/core/persistence/index.ts (lines 99–115). This writes the data with a draft status so it does not appear on the public route. Status changes—such as scheduling—route through handleStatusChange (lines 54–83), which saves the draft first before invoking updateCmsDataRowStatus.

The following example shows how a panel consumes the draft hook:

import { useContentEntryDraft } from '@admin/pages/content/hooks/useContentEntryDraft'

function ContentEditor({ entry, onUpdate, onError }) {
  const {
    title, slug, body, isDirty,
    setTitle, setSlug, setBody,
    saveMessage, handleSaveDraft, handlePublish,
  } = useContentEntryDraft({
    selectedEntry: entry,
    updateSelectedEntry: onUpdate,
    setError: onError,
  })

  return (
    <>
      <input value={title} onChange={e => setTitle(e.target.value)} />
      <input value={slug} onChange={e => setSlug(e.target.value)} />
      <MarkdownEditor value={body} onChange={setBody} />
      <button disabled={!isDirty} onClick={handleSaveDraft}>
        {saveMessage === 'saving' ? 'Saving…' : 'Save Draft'}
      </button>
      <button onClick={handlePublish}>Publish</button>
    </>
  )
}

Rendering a Live Preview Without Publishing

To let editors see their unsaved changes, Instatic provides a draft-aware preview pipeline that renders the entry on-the-fly without altering the live state.

The Draft-Aware Preview Endpoint

The server handler in src/server/handlers/cms/data/preview.ts reads any existing draft for the requested entry and merges it with the published version. It then returns the rendered HTML fragment, allowing the editor to inspect typography, layout, and custom cells exactly as they appear in draft form.

Below is a simplified view of that server-side flow:

import { readDraftIfExists, renderPage } from '@core/persistence'

export async function previewHandler(req) {
  const { entryId } = req.params
  const draft = await readDraftIfExists(entryId)   // pulls the draft row
  const html = await renderPage({ entryId, draft })
  return new Response(html, { headers: { 'Content-Type': 'text/html' } })
}

The Write-Live Mode Toggle

The workspace header renders a Write / Live switch managed in src/admin/state/adminUi.ts. When the workspace is in Live mode, the UI fetches the entry through the preview endpoint rather than the normal public route. This guarantees that the canvas reflects the latest draft while the actual public URL continues to serve the published version.

Real-Time Bridge to the Live Editor

Instatic keeps the visual editor and the content form in sync through an MCP bridge registered in src/admin/pages/content/agent/useContentToolBridge.ts. The registerContentToolBridge function wires the workspace to the MCP live-edit channel and pushes the current draft store over the NDJSON stream used by the Site editor. Because the bridge always reads from the hook-managed draft state, the live canvas cannot diverge from the editor's current inputs.

The Publish Workflow Step by Step

The complete separation between live editing and drafts follows a strict sequence:

  1. Select entry — applySelectedEntry loads the row into the draft hook.
  2. Edit fields — setTitle, setBody, and other setters update local state only.
  3. Live preview — Toggling Live mode invokes the preview handler, which merges the draft into the render pipeline.
  4. Save draft — handleSaveDraft calls saveCmsDataRowDraft to persist the draft to the database.
  5. Publish — handlePublish (lines 32–53) first saves the draft, then calls publishCmsDataRow to copy the draft into the published status and update the UI.

Because publishCmsDataRow is the only action that promotes the draft to the public route, editors can iterate indefinitely without affecting the live site.

Summary

  • useContentEntryDraft.ts maintains isolated draft state in a React hook so edits never touch the published record until publication.
  • The isDirty flag and applySelectedEntry callback provide reliable change detection and hydration.
  • The preview.ts endpoint merges drafts with published entries for on-the-fly previews without public exposure.
  • The Write / Live toggle in adminUi.ts routes the workspace between normal editing and draft-aware preview mode.
  • The useContentToolBridge.ts MCP bridge streams draft changes to the visual Site editor in real time.
  • Publishing is explicit: publishCmsDataRow promotes the draft to live, while saveCmsDataRowDraft preserves it as work-in-progress.

Frequently Asked Questions

How does Instatic know if a content entry has unsaved changes?

The useContentEntryDraft hook calculates an isDirty boolean by comparing the current local state values against the saved DataRow fields (lines 86–97). If any field diverges, the flag becomes true and enables the Save Draft button.

Can I see my draft changes without publishing them publicly?

Yes. The draft-aware preview handler in src/server/handlers/cms/data/preview.ts merges your saved draft with the published entry and returns rendered HTML. When you switch the workspace to Live mode via the Write/Live toggle in src/admin/state/adminUi.ts, you view this merged output without the draft ever reaching the public route.

What happens to my draft when I click Publish?

The handlePublish callback in useContentEntryDraft.ts (lines 32–53) first persists the latest draft with saveCmsDataRowDraft, then calls publishCmsDataRow from src/core/persistence/index.ts to copy the draft into the published status. After this succeeds, the live site serves the new content and the preview endpoint reflects the now-published version.

Does the live Site editor receive updates while I type?

Yes. The MCP bridge in src/admin/pages/content/agent/useContentToolBridge.ts registers an imperative tool surface that reads the draft store and streams updates over the NDJSON channel to the Site editor. This ensures the live canvas stays synchronized with your current draft state.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →