This document captures the page-builder model referenced by Extension Model, Frontend Architecture, Localization, and Tenant Customization Model. The accepted decision lives in ADR-0013 Page Block Schema Versioning; the data-driven block model is governed by ADR-0018 Tenant-Driven Customization Model.
A page is an ordered list of blocks. Each block instance carries a typed payload
and references a BlockDefinition by (key, schemaVersion). Two tiers of block
definitions exist:
- Built-in primitive blocks — registered in code by LearnStack
(
hero,rich-text,image,content-list,card-grid, …). The set is closed and changes only with a LearnStack release. - Tenant-defined blocks — declared as
TenantPageBlockrows in the customization module (ADR-0018). A tenant defines a schema + composite-renderer key (e.g.default-card); the runtime reads the schema, renders via the named composite, and dispatches to the configured built-in renderer. There are no vertical-prefixed block keys (english.vocabulary-listis not a code identifier — it is aTenantPageBlockrow for the English tenant).
flowchart LR
P[Page] --> PV[PageVersion draft]
P --> PV2[PageVersion published]
PV --> B1[Block hero v2]
PV --> B2[Block rich-text v1]
PV --> B3[Block course-list v3]
PV --> B4[Block vocabulary-list v1<br/>tenant-defined via TenantPageBlock]
- Payloads are JSON validated against the registered
JsonSchemaat save time. - Schema versions are immutable; a breaking change ships a new
(key, schemaVersion)pair. - Removing a
(key, schemaVersion)is allowed only after a query confirms zero remaining instances. - Lazy migration on save in the studio; bulk migration available as a platform-admin operation with dry-run.
- Known
(key, version)→ renders the component. - Known
key, unknownversion→UnknownVersionBlockplaceholder; log warning. - Unknown
key→UnknownBlockplaceholder; log warning. Triggered when theTenantPageBlockdefining the block has been deleted or its key has been renamed without migrating the existing page payloads.
Each block renders inside a React error boundary; one block crashing does not crash the page.
- No drag-and-drop. Picker + reorder buttons.
- Form-based editing driven by the block's
JsonSchema. - Inline preview via the production renderer in preview mode.
- Block edits are saved to the draft
PageVersion; published version untouched until "Publish".
Two strategies per block:
- Locale-baked block (default): one block instance per locale; page-level locale selector in the studio.
- Localised fields inside payload: for translation-heavy blocks where switching the entire page version per locale is awkward (e.g. rich text), payload uses
LocalizedTextper field.
See Localization.
When a block references data owned by another module (e.g. course-list block references the Education module's courses), the reference resolves through the owning module's read API — never through cross-module SQL or EF navigation. See Cross-Module Contracts.