Pages Catalog
Puck-based page editor for global public and organization member channels.
Overview
Yayaw provides page management at:
/dashboard/content/pages
The dashboard flow is split into:
- a list/table (
Yayaw DataTable) - a dedicated page editor route (
/dashboard/content/pages/[scope]/[slug])
Related docs:
- Dashboard for shell/navigation behavior
- Sections Catalog for how page documents reference reusable sections
- CMS Data Models for structured data bindings
- Media Library for asset storage and image generation
- CMS Overview for the full content stack
Pages are composed with these node families:
stack: layout containerscomponent: direct component references for advanced compositionsection: reusable catalog section references resolved to the latest publicationgenerated_section: recipe-backed generated sections, including seeded layout sections such as the public home header/footer; AI-generated content sections are promoted to reusablesectionreferences on save
CMS Role
Pages are the final composition layer of the CMS. They should arrange layout, SEO metadata, section references, component references, data bindings, and media bindings. They should not become the long-term source of truth for repeated content such as navigation menus, shared offers, reusable proof blocks, or organization assets.
Use sections for reusable page units, CMS data for shared structured values, media for binary assets, and design tokens for public visual theming.
Document Contract
Pages use a native Puck document contract:
PuckPageDocumentV1- location:
src/lib/shared/pages/puck-document.ts
Revision persistence remains in existing tables, and
ui_page_revisions.definition stores the versioned Puck document as the source
of truth.
The runtime conversion emits PageSectionNodeV1 for reusable sections. A page
stores a stable section id/slug rather than a frozen revision id, so published
pages follow the latest published section revision.
The document root also stores localized page settings:
titleByLocaleseo.seoTitleByLocaleseo.metaDescriptionByLocaleseo.socialTitleByLocaleseo.socialDescriptionByLocaleseo.socialImageUrlseo.canonicalUrlseo.noIndex/seo.noFollow
Registry metadata remains useful for catalog summaries, but the published runtime uses the document root for public SEO metadata.
Channels and Scope
Pages support two scopes/channels:
global(global_public)organization(org_membersfor member-only pages)organization(org_publicfor public pages served from verified custom domains)
Runtime routes:
- global public home page:
/[locale] - global public pages:
/[locale]/[...slug] - organization member pages:
/[locale]/o/[orgSlug]/[[...slug]] - organization public pages: verified custom domain +
/[locale]/[[...slug]]
Organization member page runtime requires membership.
Organization public pages do not require membership, but only render when the
request host matches a verified organization_public_domains row. Unknown
custom hosts return 404 instead of falling back to the global Yayaw site.
Public organization pages can optionally target one organization public domain;
when a target is set, that page only resolves on that host. Untargeted public
organization pages remain available on every verified public domain for the
organization. Page paths are explicit route settings, so an organization public
page can use / for the custom-domain homepage instead of inheriting a path
from the page name.
Section visibility:
- global pages: built-in + global sections
- organization pages: built-in + global + same-organization sections
Dashboard Editing UX
Editor UX is direct Puck + shadcn:
- real-time local editing in Puck
- autosave with debounce
- serialized draft saves so stale autosave completions cannot overwrite newer state
- a locale switch based on
src/config/i18n.config.ts - route settings for updating the page path, organization visibility channel, and public-domain target after creation
- page-level SEO fields in the inspector
- image/media binding controls that select existing organization media assets or generate new WebP assets with OpenAI
- global data binding controls that bind a complete published field from
/dashboard/content/data - explicit publish/archive lifecycle
- read-only mode for users without manage permission
The insert palette groups page elements as:
Built-in/Intégrés:Header,FooterSections: published global sections plus relevant organization sectionsComponents: direct component referencesLayout/Mise en page:PageStack
Built-in section instances expose no editable props in the page inspector. Section content is edited from the source section catalog, not duplicated per page.
Generated section recipes support header and footer variants in addition to
content sections. Current content variants are hero, immersive_hero,
legal, value_grid, feature_split, system_map, mcp_console, proof,
signal_wall, and cta.
Header and footer recipes use itemsJson for menu links, submenu groups, theme
toggles, language toggles, and user menus. Seeded Header/Footer page sections
bind those props to header-menu/main and footer-menu/main CMS data, with the
same JSON contract as fallback.
Toolbar state exposes autosave lifecycle:
savingsavederrorconflict
Autosave and Concurrency
Draft saves use optimistic locking:
saveDraftPageActionrequiresbaseRevisionId- stale base revision returns
conflict - successful saves return the server-canonical Puck document so the editor can adopt normalized section IDs and default bindings without dirty-state loops
- no server-side operation rebase and no automatic merge
Conflict handling strategy:
- user reloads latest draft explicitly from the conflict banner
- user reapplies desired edits in current revision context
Runtime and Validation
Published runtime resolves from the stored Puck document directly.
Server runtime path:
- loads published revision document from
ui_page_revisions.definition - normalizes Puck document
- converts to validated runtime definition
- resolves published section references through
ui_section_publications, using the current section ID first and then the scoped slug/slug fallback for older page revisions with stale IDs - renders resolved runtime preview tree
- resolves localized metadata for
generateMetadata
Dynamic public routes expose SEO metadata through:
/[locale]/page.tsx/[locale]/[...slug]/page.tsx/[locale]/o/[orgSlug]/[[...slug]]/page.tsx
Global public pages also feed Google Search discovery through:
/robots.txt, which allows public content while excluding API, analytics ingest, dashboard, auth, maintenance, and organization-member paths/sitemap.xml, which lists every published global page for every configured locale, includeshreflangalternates, and skips pages markednoIndex- site-level JSON-LD for the Yayaw organization and website
Organization-member pages are protected by membership checks and emit noindex
metadata even when a page has its own SEO fields. For generative AI search, keep
page content people-first and avoid relying on special machine-only files as a
ranking lever; llms.txt remains documentation-oriented, not a Google Search
optimization path.
Organization-public pages use the request host for canonical URLs, hreflang
alternates, robots, sitemap entries, Open Graph URLs, and JSON-LD. Custom hosts
are public-only: dashboard, auth, API, docs, ingest, and /o/* surfaces are
blocked at proxy level. Verified custom hosts also resolve localized page
content from the URL locale prefix and apply the owning organization's public
theme and design-token overrides.
Organization Public Domains
Organization custom domains are stored in organization_public_domains and
managed from organization settings or MCP. A domain belongs to exactly one
organization, can be marked primary once verified, and stores provider status,
TXT verification challenges, recommended CNAME/A records, and last check
timestamp. The legacy vercel_* columns are still used for compatibility, but
the runtime treats the row as the generic public-domain verification snapshot.
The page registry stores an optional public_domain_id for org_public pages.
The dashboard creation and editor route-settings dialogs show the active
organization's non-archived public domains, defaulting to the primary verified
domain when available. Runtime host resolution filters domain-targeted pages by
the matched custom-domain row while keeping legacy untargeted pages visible on
all verified organization public domains.
PUBLIC_DOMAIN_PROVIDER=vercel uses the Vercel project-domain API to add and
verify domains. PUBLIC_DOMAIN_PROVIDER=manual-dns generates a TXT ownership
challenge and shows CNAME/A hints from PUBLIC_DOMAIN_CNAME_TARGET and
PUBLIC_DOMAIN_IPV4_TARGETS; operators configure DNS and ingress outside the
app, then re-check the domain. Publishing page content stays DB-driven and does
not redeploy the hosting provider.
Publish is blocked when diagnostics contain error severity.
Seeded Home Page
bun run seed creates a global public Home page at path / when no published
home page exists yet. The seeded document is a normal PuckPageDocumentV1 with:
- generated
headerandfootersections backed by the CMS singleton entriesheader-menu/mainandfooter-menu/main - localized English/French page copy and SEO metadata
- a content stack composed of generated hero, value grid, feature split, and CTA sections
The seed is intentionally non-destructive. Once a home page is already published, later seed runs keep the current published revision so Page Builder edits are not overwritten.
The docs route (/[locale]/docs) uses the same runtime header and footer
wrappers as the public layout. These wrappers read the editable CMS slots for
the clickable brand, center navigation, and right-side public mini menus while
leaving the Fumadocs content layout in charge of the docs sidebar and table of
contents.
Seeded Marketing Pages
bun run seed also creates Yayaw launch pages through the same page catalog
storage. Their page shells use the generated Header/Footer variants bound to
global menu data, while the page body remains generated-section content:
/is the published public home page for the source-owned SaaS codebase./codebaseis the published sales page for ownership and architecture./pricingis the published offer page for lifetime codebase access.
The same seed publishes the global data used by those pages, including
sales-offer/main and billing-product-content/pro-lifetime, so offer copy can
stay editable while product names, prices, Stripe IDs, and checkout state remain
resolved from the billing catalog.
The seed creates missing pages and can publish a newer revision when a page is
still owned by the same seed key and its seedVersion changes. Existing Page
Builder pages that are not seed-owned keep their current publication state and
latest revisions.
AI Page Builder
The page AI flow is reusable-section-first:
- it generates recipe-backed sections
- it can generate header/footer recipes when a page needs layout navigation
- it creates and publishes those sections in the catalog
- it inserts
PageSectionreferences into the page document - it does not persist new
PageGeneratedSectionnodes in saved documents - it receives the configured locales from
src/config/i18n.config.ts - it fills localized page titles and SEO metadata for every configured locale
- generated content bindings use recipe editor hints to preserve typed structures (for example badge arrays and card arrays) and store localized values for each configured locale
- generated header/footer menus use
itemsJsonwithlink,group,themeToggle,languageToggle, anduserMenuitems - it injects compact shadcn/ui composition guidance so landing pages use registry-quality section patterns, semantic tokens, CTA groups, proof bands, and real component usage hints instead of drifting into isolated controls
- it runs a dedicated localization pass after layout/content generation so missing
valuesByLocaleentries are translated for every locale in the page document - when a prompt asks for generated media, or strongly implies a rich landing-page
visual, Page AI can generate at most one configured OpenAI image asset
(default
gpt-image-1.5), persist it through the active organization media library, and bind it toheroImageor a compatible image media slot - global pages can use generated assets from the active organization's private media library because the stored file URL is public; if no active organization or media permission is available, image generation is skipped with a warning and the page draft still succeeds
If section creation or publication fails, the page save/generation fails with an explicit error instead of silently storing an inline fallback.
Page AI generation is durable:
- Page AI defaults to the fast
gpt-5.4-minimodel with a short fallback chain to reduce deterministic fallback drafts when a fast model returns invalid JSON. POST /api/ai/pages/runspersists the request in Postgres and wakes a workerGET /api/ai/pages/runs/:runIdreloads the latest run snapshotGET /api/ai/pages/runs/:runId/events?afterSeq=nreplays ordered progress eventsPOST /api/ai/pages/runs/:runId/cancelrequests best-effort cancellationui_page_ai_runsstores ownership, payload, status, partial result, final result, errors, attempts, and lock metadataui_page_ai_run_eventsstores ordered progress events for resume after reload- Vercel Queues is one wake-up transport; Postgres remains the source of truth.
- In production, the default wake-up driver is
vercel-queueonly on Vercel runtimes anddb-workerelsewhere. PAGE_AI_QUEUE_DRIVER=db-workerlets a long-lived worker process the same runs withbun run worker:page-aiwithout changing the editor/API contract.
Data Model
Page persistence tables:
ui_page_registry_itemsui_page_revisionsui_page_publicationsui_page_ai_runsui_page_ai_run_events
Reusable sections are stored separately in:
ui_section_registry_itemsui_section_revisionsui_section_publications
Page constraints remain unchanged:
- unique
(scope, organization_id, slug) - unique
(scope, organization_id, path) - immutable revisions with hash dedupe
- one publication row per registry item
Server APIs
Page actions live in:
src/lib/server/actions/pages/pages-catalog-actions.ts
Current actions:
listPagesForTableActionlistPagesActionopenPageForEditingActioncreatePageActionupdatePageDefinitionJsonActionsaveDraftPageActionpublishPageActionarchivePageAction
The dashboard table uses Yayaw Table custom bulk actions for the selected-row lifecycle shortcuts:
- publish selected pages through
publishPageAction - archive selected pages through
archivePageAction - keep bulk deletion on
deletePagesBulkAction
Section availability for the page builder comes from:
listAvailableSectionsForPageActionlistAvailableSectionsForPage
Image generation for page media fields is handled by the shared media image
asset service used by both the media picker and Page AI. Generated images use
the configured OpenAI image model, default to gpt-image-1.5, and follow the
same organization permissions, quotas, object storage upload, and media_assets
persistence path as manual uploads. The media picker displays stored thumbnails
when available, while bindings keep the original publicUrl so rendered pages
still use the source asset.
Published global data fields are exposed to the page editor as binding
references. Runtime page rendering resolves global_data_field and
global_data_query bindings from the published global data cache.
Text bindings also resolve namespaced CMS variable tokens such as {site.name},
{organization.name}, and {data.global.header-menu.main.brand_label}. Missing
tokens remain unchanged.
applyPageOperationsAction has been removed.
Validation
Useful checks after page catalog changes:
bun test src/lib/shared/pages/puck-document.test.ts
bun test src/lib/server/services/pages/page-definition-schema.test.ts
bun test src/lib/server/services/pages/page-definition-runtime.test.ts
bun test src/lib/server/services/pages/page-ai-annotation-runtime.test.ts
bun run check
bunx tsc --noEmitACL Baseline
Resource: page
Recommended organization policies:
- member:
page:list,page:read - manager/admin:
page:manage - super admin: global
page:manage