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:
- Select entry —
applySelectedEntryloads the row into the draft hook. - Edit fields —
setTitle,setBody, and other setters update local state only. - Live preview — Toggling Live mode invokes the preview handler, which merges the draft into the render pipeline.
- Save draft —
handleSaveDraftcallssaveCmsDataRowDraftto persist the draft to the database. - Publish —
handlePublish(lines 32–53) first saves the draft, then callspublishCmsDataRowto 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.tsmaintains isolated draft state in a React hook so edits never touch the published record until publication.- The
isDirtyflag andapplySelectedEntrycallback provide reliable change detection and hydration. - The
preview.tsendpoint merges drafts with published entries for on-the-fly previews without public exposure. - The Write / Live toggle in
adminUi.tsroutes the workspace between normal editing and draft-aware preview mode. - The
useContentToolBridge.tsMCP bridge streams draft changes to the visual Site editor in real time. - Publishing is explicit:
publishCmsDataRowpromotes the draft to live, whilesaveCmsDataRowDraftpreserves 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →