Microsites: owner-written pages the instance builds and hosts

Updated · View the entry on sijie.xyz ↗

Verdict (2026-09-07): a microsite is a React page the owner writes against the StandMeet SDK (software development kit), stored as source in Postgres, compiled by the separate builder service, and served by the instance at /p/<slug>/ — or at / when its slug is the reserved home. The owner never touches deploy infrastructure: a page is a row plus a build pointer, so going live, staging, and rollback are pointer swaps (live_build_id / staging_build_id / previous_live_build_id, backend/db/schema.sql:734). Everything a page needs from the instance — the corpus, the agent, a pool asset, its own key-value store, the visitor's access code — arrives through SDK widgets and hooks, so the page composes what it cannot own. The surface was called "custom pages" until 2026-09-05 (rename e7fe80e91 + f6ffcaaa7, migration 2026-09-05-microsites-rename.sql).

%% LR (left-to-right) layout
flowchart LR
  own["owner
admin editor (PageEditor.tsx) · microsite.* over MCP"] -- "write_file / build" --> db["microsites + microsite_builds
source_files jsonb · status · output_path"]
  db -- "claim (skip locked)" --> bld["builder service
vite build + Tailwind v4 + vendored @standmeet/sdk"]
  bld -- "patches status, output on shared volume" --> db
  db -- "live / staging / previous_live pointers" --> srv["routes/public/microsites.go
/p/<slug>/ · /api/v1/homepage (base href /)"]
  srv -- "index.html + injected head" --> vis["visitor"]

The serve path

  • One serve core, two addresses. MicrositeHandlers.Mount (routes/public/microsites.go:54) registers /microsites/{slug} and /homepage; both call serveSlugAt, which differs only in the slug it resolves and the <base href> it injects (/p/<slug>/ at line 105, / at line 114). The Next.js app reverse-proxies /p/:slug to it in beforeFiles rewrites (app/next.config.ts:71). File serving is shared with the admin preview (microsite_serve.go) so path-escape validation exists exactly once.
  • Injected head, read fresh per request. pageHead.tags() (microsites.go:172) writes the base, the SEO block, and <meta name="standmeet-page-byoai">; nothing is snapshotted into the built page, and the response carries Cache-Control: no-cache, no-store so a rolled-back page stops opening immediately.
  • Staging is a path credential. /p/{slug} is live only; the staging build is reachable through the preview route (microsite_preview.go), where the credential travels in the path because a query string on <base href> would break relative assets.

The homepage is a microsite

The instance root is the reserved slug home (HomepageSlug, owner/usecase/microsite.go:34; a5e1cada9 on 2026-09-04, template 77aee7dc6). The app middleware probes GET /api/v1/homepage and rewrites / to it when a home build is live (app/src/middleware.ts:27); on 404 it falls through to the coded route, whose fallback branch renders DefaultHome from code (app/src/app/visitor-root.tsx:48, app/src/app/default-home.tsx). DefaultHome is itself composed of the same SDK widgets (AgentWidget, CorpusWidget, GateWidget, PageNavWidget), so an unedited instance and an edited one render through one vocabulary (28f750caf). Since 185c4321b (2026-09-07) nothing is materialized at claim — InstallHomepage: nil in backend/cmd/server/boot_http.go:248 — so no stored starter can freeze behind later SDK changes. The middleware never rewrites a /?code= or /?q= view: a coded visitor must reach the name picker, and a codeless question must hand off to /gate. The admin section gives the homepage its own edit entry and card, outside the pages table (38bfe3a2f, 06ab844de).

The editor: a mini-IDE that always builds

Each page has its own editor route (app/src/app/admin/microsites/page.tsx → components/admin/sections/microsites/PageEditor.tsx, 5c5034962 / 10ac91c2e). Editing triggers a build automatically (lib/admin/use-auto-build.ts; 385c22f85, 0140bbbac, bf5ffaa82) — there is no separate compile button. The wait for a build result runs in a generic long-poll worker (lib/long-poll/long-poll.worker.ts + use-long-poll.ts, 5ca6055aa) rather than a per-feature timer. A layout gear switches code / split / render by hiding a column with CSS, not by unmounting (EditorViewToggle.tsx). Renaming a slug is a first-class op from the editor header (RenamePage, owner/usecase/microsite.go:62, e1c14b27e). The same authoring set is exposed to the owner's AI client as microsite.write_file / get_draft / build / get_build / promote_to_staging / promote_to_live / rollback / delete / rename — the ops are declared per domain in owner/ops/microsites*.go and aggregated at the dispatcher (routes/dispatcher/collect.go:70), with microsite.guide returning the authoring guide (design system, widgets, how to show corpus inline). The header comment in owner/ops/microsites.go still says authoring is "deliberately MCP-only" — that predates the editor and is stale; both faces exist today.

A page owns its own store

A microsite can persist visitor input (a poll, a sign-up, a guestbook) without the owner adding a table. The store is NoSQL-shaped — opaque JSON (JavaScript Object Notation) documents in named collections — and physically isolated: each page gets its own Postgres schema (page_<id>, the capstore pattern), not a shared table filtered by id, so there is no WHERE clause to forget; DeletePage drops the schema (owner/usecase/microsite_store.go, a94909aa9). Public route: GET/POST /api/v1/pages/{slug}/store (routes/public/microsite_store.go:40). Reads are open; visitor writes are off until the owner opens them (microsites.store_writable, default false — "model C"; MCP op microsite.set_store_writable, eb2cf70ce). Bounds: 500 documents per page, 8 KiB per document, per-IP rate limit at the route. In the SDK the hook is useMicrositeStore(collection) (sdk/packages/react/src/use-microsite-store.ts:26; it was usePageStore in 5ec7263aa and renamed with the surface); the client base is /api/v1/pages (sdk/packages/core/src/client.ts:172). Owner-side inspection and cleanup are microsite.store_docs / store_delete_doc / store_clear.

What a page is made of: SDK widgets

  • AgentWidget — the visitor chat, adopting whatever session the code issued; GateWidget — code entry; PageNavWidget — links to the owner's other live pages (GET /api/v1/microsites, microsites.go:81).
  • CorpusWidget — corpus cards, with a small query language: query="path:math/** sort:title limit:5" (subtree · sort recent or title · cap; query wins over a bare limit; sdk/packages/react/src/widgets/CorpusWidget.tsx:7, 113ba6a1a). Reads go through the same access-control as everything else.
  • AssetWidget — a pool asset served same-origin at /api/v1/assets/{id}, gated to assets the microsite references; the builder's completion callback recomputes the page's asset references (RebuildMicrositeAssetRefs, corpus/usecase/note_asset_refs.go:138, called from routes/sys/builds.go:191; 836af0fea), so a referenced asset cannot be deleted from the pool.
  • Widgets own their vertical layout inline rather than through a Tailwind class the consumer may never compile (00a3bf9e4); the builder template declares @source "../node_modules/@standmeet/sdk" so widget classes do compile (builder/template/src/theme.css:15, 6faa580f8), and the release build strips data-testid from the widgets (sdk/packages/react/tsup.config.ts, bec5394fb). The SDK is vendored into the builder image, not npm-installed (builder/Dockerfile:21).

SEO follows the page, not the instance

Per-page seo_title / seo_description / seo_image (schema.sql:746; migrations 2026-09-06-microsite-seo.sql, 2026-09-06-microsite-seo-image.sql) are injected into the served head as <title>, <meta name="description">, Open Graph (OG) and Twitter Card tags (routes/public/microsite_seo_head.go; 79a276cce, b19d8db39). Null means nothing is injected and the build keeps its own title. Live non-home microsites are listed in the dynamic sitemap under /p/ (routes/public/seo.go:107 → IndexedMicrosites, owner/usecase/seo_microsites.go:15; 701bf1648). The global SEO settings page and its seo_settings table were removed the same week (7037a434e, migration 2026-09-06-drop-seo-settings.sql) — SEO (search engine optimization) has no instance-wide home any more. MCP op: microsite.set_seo.

A code opens a page

access_codes.microsite_id (schema.sql:344, FK at line 758, migration 2026-08-23-code-page-binding.sql) binds a code to one page. The session response carries microsite_slug (routes/public/sessions.go:62) and the app lands the visitor on /p/<slug> (landOnRendering, app/src/lib/gate/use-absorb-code.ts:71), where the page's AgentWidget adopts the session. A page is a rendering of the code, not a change to it — same grant, same role, same quota, same accounting (microsite-is-the-codes-rendering.spec.ts). /c/<slug> is the code's stable landing path after redemption (c6c54ce88); the slug is a locator, never a credential (app/src/app/c/[slug]/page.tsx). microsites.allow_byoai (same migration) decides whether a page lets a reader bring their own key when no grant is shown — a per-page pill in the editor (afb7ee478) and microsite.set_byoai over MCP; the code side is codes.set_microsite.

Honest ceiling

  • Single-owner serve. listLive, the store, and the homepage resolve the sole owner; the URL carries no handle. Multi-tenant serving needs a handle in the path, not a redesign.
  • Build is a black box to the panel. The editor sees status and error_message; there is no per-file diagnostic mapping, so a Tailwind or type error reads as one build failure.
  • The store is bounded, not indexed. 500 documents and JSONB (binary JSON) containment filters; a page that wants ordering, joins, or counters beyond that has outgrown the store and should become a connector.

Built 2026-08-23 → 2026-09-07. Tables microsites (schema.sql:728) and microsite_builds (schema.sql:790); serve in routes/public/microsites.go / microsite_serve.go / microsite_preview.go / microsite_seo_head.go / microsite_store.go; usecases in owner/usecase/microsite*.go; 18 microsite.* ops through the dispatcher (verified in e2e/test/norm-outward-toolset.spec.ts; paritymanifest.micrositeEntries() is deliberately empty because reach lives on each op). Tests: microsite.spec.ts (create → build → staging → live → rollback), microsite-rename, microsite-preview-before-publish, microsite-preview-follows-the-agent, microsite-store-admin + microsite-store-isolation (the security scenarios), microsite-per-page-seo, microsite-code-binding + microsite-is-the-codes-rendering, microsite-design-system, microsite-asset-widget, microsite-starter-actually-chats, microsites-linked-on-public-surfaces, the editor set (microsite-admin-ui, microsite-admin-authoring, microsite-editor-entry, microsite-editor-live-follow, microsite-editor-view-toggle), and the homepage set (homepage-served-at-root, homepage-served-at-site-root, homepage-auto-goes-live-at-claim, homepage-view-live-links-to-root, owner-homepage-edit-entry, default-home-look, default-homepage-cards-expand-inline).

Origin: read from standmeet-new main at 36789537d (v0.1.31) on 2026-09-07; design docs under docs/design/ were treated as seeds and not cited.

Related notes