CMS Data Models
Typed, localized, publishable data models and inline variables for CMS content.
Overview
Yayaw separates CMS content data from design tokens.
/dashboard/content/design-tokensremains the design-token editor./dashboard/content/data-modelsmanages scoped CMS data model structure./dashboard/content/datafills and publishes entries for those models.
CMS data models can be scoped:
global: shared site-level data controlled byglobal-data:manage.organization: active-organization data controlled bypage:manageorsections:manageon that organization.
Media-like values should be stored as URLs or strings. The organization media library remains scoped separately. Use Media Library for organization-owned binary assets and store only the resulting URLs in global or organization data entries.
CMS Role
CMS data is the structured source layer for content that needs a stable contract: site variables, menus, product facts, organization facts, reusable collections, and fields that pages or sections bind by reference. Data entries should hold values, not presentation. Sections and pages decide how those values render.
Use data models when authors need typed fields, localization, validation, and a published lifecycle. Use media assets for files, sections for reusable visual composition, and design tokens for visual theme values.
Document Contracts
Global data uses two versioned JSON documents:
GlobalDataModelDocumentV1GlobalDataEntryDocumentV1
Model documents define:
cardinality:singletonorcollectionfields: typed fields inspired by Builder custom fieldslocalization: default locale and supported localespresentation:headless,builtin_renderer, orcomponent_recipe
Cardinality controls how many entries a model can own:
singleton: one global entry, for exampleheader-menu,footer-menu, or default SEO settings.collection: many entries, for example products, authors, redirects, or reusable announcements.
Presentation controls how data is consumed:
headless: structured data only, consumed by pages, bindings, helpers, or custom code.builtin_renderer: data rendered by Yayaw runtime components, currently used by first-party models such as header and footer.component_recipe: data intended to be paired with a reusable UI recipe in a later workflow.
Each field supports the Builder-style settings editors expect:
type
localization
default value
helper text
required/optional
enum values for select fields
hidden fields that stay out of entry editing while preserving values/defaults
Hidden required fields must define a default value so generated entry editors cannot create impossible drafts.
Supported V1 field types are:
text,long_text,url,filenumber,boolean,select,colorrich_text,html,date,timestamplist,reference,map,javascript,code,tags,json
file stores a URL/string in V1 because the media library is organization-scoped
while global data is site-wide.
Entry documents store values as either:
shared: one value for every localelocalized: values per locale with fallback from requested locale to default locale, then first available locale
Storage
CMS data uses immutable revisions and one publication row per registry item.
Tables:
global_data_model_registry_itemsglobal_data_model_revisionsglobal_data_model_publicationsglobal_data_entry_registry_itemsglobal_data_entry_revisionsglobal_data_entry_publications
Revisions are hash-deduped and validated before publish. Publications can be
draft, published, or archived.
global_data_model_registry_items stores scope and organization_id.
Existing rows are migrated to scope = "global". Slugs are unique per global
scope or per organization scope.
Dashboard Editing
/dashboard/content/data-models and /dashboard/content/data render the
screens dashboard.content.data-models and dashboard.content.data (see
Dashboard). They are the organization's screens: each
organization customizes its own copy. Each default is a full-page table: the
models on the system:global-data-models source, the models edited last
first; the entries on system:global-data-entries, in their sort order; 20 a
page.
The tables list what the CMS data lists listed, no more and no fewer:
global models, and their entries, to whoever holds
global-data:read,global-data:listorglobal-data:manageglobally;the active organization's models, and their entries, to whoever holds
read,listormanageonglobal-data,pageorsectionsin that organization;never another organization's models, nor the Documentation model, which the Documentation workspace edits;
the entries of the deprecated
site-headerandsite-footermenus stay out of the entries table while aheader-menuorfooter-menumodel shows.
Whoever may change a model (global-data:manage globally for a global model,
manage on global-data, page or sections in the active organization for
its models) gets:
New model: the model form (name, slug, description, cardinality, presentation, the scope of a new model among those they manage, and the full-width field editor);
New entry: the entry form, for the models that take one (a singleton only while it has no entry): an inspector-style editor generated from the model's fields, localized values grouped by language, and the visual menu editor of the header and footer menus. Saving a menu's or the site variables' entry publishes it at once;
a row's click opens its edit form, which first loads the model's or the entry's latest document;
Publish and Archive for a selection, each confirmed; publishing takes only the rows whose latest revision is valid and not yet published.
Everyone else reads the tables, which say they are read-only. The server checks every write, and every form's load, again.
The tables keep the lists' columns, saved views and favorites
(global-data-models-table, global-data-entries-table), and their work
queues after the screen's own view: Drafts to review and Published
content. They offer Table, List, a Kanban by status whose cards cannot be
dragged (a model or an entry changes through its form) and Chart.
Search reads names and slugs, and an entry's model's name and slug. It never reads a document, a description or a field's value.
Paging: at most 100 rows a page (10, 20 or 50 offered).
Order: names sort as people read them ("Entry 2" before "Entry 10"), in the database; where two names differ only by case or accents, the lowercase, unaccented one comes first.
URL parameters: the tables' parameters are keyed by their sources (
system:global-data-models-…,system:global-data-entries-…), andview=<id>opens a saved view or a work queue.
An assistant preparing a draft of these screens reads the sources' columns only, never a row's id, its revisions or whether the viewer may change it; assistants read and write CMS data with the MCP data tools below.
Organization models have no seeded policy of their own: they follow the
organization's page and sections policies (the seeded Organization Member
reads them, Organization Manager and Organization Admin edit them), and an
organization-scoped global-data policy may grant them explicitly. The
dashboard, the CMS variables catalog and the MCP data tools
(yayaw_data_models_list, yayaw_data_schema_get, yayaw_data_entry_get,
yayaw_data_entry_upsert, yayaw_data_entry_publish) decide with can()
alone. A Better Auth member role string, owner included, never grants access.
Runtime APIs
Server helpers live in:
src/lib/server/services/global-data
Key helpers:
getPublishedGlobalDataEntrygetPublishedGlobalDataValuelistPublishedGlobalDataEntrieslistPublishedGlobalDataFieldReferences
Most helpers accept scope and organizationId when organization-scoped data is
needed. Existing page-builder global_data_field and global_data_query
bindings continue to resolve global data.
The shared runtime slot component lives in:
src/blocks/shared/global-data-slot.tsx
It resolves a published singleton entry and falls back to the existing static UI when no published data is available.
Header and Footer
Header and footer are no longer seeded section catalog entries or editable page props on every page.
The seed script creates two singleton built-in renderer models:
header-menufooter-menu
Both receive a default main entry.
Generated page headers and footers bind to these singleton entries with
global_data_field props. Pages therefore share one localized source of truth
for brand labels, locale-specific brand URLs, menu items, footer tagline, and
copyright text.
The items_json field is edited with a visual menu editor in the global data
entry form on /dashboard/content/data. It supports top-level links, groups
with one nested link level, theme toggles, language toggles, account actions,
reorder controls, and a raw JSON recovery panel.
Header items can target the left, center, or right zones; the default entry uses
the left brand link, center navigation, and right-side theme/language/account
actions.
The default header-menu/main and footer-menu/main entries organize public
navigation into two first-level groups:
Yayaw:/[locale]/codebase,/[locale]/pricing, and/[locale]/docsYayaw Table:/[locale]/table,/[locale]/table/example,/[locale]/docs/table, and/[locale]/docs/table/installation
Keep Yayaw Table developer documentation links under the Fumadocs subproject at
/[locale]/docs/table/*. CMS-authored /[locale]/table/* pages remain the
public product and demo surfaces. The shared header/footer loader normalizes
legacy menu hrefs under /[locale]/table/docs/* or /table/docs/* to the
current /[locale]/docs/table/* route at render time, so older published menu
entries do not keep sending users to retired docs paths after an upgrade.
Old site-header, site-footer, builtin-header, and builtin-footer seed
rows are deleted by migration. Legacy page documents that still reference the
old built-in sections are normalized into generated header/footer nodes.
Page Builder Bindings
Page prop bindings support global data in addition to literals and media assets.
Binding kinds:
global_data_field: binds one field from one published entryglobal_data_query: resolves a published collection and optionally extracts one field
The Puck inspector exposes a "Bind data" control when published global data references are available. V1 binds complete fields.
Inline CMS variables can also be inserted into text values from the dashboard
header catalog on /dashboard/content/* routes. Tokens are namespaced to avoid
collisions:
{site.name}{organization.logo}{data.global.header-menu.main.brand_label}{data.organization.<modelSlug>.<entrySlug>.<fieldKey>}{billing.product.pro_monthly.name}{billing.price.pro_monthly.id}
Unknown tokens remain unchanged at runtime. Non-namespaced tokens such as
{name} are ignored in V1.
Site variables are edited through the CMS Data entry for the built-in
site-variables/main singleton model. Its default fields are locked so the
editing surface can change values without deleting the canonical site.*
contract. Each field still keeps its own localization setting: technical values
such as base_url, domain, and logo URLs stay shared, while editorial values
such as description and title can be localized and resolve according to the
requested locale. Existing system_settings values under
cms.site-variables.v1 remain as migration and fallback data.
Billing product and price variables are read from the runtime billing product
catalog. Product metadata comes from billing_products when configured, while
Stripe price IDs are stored internally after the admin or MCP catalog sync.
Organization variables are read from the Better Auth organization row; editing
continues to live in organization settings flows.
The built-in billing-product-content global collection complements the billing
catalog with one CMS entry per product key. Its locked catalog_product_key
field links the CMS entry to Products & Services; editorial fields such as
badge, summary, feature bullets, CTA label, and highlight state can be localized.
Do not store product names, prices, intervals, Stripe IDs, or checkout
availability in this model. Resolve those facts from the billing.product.* and
billing.price.* variable namespaces.
The seed also carries the Supabase-exported global data snapshot for production
site variables, header and footer menus, and the sales-offer/main singleton.
These entries are published on every seed run so fresh production or preview
databases resolve the same CMS variables that were authored in Supabase. The
billing-product-content entries are still created only when missing so later
editorial product copy is not overwritten by an empty baseline seed.
Cache Invalidation
Published global data is cached with tags:
global-dataglobal-data:{modelSlug}
Publish and archive actions revalidate the global data tags, dashboard data routes, the public root path, and the published page runtime cache.
Validation
Useful checks after CMS data model changes:
bun test src/lib/shared/global-data/global-data.test.ts
bun test src/lib/server/services/screens/sources/system/global-data.test.ts
.github/scripts/with-throwaway-postgres.sh env SCREENS_TEST_THROWAWAY=1 bun test src/lib/server/services/screens/sources/system/global-data.postgres.test.ts
bun run check
bunx tsc --noEmit