CMS Grid
The page-scoped 12-column layout tree — node hierarchy, placement contract, responsive behavior, and styling allowlist
Every CMS page is a single drag-and-drop area that owns one layout tree. The
tree sits above module content: cms_page_blocks rows hold what a module
says, and the layout holds where it sits. The two are stored and validated
separately, so reordering a page never rewrites block content.
The column count is a platform invariant. Every section and row creates a 12-column child grid, and that number is not theme-configurable.
Composition hierarchy
The hierarchy is strict, and modules are always leaves. A page is one drag-and-drop area whose containers nest to produce the rendered layout:
A page is built from three sections. The middle section splits into two columns,
and the left column stacks two rows. A module may sit directly in a section or
inside a row — both appear above. Only column and module nodes carry
placement; sections and rows are always full width within their parent.
| Node | May contain | Carries placement |
|---|---|---|
| root | section | No |
section | column, module | No — always a root container |
column | row | Yes |
row | column, module | No — always full width in its column |
module | nothing | Yes |
Sections and rows do not carry placement relative to their own children. A row is always full width within its parent column, and a section is always a root container. This prevents two competing width concepts from existing at once.
A module node references exactly one existing cms_page_blocks row, and every
page block appears exactly once in the layout. A page may contain many module
instances of the same block_type — each is its own block row with independent
content and its own module node. The one-placement rule constrains block rows,
not block types.
Placement contract
Direct column and module children of sections and rows declare a zero-based
start and a span:
interface GridPlacement {
desktop: { start: number; span: number };
tablet?: { start: number; span: number };
mobile?: { start: number; span: number };
}startis0..11,spanis1..12, andstart + span <= 12.- Sibling intervals must not overlap at any breakpoint. Gaps are allowed.
- Tablet inherits desktop when omitted.
- Mobile defaults to
{ start: 0, span: 12 }when omitted, so desktop columns stack safely on small screens.
The same two children at two breakpoints. Each becomes full width on mobile and they stack in document order.
The API uses start/span rather than offset/width because start is
unambiguously an absolute grid line, which makes overlap validation explicit.
Responsive behavior
Sections and rows take an optional columnBehavior at tablet and mobile:
- Stack renders every direct child full width. It is authoritative and non-destructive — saved child widths are preserved for later use.
- Keep columns uses the child widths saved for the active breakpoint, inheriting missing widths from the next wider breakpoint. Widths remain editable and always form one packed 12-column line.
An explicit tablet choice cascades into mobile until mobile owns its own choice. Stacking order always equals document order; there is no breakpoint-specific reordering, offsets, or custom mode.
Layouts saved before this setting existed infer their behavior rather than being migrated. Ambiguous legacy arrangements render unchanged until an author explicitly picks Stack or Keep columns.
Padding and margin are editable per breakpoint on every node type. A narrower breakpoint inherits from the wider one; on first edit the complete effective four-sided value is copied into the active breakpoint, and the override can be reset back to inheritance. The admin inspector shows whether a value is automatic, inherited, or overridden.
Styling
styles is a responsive object — desktop plus optional tablet and mobile.
Values are typed DTOs. Raw CSS, class names, style strings, and scripts are
never accepted.
| Property | Applies to | Value |
|---|---|---|
background | section, row, column | color, linearGradient (direction + exactly two colors), or image (HTTPS CMS-hosted URL, position, cover | contain | auto) |
margin, padding | all | top/right/bottom/left CssLength |
maxWidth | section, row, column | pixel integer |
verticalAlignment | section, row, column | top | middle | bottom |
fullWidth | section only | boolean |
horizontalAlignment | module only | left | center | right |
CssLength is { value: number, unit: 'px' | 'rem' | 'em' | '%' | 'vw' | 'vh' },
with finite-number and conservative range validation. Only one background
variant may be present at a time.
Responsive style objects use shallow property inheritance: an omitted breakpoint inherits the next wider one, and a supplied property replaces that property as a whole. This is deterministic for renderers and avoids CSS-string merge rules in the API.
Block-specific visual options stay in the block schema rather than becoming unvalidated layout styles.
Theme defaults
cms_page_themes carries a grid object holding page-wide defaults — content
max width, row and column gap, and the mobile and tablet breakpoints. The column
count is deliberately excluded; it is always 12.
Writes and delivery
Layout is read and replaced atomically through the Journey-admin endpoints under
/admin/pages/:pageGroupId/layout, gated by the existing PAGES_READ and
PAGES_MANAGE permissions. Replacement carries an expectedRevision so one
editor cannot silently overwrite another's drag-and-drop changes.
Block mutations stay independently useful. Creating a block automatically places it in a new full-width section, deleting a block removes its module placement, and the flat reorder operation reassigns blocks across the existing module slots in depth-first visual order rather than discarding the tree.
Public page delivery returns the resolved layout tree, whose module nodes carry
the referenced block's public blockKey, blockType, and content. The flat
blocks array is retained as a compatibility projection in depth-first visual
order. No internal numeric identifiers are exposed — layout nodes use ULID
nodeKey values and modules use ULID blockKey values.
Clone-on-edit preserves nodeKey values and remaps internal IDs, and publishing
promotes the draft row transactionally, so layout changes cannot leak into a live
page.
Storage
| Table | Holds |
|---|---|
core.cms_page_layouts | one layout per page row, with its revision |
core.cms_page_layout_nodes | the section/column/row/module tree, placement, and styles |
core.cms_page_themes | grid JSONB defaults |
Source: api/src/core/cms/pages/layout/, api/src/core/cms/pages/schema/cms-page-layout-nodes.schema.ts.