Journey Docs
CMS

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:

sectioncolumnrowmodulepage
section
module · span 12
section
column · start 0 · span 7
row
module · span 6
module · span 6
row
module · span 12
column · start 7 · span 5
row
module · span 12
section
module · span 4
module · span 4
module · span 4

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.

NodeMay containCarries placement
rootsectionNo
sectioncolumn, moduleNo — always a root container
columnrowYes
rowcolumn, moduleNo — always full width in its column
modulenothingYes

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 };
}
  • start is 0..11, span is 1..12, and start + 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.
0
1
2
3
4
5
6
7
8
9
10
11
desktop
start 0 · span 7
start 7 · span 5
mobile
start 0 · span 12
start 0 · span 12

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.

PropertyApplies toValue
backgroundsection, row, columncolor, linearGradient (direction + exactly two colors), or image (HTTPS CMS-hosted URL, position, cover | contain | auto)
margin, paddingalltop/right/bottom/left CssLength
maxWidthsection, row, columnpixel integer
verticalAlignmentsection, row, columntop | middle | bottom
fullWidthsection onlyboolean
horizontalAlignmentmodule onlyleft | 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

TableHolds
core.cms_page_layoutsone layout per page row, with its revision
core.cms_page_layout_nodesthe section/column/row/module tree, placement, and styles
core.cms_page_themesgrid JSONB defaults

Source: api/src/core/cms/pages/layout/, api/src/core/cms/pages/schema/cms-page-layout-nodes.schema.ts.

On this page