Skip to content

Drafts & publishing

By default the sections editor writes straight to the live page. Opt a collection into drafts and edits stage as versions instead — the live page only changes when you Publish, and every version is recoverable.

A page’s main row is the live document (what the public site renders). Drafts live in a companion ${slug}_versions table until published; a nullable published_version_id on the main row points at the live version.

  • Save draft stores a full snapshot in ${slug}_versions — the live row is untouched. With auto-save on (the default), edits stage this draft automatically on an idle debounce — no button.
  • Publish copies a version’s snapshot onto the live row and sets published_version_id, running full field validation.
  • Unpublish clears the pointer; Restore is just publishing an older version again.

Publishing is a distinct privilege from editing (access.publish), and is always a manual, explicit action — auto-save only ever stages drafts, it never publishes.

Model the collection with versions.drafts and generate its versions table from the same config:

pages-collection.ts
import { defineCollection } from "louise-toolkit/content";
export const pagesCollection = defineCollection({
slug: "pages",
fields: {
slug: { type: "text", required: true },
title: { type: "text", required: true },
sections: { type: "json" },
},
versions: { drafts: true },
});
// schema.ts — the snapshot table, plus the pointer column on `pages`
import { collectionVersionsTable } from "louise-toolkit/content";
export const pagesVersions = collectionVersionsTable(pagesCollection);
// pages: { …pagesColumns, publishedVersionId: integer("published_version_id") }

Mount versionsRoutebefore pagesRoute, so its /:id/versions paths aren’t claimed by pagesRoute’s /:id matcher:

versionsRoute({
table: pages,
versionsTable: pagesVersions,
config: pagesCollection,
resolveEditor,
});

A save merges the edit (config fields only) over the newest pending draft — falling back to the live row when there is none — and stores a complete, publishable snapshot; the field keys must match the pages table’s property names so publish’s write maps straight onto columns. Merging over the pending draft (not always the live row) is what lets a partial save layer onto work-in-progress instead of reverting it (see below).

A save sends only the fields it changed, and the route backfills the rest. That works cleanly when one surface drives a page’s drafts. Mounting two versioned surfaces on the same page — e.g. mountLouise({ versionedPageId }) for an inline body and a sections dock for the same page id — is not the supported model:

  • Each surface would render its own Save draft / Publish. The framework de-dupes the shared edit bar (only the first surface’s actions land on it), but the two surfaces still save independently.
  • Saves are made safe by merging each partial edit over the newest pending draft rather than the live row, so concurrent surfaces no longer revert each other — but keeping one versioned surface per page is still the clearer model.

If a page needs both an inline body and Louise Sections, prefer a single surface (put the body in the sections catalog, or vice versa) so one Save draft / Publish governs the whole page.

View mode renders the live main row. In edit mode, resume the latest draft so work-in-progress is visible until published — query the newest status: 'draft' version for the page and render its content, falling back to the main row.

Skip superseded drafts: ignore any draft whose id is at or below the live row’s published_version_id. Publishing a version stamps published_version_id and leaves older drafts in history; resuming one of those would silently revert the just-published content. Only a draft newer than the live pointer is pending work (a page that has never published has no pointer, so every draft counts):

import { and, desc, eq, gt } from "drizzle-orm";
async function latestPendingDraft(db, pageId) {
const [page] = await db
.select({ publishedVersionId: pages.publishedVersionId })
.from(pages)
.where(eq(pages.id, pageId));
const live = page?.publishedVersionId ?? null;
const [draft] = await db
.select()
.from(pagesVersions)
.where(
and(
eq(pagesVersions.parentId, pageId),
eq(pagesVersions.status, "draft"),
live === null ? undefined : gt(pagesVersions.id, live),
),
)
.orderBy(desc(pagesVersions.id))
.limit(1);
return draft?.versionData ?? null;
}

The route applies the same rule server-side: publishing with no explicit versionId promotes the newest pending draft, never a superseded one.