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
siteVersionstable. Each row is one immutablesnapshot(a denormalizedSiteSnapshot,convex/model/snapshot.ts) plus optionallocalizedSnapshotsper locale. - The flip:
websites.publishedVersionIdpoints at the livesiteVersionsrow. The public site reads only viagetPublishedSnapshot(slug)/getSiteByHost(host), which returnnullunlesswebsite.status === "published"andpublishedVersionIdis 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):
- 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
overridecan pass warnings; hard blockers cannot be overridden.
- Compose the snapshot: fresh pages (selected by
target) re-render toSnapshotPage(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 stableresolvedAssetsmap), so the public site does no live asset query. - Write + flip (atomic): insert one
siteVersionsrow, thenpatch(website, { status: "published", publishedVersionId, … }). No half-published state is observable. - Translate (best-effort, async): a scheduled action writes
localizedSnapshots[locale]for secondary languages (needs an AI key); the public site falls back to the primarysnapshotif a locale is absent.
Selective / per-page publish
target = { mode: "all" } | { mode: "pages"; slugs[] }.
"all"(default): every draft page not markedexcludeFromPublishis re-rendered; globals are always refreshed."pages": only the named slugs re-render; globals are frozen unlessincludeGlobals: 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; loadsapi.editor.getDraftSite+ draft sections / fonts / assets. - Shareable preview
/p/[token]— token-gated, always-live draft (convex/previewLinks.ts). OnepreviewLinksrow per website (upsert).modeis"public"(anyone with the link) or"password"(salted SHA-256;unlockPreviewmints a 24hpreviewGrantstoken). 30-day expiry; revoke / rotate supported;/pisnoindex.
Known preview edges: the password gate has no brute-force rate limit (P1, MVP close-out);
getPreviewShare/setPreviewShareuse.unique()on first-create and can race; apreviewGrantsGC cron is missing.
Public URL behavior
- Slug:
websites.slugis 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.tsresolves the host via ConvexgetSlugByHost(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:
generateMetadatabuilds 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.tsblocks/dashboard/,/onboarding,/preview/,/api/. - JSON-LD: a schema.org graph is emitted on public pages.
- OG image: the default
ogImageUrlisundefinedin the snapshot; pages fall back to the generated/site/<slug>/ogendpoint, and posts use their featured image. There is no branded default OG image yet.
Known publishing edges
| Issue | Severity | Notes |
|---|---|---|
No ISR / revalidateTag on publish | P2 perf | /site is dynamic; every load hits Convex. |
| Default OG image undefined | P2 | Falls back to the generated /og endpoint. |
| Selective publish freezes globals by default | P2 UX | Must be communicated in the publish dialog. |
| Held-post unpublish is destructive | P2 UX | The article leaves the live snapshot immediately; confirm in UI. |
| Live domain status never auto-downgrades | low (intentional) | Protects against DNS flapping; only records lastCheckedAt. |
Not an issue (verified): draft↔published isolation and atomicity hold under test.
Section registry
The registry as the single source of truth for section labels, variants, tones, and default content — and how to add a section type.
AI system
The one rule (validate before write), the OpenRouter-only provider, model tiers, generation flow, the chat assistant loop, and anti-fabrication guardrails.