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". - Blocker (hard): a heading/body font whose uploaded family is marked
license: "trial"(trial_font_in_use) - trial families render in draft and preview, but going live requires uploading and assigning the licensed files. There is no "publish anyway" for this one. - 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. With globals included, nav is rebuilt from the merged live pages. Without globals, the published labels, custom links and order stay frozen while removed pages disappear and new visible pages append. 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. This includes the menu and contact fields materialized into booking sections, so draft navigation or contact details cannot ride an unrelated page publish.
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.
Preview safety: the password gate is rate-limited and a preview token goes dark immediately when its website is deleted or operator-suspended. Remaining maintenance edges:
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.
Hittade du inte svaret, eller stämmer något inte? Berätta det för oss.
Senast uppdaterad
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.