Sajt · Docs

Publishing

The two-world draft/published separation, the atomic snapshot flip, selective publish, preview surfaces, multi-language, and SEO output.

How preview, publishing, and the public site work. Core files: convex/publish.ts, convex/previewLinks.ts, convex/domains.ts, app/site/[slug]/[[...path]]/page.tsx, proxy.ts.

The guarantee

A draft edit does NOT change the live public site until republish. This is the two-world model — real, isolated, and tested (convex/publish.isolation.test.ts). If you ever see code that makes the public site read draft tables, treat it as a critical bug.

Draft vs published separation

  • Draft world: the normalized tables (websites, pages, sections, services, assets, fonts, …). The editor, /preview, and /p/<token> read these.
  • Published world: the siteVersions table. Each row is one immutable snapshot (a denormalized SiteSnapshot, convex/model/snapshot.ts) plus optional localizedSnapshots per locale.
  • The flip: websites.publishedVersionId points at the live siteVersions row. The public site reads only via getPublishedSnapshot(slug) / getSiteByHost(host), which return null unless website.status === "published" and publishedVersionId is set.

Isolation evidence (convex/publish.isolation.test.ts): a draft edit leaves the public snapshot at its previous version until republish; older published versions stay immutable; publish is atomic (a public read never resolves to a half-written snapshot); an unpublished website is invisible to the public (null).

Publish flow

The mutation publishWebsite(websiteId, target?, includeGlobals?, override?) delegates to publishWebsiteCore (convex/publish.ts):

  1. Pre-publish QA gate (getPrepublishChecks):
    • Blocker: empty home (no visible sections) — cannot be skipped.
    • Blocker: missing CTA on home (no booking / contact / lead-form section).
    • Blocker: missing phone when goal === "get_calls".
    • Warning: leftover {tokens} in copy.
    • An override can pass warnings; hard blockers cannot be overridden.
  2. Compose the snapshot: fresh pages (selected by target) re-render to SnapshotPage (slug, title, order, showInNav, SEO, post fields, and visible sections only — hidden ones are excluded). Pages from the previous snapshot that are not being re-rendered are carried over verbatim. On a slug collision the fresh page wins; nav is always re-derived from the merged live pages' showInNav. Every referenced asset id is resolved to a storage URL once per publish (a stable resolvedAssets map), so the public site does no live asset query.
  3. Write + flip (atomic): insert one siteVersions row, then patch(website, { status: "published", publishedVersionId, … }). No half-published state is observable.
  4. Translate (best-effort, async): a scheduled action writes localizedSnapshots[locale] for secondary languages (needs an AI key); the public site falls back to the primary snapshot if a locale is absent.

Selective / per-page publish

target = { mode: "all" } | { mode: "pages"; slugs[] }.

  • "all" (default): every draft page not marked excludeFromPublish is re-rendered; globals are always refreshed.
  • "pages": only the named slugs re-render; globals are frozen unless includeGlobals: true. (UX risk: an owner who changes their phone number and selectively publishes one page won't see the number go live — the publish dialog must surface this.)

Frozen globals (theme, fonts, logo/favicon, business info, contact, socials, tracking, languages) come from the draft on "all" or when includeGlobals is set; otherwise they are frozen from the previous snapshot. QA (runChecksOnPages) runs on the merged result, so a selective publish can never push a broken home.

Held-page semantics (pages.excludeFromPublish):

  • A held normal page stays frozen-live (its previous snapshot version is kept).
  • A held post is dropped from the snapshot entirely — that is how "unpublish an article" works. This is destructive: the article leaves the live snapshot immediately, so the UI confirms it.

Tests: convex/publish.test.ts, convex/selectivePublish.test.ts.

Preview surfaces

Two preview surfaces both read draft tables (never snapshots), through the same SectionRenderer as production:

  • Owner preview /preview/[websiteId] — Clerk-gated; loads api.editor.getDraftSite + draft sections / fonts / assets.
  • Shareable preview /p/[token] — token-gated, always-live draft (convex/previewLinks.ts). One previewLinks row per website (upsert). mode is "public" (anyone with the link) or "password" (salted SHA-256; unlockPreview mints a 24h previewGrants token). 30-day expiry; revoke / rotate supported; /p is noindex.

Known preview edges: the password gate has no brute-force rate limit (P1, MVP close-out); getPreviewShare / setPreviewShare use .unique() on first-create and can race; a previewGrants GC cron is missing.

Public URL behavior

  • Slug: websites.slug is the globally-unique public key (by_slug). Path form: /site/<slug>.
  • Subdomain: <slug>.<NEXT_PUBLIC_SITES_DOMAIN> (if configured).
  • Custom domain: stored in domains; proxy.ts resolves the host via Convex getSlugByHost (only LIVE-status domains resolve) and rewrites to /site/<slug>.
  • Canonical precedence: custom domain > subdomain > path — used for canonical URLs and OG / JSON-LD links. See Routes.

Multi-language (sv / en)

The primary language has no locale prefix; secondary languages live under /site/<slug>/<locale>/…. The per-locale snapshot is read from siteVersions.localizedSnapshots; hreflang alternates (+ x-default) are emitted in <head> and the sitemap. Translation runs at publish (needs an AI key).

SEO output

  • Per-page: generateMetadata builds the title ({page} | {business}), description, canonical (domain-aware), hreflang alternates, and OG tags.
  • noindex is honored per page (pages.seo.noindex) and in the sitemap.
  • Sitemap / robots: /site/<slug>/sitemap.xml (hreflang, lastmod, noindex respected); app/robots.ts blocks /dashboard/, /onboarding, /preview/, /api/.
  • JSON-LD: a schema.org graph is emitted on public pages.
  • OG image: the default ogImageUrl is undefined in the snapshot; pages fall back to the generated /site/<slug>/og endpoint, and posts use their featured image. There is no branded default OG image yet.

Known publishing edges

IssueSeverityNotes
No ISR / revalidateTag on publishP2 perf/site is dynamic; every load hits Convex.
Default OG image undefinedP2Falls back to the generated /og endpoint.
Selective publish freezes globals by defaultP2 UXMust be communicated in the publish dialog.
Held-post unpublish is destructiveP2 UXThe article leaves the live snapshot immediately; confirm in UI.
Live domain status never auto-downgradeslow (intentional)Protects against DNS flapping; only records lastCheckedAt.

Not an issue (verified): draft↔published isolation and atomicity hold under test.

On this page