Page Builder Frontend Implementation
This guide is for frontend developers (and coding agents) implementing the WhiteBeard News Suite page builder on an existing frontend (usually Astro). It covers the API, the rules for rendering a layout, and an Astro implementation.
What the page builder is
Section titled “What the page builder is”Editors build pages in the CMS as rows → columns → blocks on a 12-column grid. They can change the display per screen size, hide items per screen size, and reuse presets. The CMS returns a resolved layout: a JSON tree where every block already has its display variant, options and content. The frontend’s job is to:
- fetch the layout,
- render the grid,
- map each block (
type+variant) to a component.
The frontend does not query content for blocks. Stories, de-duplication across the page, offsets and limits are already applied by the CMS.
The CMS defines what goes where and which display to use. The frontend owns how it looks. A
variantis a design name, likelead_grid, that your design system implements. It is never CSS.
What you deliver
Section titled “What you deliver”- The renderer: fetching, grid, block components, category and article templates, preview route (§1–§6).
- The variant catalogue: a variant component for every block type, covering every display the site already has, plus a
pagebuilder-variants.jsonmanifest and preview images, registered in the CMS through the variants API (§7). Editors can only choose variants that are registered in the CMS, so this step is what makes your designs available to them.
1. API
Section titled “1. API”All endpoints are under the CMS API base, e.g. https://cms.example.com/cms, and need the API-Key header. Call them server-side only, so the API key never reaches the browser.
| Endpoint | Use it for | Response |
|---|---|---|
GET /cms/page/{pageId}/layout |
Homepage, section fronts, landing pages (anything that is a CMS Page) | { page, layout }, where layout is PageLayout or null |
GET /cms/category/{id}/layout |
Category pages, in one call: the category, its template and the category’s stories already in the template (§5.7) | TemplatePage<'category'> |
GET /cms/author/{id}/layout/{publicationIds} |
Author pages, the same way (§5.7) | TemplatePage<'author'> |
GET /cms/keyword/{id}/layout/{publicationIds} |
Keyword (tag) pages, the same way (§5.7) | TemplatePage<'keyword'> |
GET /cms/content/{id}/layout |
Article pages, in one call: the article as /cms/content/{id} returns it, its article template and the related stories already in the template (§5.8) |
ContentWithLayout |
GET /cms/layout/key/{key} |
Templates and standalone layouts (a shared sidebar), by the stable key set in the editor | PageLayout |
GET /cms/layout/{layoutId} |
Same, by numeric ID. Prefer the key: IDs differ between environments | PageLayout |
GET /cms/layout/{layoutId}/preview/{key} |
Previewing an unpublished draft from the CMS editor | PageLayout |
GET /cms/pagebuilder/blocks |
Discovering enabled block types, their settings and variants (for type generation and dev checks) | PageBuilderBlocks |
GET /cms/pagebuilder/variants |
Exporting every registered variant, including disabled ones, in manifest format (admin key, §7.5) | PageBuilderVariantManifest |
PUT /cms/pagebuilder/variants |
Registering your pagebuilder-variants.json (admin key, §7.5) |
PageBuilderVariantImportReport |
PUT /cms/pagebuilder/variants/{blockType}/{key} |
Registering or updating one variant (admin key) | PageBuilderVariantImportReport |
POST /cms/pagebuilder/variants/{blockType}/{key}/thumbnail |
Uploading a variant’s preview image (admin key) | { thumbnailId, thumbnail } |
Optional query parameter: include_text=1 (full article text) or include_text=2 (introduction and summary only) on the layout endpoints. It has the same meaning as on /cms/page/{id}. The category, author and keyword /layout endpoints also take page_number (default 1), limit (stories per page when no template applies) and default_template={key} (the template to use when the object has none, §5.7); the category one also takes include_subcategories=1, as /cms/category/{id}/content does. /cms/content/{id}/layout takes default_template only (§5.8).
Notes:
/cms/page/{id}is unchanged. It still returnsissueanddynamicSectionsfor frontends that don’t use layouts. Don’t call both: the layout endpoint already contains every story the layout shows.- Feature switch: if the page builder is turned off in the CMS, the layout endpoints return 404. Fall back to your existing page rendering (see §5.6).
- No layout yet:
layout: nullmeans the page has no published layout. Fall back the same way. - Which template applies is a
layoutTemplatefield on the views you already fetch:GET /cms/category/{id},GET /cms/publication/{id},GET /cms/content/{id}(next todata),GET /cms/author/{id}andGET /cms/keyword/{id}. It isTemplateMatchornull; alwaysnullwhen the page builder is off (§5.7, §5.8). Don’t confuse it with the category’s oldertemplatefield (a template name set on the category), which is unchanged. - Caching: responses are CDN-cached and purged automatically when an editor publishes. Don’t add long caches of your own on top; follow the response
Cache-Control. When editors change what a template is used for, those categories, publications, authors and keywords are purged; subcategories and articles that inherit the template pick it up when their cache expires. The category, author, keyword and content/layoutresponses are tagged with their template (pblayout-{site}-{layout.id}) at the CDN, and are purged by that tag whenever the template changes, wherever it is used. - Your own cache (purge webhook): if the site caches CMS responses or pages built from them and receives the CMS purge webhook, handle its
tagstoo (see §5.9). - Full schemas: the API reference (
PageLayout,PageLayoutBlock,PageBuilderBlocks, …) has every field. This guide covers what you need to render.
2. Data shapes (TypeScript)
Section titled “2. Data shapes (TypeScript)”export type Breakpoint = 'lg' | 'md' | 'xs'; // desktop, tablet, mobileexport type PerBreakpoint<T> = Record<Breakpoint, T>;export type Spacing = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; // a size of your spacing scale (§3.2)
export interface PageWithLayout { page: { id: number; name: string; url: string; metadata: Record<string, unknown> }; layout: PageLayout | null;}
export interface PageLayout { id: number; name: string; kind: 'front' | 'landing' | 'category_template' | 'article_template' | 'author_template' | 'keyword_template'; versionId: number; // 0 in previews key: string | null; // stable key (GET /cms/layout/key/{key}) direction: 'ltr' | 'rtl'; // layout's own setting, else the site default schemaVersion: 1; rows: LayoutRow[]; streams: Record<string, { items: number }>; // external lists you must provide (§5.7), e.g. { main: { items: 20 } }}
// GET /cms/category/{id}/layout, /cms/author/{id}/layout/{publicationIds}, /cms/keyword/{id}/layout/{publicationIds}export type TemplatePage<K extends 'category' | 'author' | 'keyword'> = { total: number; // stories of the category, author or keyword page: number; limit: number; // stories per page: the template's streams.main.items, else the `limit` parameter layout: PageLayout | null; // `main` already filled; null when no template applies data?: any[]; // only when layout is null: the page of stories} & Record<K, any>; // the object, as GET /cms/{K}/{id} returns it (with layoutTemplate)
export interface LayoutRow { id: string; settings: { background?: 'muted' | 'dark' | 'brand'; fullWidth?: boolean; marginTop?: Spacing; marginBottom?: Spacing; marginStart?: Spacing; marginEnd?: Spacing; // outside the row paddingTop?: Spacing; paddingBottom?: Spacing; paddingStart?: Spacing; paddingEnd?: Spacing; // inside it, within its background }; visibility: PerBreakpoint<boolean>; presetId?: number; // came from a linked preset (already inlined), informational only columns: LayoutColumn[];}
export interface LayoutColumn { id: string; span: PerBreakpoint<number>; // 1–12, all three always present order: Partial<PerBreakpoint<number>>; // only breakpoints that reorder visibility: PerBreakpoint<boolean>; blocks: LayoutBlock[];}
export interface BlockDisplay { variant: string; variantOptions: Record<string, unknown>; limit: number; // list blocks: how many items to show at this breakpoint}
export interface LayoutBlock { id: string; type: string; // 'content_list' | 'tabs' | 'rich_text' | 'image' | 'embed' | 'cta' | 'ad_slot' | 'newsletter_signup' | 'social_links' | 'messaging_zone' | 'html' | 'page_header' | 'poll' | 'form' | 'article_body' | 'author_box' | 'article_tags' | 'comments' | 'custom' | (future types) disabled?: true; // if present: render nothing (only id/type are sent) variant: string; // = responsive.lg.variant variantOptions: Record<string, unknown>; // = responsive.lg.variantOptions settings: Record<string, any>; // per type, see §4 responsive: PerBreakpoint<BlockDisplay>; visibility: PerBreakpoint<boolean>; presetId?: number; limit?: number; // list blocks: length of `data` (max of the breakpoint limits) data?: any; // list blocks: the items (array), ready to render; rich_text / image / embed: an object (§4) children?: LayoutBlock[]; // container blocks (tabs) stream?: { name: string; offset: number; items: number }; // external lists: which slice of which list to show (instead of `data`) // Images and content picked in `image` / `content` options (variantOptions hold their IDs), keyed by ID optionData?: { images?: Record<string, Image>; content?: Record<string, Content> };}
// `layoutTemplate` on GET /cms/category/{id}, /cms/publication/{id}, /cms/content/{id}, /cms/author/{id} and /cms/keyword/{id}// GET /cms/content/{id}/layout: GET /cms/content/{id} plus the templateexport interface ContentWithLayout { data: any[]; // [article], as GET /cms/content/{id} layoutTemplate: TemplateMatch | null; layout: PageLayout | null; // `related` already filled; null when no template applies}
export interface TemplateMatch { id: number; // layout ID key: string | null; // fetch with /cms/layout/key/{key}, else /cms/layout/{id} name: string; matchedBy: 'article_type' | 'category' | 'parent_category' | 'publication' | 'author' | 'keyword'; targetType: 'article_type' | 'category' | 'publication' | 'author' | 'keyword'; targetId: number;}3. Rendering rules
Section titled “3. Rendering rules”3.1 Breakpoints
Section titled “3.1 Breakpoints”The CMS uses three abstract breakpoints. Map them to your site’s media queries. Sensible defaults:
| Key | Meaning | Suggested CSS |
|---|---|---|
xs |
mobile | < 768px |
md |
tablet | 768px – 1023px |
lg |
desktop | ≥ 1024px |
Render one HTML response for all devices. Don’t detect the device server-side, because responses are CDN-cached per URL. Express every per-breakpoint difference with CSS (classes, custom properties, media queries).
3.2 Grid
Section titled “3.2 Grid”- Rows go top to bottom. Each row is a 12-column CSS grid.
column.span[bp]is the column width at that breakpoint, with all three always filled in. Usegrid-column: span N.column.order[bp], when present, is the CSSorderat that breakpoint, e.g. a sidebar moved below the main column on mobile.row.settings.fullWidth: the row spans the full viewport instead of your content container.row.settings.background:muted,darkorbrand. Map these to your theme.- Row spacing:
margin*(space outside the row) andpadding*(space inside it, within its background), each withTop,Bottom,StartandEnd. Start and end are logical sides, like CSSmargin-inline-start/-end: left and right in a left-to-right layout, right and left in a right-to-left one. Use the logical CSS properties and they followdir(§3.2), no code per direction. Each is a size name,none,xs,sm,md,lgorxl, never a pixel value: map them to your own spacing scale (noneis 0). Absent means your default, so rows editors didn’t touch keep the spacing your site already uses. The same values apply on every screen size; your scale can still grow with the screen (e.g.lgis 2rem on mobile, 4rem on desktop). layout.direction(ltrorrtl): the direction editors laid the page out in. Columns are listed in reading order, and CSS grid places them according todir, so don’t reverse anything in code.- Site entirely in one direction (e.g.
<html dir="rtl">): nothing to do. - Mixed-language site: set
dir={layout.direction}on the layout wrapper (see §5.3), so an Arabic layout flows right-to-left on an English site and vice versa. - Use logical CSS (
margin-inline-start,inset-inline-end, …) in your block components so they mirror correctly.
- Site entirely in one direction (e.g.
A CSS approach that works well is per-breakpoint custom properties:
.pb-row { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap, 1.5rem); }.pb-col { grid-column: span var(--span-xs); order: var(--order-xs, 0); }@media (min-width: 768px) { .pb-col { grid-column: span var(--span-md); order: var(--order-md, 0); } }@media (min-width: 1024px) { .pb-col { grid-column: span var(--span-lg); order: var(--order-lg, 0); } }
.pb-hide-xs { } .pb-hide-md { } .pb-hide-lg { }@media (max-width: 767.98px) { .pb-hide-xs { display: none !important; } }@media (min-width: 768px) and (max-width: 1023.98px) { .pb-hide-md { display: none !important; } }@media (min-width: 1024px) { .pb-hide-lg { display: none !important; } }
/* Row spacing (spacingClasses() below): map the sizes to your scale */.pb-row-wrap { --pb-space-none: 0; --pb-space-xs: .5rem; --pb-space-sm: 1rem; --pb-space-md: 1.5rem; --pb-space-lg: 2.5rem; --pb-space-xl: 4rem; }.pb-mt-none { margin-top: 0; } .pb-mt-xs { margin-top: var(--pb-space-xs); } /* … every size, and pb-mb-*, pb-pt-*, pb-pb-* */.pb-ms-lg { margin-inline-start: var(--pb-space-lg); } .pb-pe-sm { padding-inline-end: var(--pb-space-sm); } /* start/end: logical properties */order does not inherit between breakpoints. It is only sent for breakpoints where the editor changed it; every other breakpoint uses the default order, 0. Always set the variable for all three breakpoints, so a mobile-only reorder doesn’t leak into tablet or desktop. The columnStyle() helper below does this.
3.3 Visibility
Section titled “3.3 Visibility”visibility[bp] === false means “hide at this breakpoint” for a row, column or block. Hidden items are still in the response. Render them with the matching pb-hide-* class. Don’t drop them on the server.
3.4 Per-breakpoint display (responsive)
Section titled “3.4 Per-breakpoint display (responsive)”A block can use a different variant, different options or a different number of items per breakpoint. For example, lead_grid with 7 stories on desktop becomes compact_byline with 4 on mobile.
Rule:
- Group the three breakpoints by identical
{variant, variantOptions, limit}. - Render one copy of the block per group, with
pb-hide-*classes for the breakpoints not in that group. - In each copy, use
data.slice(0, limit).
Most blocks are identical everywhere, so this produces a single copy. Use responsive.lg when you need the “default” display.
3.5 Variants and unknown values
Section titled “3.5 Variants and unknown values”- Map
type→ component, thenvariant→ design inside that component. - Unknown
variant(editors can create variants before your frontend implements them): render the block type’s default variant. Get it from/cms/pagebuilder/blocks(defaultVariant), or hard-code it per type. Never crash. Exception: blocks withvariantFallback: falsein/cms/pagebuilder/blocks(custom) render nothing for an unknown variant. - Unknown
type(a block added to the CMS later): render nothing. Log a warning in development. disabled: true: render nothing.- Use
block.id,row.idandcolumn.idas stable keys. They’re unique within a layout.
3.6 Content is already resolved
Section titled “3.6 Content is already resolved”datais ready to render. It’s ordered, de-duplicated across the page (a story never appears twice), and theoffsethas already been applied. Don’t re-sort, re-query or de-duplicate.- Limits:
data.length ≤ limit. Each breakpoint showsdata.slice(0, responsive[bp].limit). - Item shape:
- Article items use the CMS Content shape, the same as
/cms/content/{id}(id,title,firstPublished, categories, images, …). Reuse your existing article card components. - Curated category or author sections return Category or Author objects instead. Check the item shape.
- Article items use the CMS Content shape, the same as
- Empty lists:
datacan be empty, e.g. a new section with no stories yet. Render nothing (or just the heading); don’t break the layout. - External lists are the exception. A block with
settings.source.kind === 'external'has nodata, onlystream: { name, offset }. Its items come from a list you fetch, typically the category or author being rendered by a template. See §5.7.
4. Block reference
Section titled “4. Block reference”content_list
Section titled “content_list”A list of content from one source, under an optional heading. This covers most of a news front: top stories, category lists, opinion, most read, video.
| Field | Meaning |
|---|---|
settings.header.title |
Section heading (may be empty, so render no heading) |
settings.header.link |
null or { type, value } for the “More →” link: type: 'url' → value is a URL; 'category' → value is a category ID (build your category URL); 'page' → a CMS page ID (map it to your route) |
settings.source.kind |
section | query | mostread | mostcommented: informational only, the content is already in data. external: you provide the items (block.stream, §5.7) |
settings.offset, settings.dedupe |
Informational only: already applied to data |
responsive[bp].variant / .variantOptions / .limit |
Display per breakpoint (§3.4) |
data |
The items |
Variants examples (each deployment can add, rename or disable its variants; keys never change):
| Variant | Typical rendering | Common options |
|---|---|---|
thumb_list (usual default) |
Vertical list, small image beside each headline | showByline, showDate |
lead_grid |
One large lead story, then a grid of smaller ones | leadBadge (e.g. “TOP STORY”), showByline |
thumb_list_2col |
Thumbnail list flowed into 2 columns (tablet/desktop) | showByline |
compact_byline |
Text-only headlines with author, for sidebars | — |
single_card |
One story as an image card | — |
card |
Large image card(s) with headline, date, comment count | — |
numbered |
Ranked text list (1, 2, 3…) | — |
image_only |
Image with no headline (cartoons) | — |
player_playlist |
Featured video player + “up next” list | playlistTitle |
carousel |
Horizontally scrolling cards (mobile/tablet only) | — |
Always read variantOptions defensively. Options are defined per variant in /cms/pagebuilder/blocks → variants[].options (a JSON Schema).
A container. children are content_list blocks, and each child’s settings.header.title is its tab label. Variants: tabs (default) and accordion (typically on mobile; see §3.4). Render each child’s items as above.
ad_slot
Section titled “ad_slot”settings.slot is the ad unit known to your ad stack, e.g. /21894097782/Homepage_Leaderboard. settings.sizes is optional: the sizes the unit accepts per breakpoint, in Google Publisher Tag format (a list of [width, height] pairs and/or "fluid"), ready for googletag.defineSlot() or a data-dimension attribute:
{ "lg": [[970, 90], [970, 250], [728, 90]], "xs": [[300, 250], [320, 100]] }A breakpoint without sizes uses the next larger one’s (desktop first): above, tablets get the lg sizes. No sizes at all: use your own defaults for the unit. Reserve space with the smallest size to avoid layout shift, e.g. min-height: 90px for the list above. Editors often use per-breakpoint visibility to show different slots on mobile and desktop.
---const { block, bp } = Astro.props; // bp: the breakpoint you render for, or emit one slot per breakpointconst order = ['xs', 'md', 'lg'];const sizes = order.slice(order.indexOf(bp)).map(b => block.settings.sizes?.[b]).find(Array.isArray) ?? [];const fixed = sizes.filter(s => Array.isArray(s));---<div class="advertisement" data-adunit={block.settings.slot} data-dimension={JSON.stringify(sizes)} style={fixed.length ? `min-width:${Math.min(...fixed.map(s => s[0]))}px;min-height:${Math.min(...fixed.map(s => s[1]))}px` : undefined}></div>newsletter_signup
Section titled “newsletter_signup”settings.channelId, plus optional title, description and buttonLabel. Submit with POST /cms/channel/{channelId}/subscribe, the same flow as your existing newsletter forms.
social_links
Section titled “social_links”settings.title (optional) and settings.links: [{ network, label?, url }]. network is one of facebook, instagram, x, whatsapp, telegram, tiktok, youtube, threads, linkedin, rss.
messaging_zone
Section titled “messaging_zone”A placeholder for the CMS Messaging Tool. Render exactly:
<div data-wb-zone-key="{settings.zoneKey}"></div>and nothing else. The messaging loader script (already on the site, see Messaging Tool Frontend Implementation) discovers zones and fills them. The key is validated as [A-Za-z0-9_.:-], but still escape it as an attribute. If your site navigates client-side, make sure the loader sees zones added after the initial load (see Messaging Tool Frontend Implementation).
settings.html is raw HTML from a trusted editor: embeds, widgets, scripts. Insert it unescaped. Scripts only run if they’re in the initial server-rendered HTML. HTML injected later with innerHTML doesn’t execute <script> tags. In Astro, server-render it with set:html (see §5).
rich_text
Section titled “rich_text”Formatted text written in the editor. data.html is ready-to-render HTML, produced exactly like an article body (/cms/content/{id}): the same markup, figures and embeds. Render it the way you render article bodies (e.g. set:html inside your article typography styles). Don’t use settings.html: that’s the editor’s raw markup.
One media library image.
data.imageis the image in the article API’s v3 shape:url,url_<size>per configured size,width,height,focal,transforms.data.altanddata.captionfall back to the library’s own description and name when the editor leaves them empty.data.creditis a string, possibly empty.- Link:
settings.link(optional, safe http(s)/path/mailto) andsettings.newTab. dataisnullif the image was deleted from the library: render nothing.
A URL or embed code, handled like article embeds. data.html is the same HTML articles get (YouTube/Vimeo players, X/Instagram/TikTok/Facebook/Telegram embeds, link cards, third-party code), and data.provider is e.g. youtube, twitter, instagram, custom_link, thirdparty. Render data.html the way you render embeds inside article bodies, including any provider scripts your article pages load. data is null when nothing valid is embedded.
Call to action. settings.title, settings.text (plain text, may contain line breaks), settings.buttonLabel, settings.url (http(s), site path or mailto, validated) and settings.newTab. No data. Variants (e.g. default, banner, card) are the design.
page_header
Section titled “page_header”The title of the page a category, author or keyword template is rendering (§5.7). No settings and no data: render it from the object you fetched, passed to the layout as page: the category (GET /cms/category/{id}), the publication on a publication page (it only has a name; skip the rest), the author (GET /cms/author/{id}) or the keyword (GET /cms/keyword/{id}). Check layout.kind to know which.
The details to show are options of its variants (variantOptions, per breakpoint as usual), so each design offers what it can render. The variants are generic: one variant serves all three kinds, and you ignore options that don’t apply to the page you’re rendering. The CMS seeds default (plain heading) and banner (the cover, else image, behind the name) with these options; register your own in the manifest (§7) to change them:
| Option | Applies to | Render from page |
|---|---|---|
| (always) | all | name as the page’s <h1> |
showDescription (default true) |
all | description |
showTagline (default true) |
categories | tagline |
showBreadcrumbs |
categories | parent chain, as links |
showSubcategories |
categories | children, as links |
showImage (default true) |
authors | image |
showBiography |
authors | biography |
Render nothing for an empty field.
custom
Section titled “custom”Your own components, for anything the CMS has no block for (a weather widget, an election map, a promo strip). Each variant is one component: you register it in the manifest under custom (§7.3) with the fields editors fill in, and render it by variant. Editors add a “Custom component” block and pick the component.
- Unknown variant → render nothing. Unlike other blocks, a custom component is never swapped for a default one (its options wouldn’t fit). The CMS already sends
disabled: truewhen the component is disabled or unregistered; this covers components you registered but haven’t deployed yet. - No
data, nosettings: everything is invariantOptions(andresponsive[bp].variantOptionswhen editors change it per screen size). - Images and content fields hold IDs in
variantOptions; their resolved objects are inoptionData.images[id]andoptionData.content[id](same shapes as everywhere else). An ID missing fromoptionDatawas deleted or unpublished: skip it. - Required fields are enforced by the CMS on publish, but still render defensively.
---import Weather from '../custom/Weather.astro';import PromoStrip from '../custom/PromoStrip.astro';const COMPONENTS: Record<string, any> = { weather_widget: Weather, promo_strip: PromoStrip };const { block, display } = Astro.props;const Component = COMPONENTS[display.variant];if (!Component && import.meta.env.DEV) console.warn(`[pagebuilder] custom component "${display.variant}" not implemented`);---{Component && <Component options={display.variantOptions} optionData={block.optionData ?? {}} />}A poll from Extras → Polls, on any layout kind. data is the poll as GET /cms/poll/{id} returns it (id, question, choices, image, endDate, closed, onlyLoggedIn, …). data: null means there’s nothing to show: render nothing. That happens when the poll was deleted, or when the block reads a page section (settings.source.kind === 'section') that has no poll right now; editors swap the poll from the page.
The layout is cached until something changes, so treat votes, results and voted in data as a starting point: vote with POST /cms/poll/{id} as your existing poll component does, and fetch GET /cms/poll/{id} client-side for live results and whether the visitor already voted. Variants: default.
A form from Extras → Forms, on any layout kind. data is the form as GET /cms/form/{id} returns it (id, name, fields, allow_anonymous, captcha_enabled). Submit with POST /cms/form/{id}, as your existing form component does. data: null (the form is a draft, archived or deleted): render nothing. Variants: default.
Article template blocks
Section titled “Article template blocks”These appear only in article templates (§5.8). They have settings only, no data: render them from the article you fetched (/cms/content/{id}), passed to the layout as article.
| Type | Render | Settings |
|---|---|---|
article_body |
The article itself: title, then the main image, summary, byline and date, sharing toolbar, and the body, the way your article page renders them today. Exactly one per article template (the CMS enforces it) | showMainImage, showSummary, showByline, showSharing (your share buttons): booleans, missing means true |
author_box |
The article’s authors: photo, name, bio |
title (optional heading), showBio, showSocial (missing means true) |
article_tags |
The article’s tags (keywords) as links to your tag pages | title (optional heading) |
comments |
Your comments widget (/cms/content/{id}/comment). Render nothing if the article has comments turned off |
title (optional heading), collapsed: show a “Show comments” button first |
Related articles are a content_list with an external list named related (§5.7): GET /cms/content/{id}/layout fills it with the article’s related stories (§5.8). If you fetch the template yourself, fill streams.related from the article’s related field.
Anything else
Section titled “Anything else”Render nothing. New types appear in /cms/pagebuilder/blocks before editors can use them.
5. Astro implementation
Section titled “5. Astro implementation”The structure below assumes SSR or hybrid output. Adapt the paths to the project.
src/lib/pagebuilder/ api.ts // fetchers (server-only) types.ts // the types from §2 display.ts // breakpoint grouping + style helperssrc/components/pagebuilder/ Layout.astro // rows/columns Block.astro // type → component switch + per-breakpoint copies blocks/ContentList.astro, Tabs.astro, AdSlot.astro, NewsletterSignup.astro, SocialLinks.astro, MessagingZone.astro, Html.astro5.1 Fetching (src/lib/pagebuilder/api.ts)
Section titled “5.1 Fetching (src/lib/pagebuilder/api.ts)”import type { PageWithLayout, PageLayout, TemplateMatch, TemplatePage, ContentWithLayout } from './types';
const BASE = import.meta.env.CMS_API_BASE; // e.g. https://cms.example.com/cmsconst KEY = import.meta.env.CMS_API_KEY; // server-only env var, never PUBLIC_
async function get<T>(path: string): Promise<T | null> { const res = await fetch(`${BASE}${path}`, { headers: { 'API-Key': KEY, Accept: 'application/json' } }); if (res.status === 404) return null; // not found, or page builder disabled if (!res.ok) throw new Error(`CMS ${res.status} for ${path}`); return res.json() as Promise<T>;}
export const getPageLayout = (pageId: number | string) => get<PageWithLayout>(`/page/${pageId}/layout`);export const getLayout = (layoutId: number | string) => get<PageLayout>(`/layout/${layoutId}`);export const getLayoutByKey = (key: string) => get<PageLayout>(`/layout/key/${encodeURIComponent(key)}`);const paged = (path: string, page: number, defaultTemplate?: string) => { const query = new URLSearchParams(); if (page > 1) query.set('page_number', String(page)); if (defaultTemplate) query.set('default_template', defaultTemplate); return query.size ? `${path}?${query}` : path;};const id = (value: number | string) => encodeURIComponent(String(value));
/** A category, author or keyword page in one call: the object, its template and its stories already in the template (§5.7). */export const getCategoryLayout = (category: number | string, page = 1, defaultTemplate?: string) => get<TemplatePage<'category'>>(paged(`/category/${id(category)}/layout`, page, defaultTemplate));export const getAuthorLayout = (author: number | string, publications: string, page = 1, defaultTemplate?: string) => get<TemplatePage<'author'>>(paged(`/author/${id(author)}/layout/${publications}`, page, defaultTemplate));export const getKeywordLayout = (keyword: number | string, publications: string, page = 1, defaultTemplate?: string) => get<TemplatePage<'keyword'>>(paged(`/keyword/${id(keyword)}/layout/${publications}`, page, defaultTemplate));/** An article with its article template, `related` already filled (§5.8). */export const getContentLayout = (content: number | string, defaultTemplate?: string) => get<ContentWithLayout>(paged(`/content/${id(content)}/layout`, 1, defaultTemplate));
/** The template layout named by the `layoutTemplate` field of a category, publication or content response. */export const getTemplateLayout = (match: TemplateMatch | null | undefined) => !match ? Promise.resolve(null) : match.key ? getLayoutByKey(match.key) : getLayout(match.id);export const getLayoutPreview = (layoutId: string, key: string) => get<PageLayout>(`/layout/${layoutId}/preview/${encodeURIComponent(key)}`);5.2 Display helpers (src/lib/pagebuilder/display.ts)
Section titled “5.2 Display helpers (src/lib/pagebuilder/display.ts)”import type { Breakpoint, LayoutBlock, BlockDisplay, PerBreakpoint } from './types';
export const BPS: Breakpoint[] = ['xs', 'md', 'lg']; // mobile-first for CSS
/** Classes hiding an element on the breakpoints where it isn't visible. */export function hideClasses(visibility: PerBreakpoint<boolean>, only?: Breakpoint[]): string { return BPS.filter(bp => visibility?.[bp] === false || (only && !only.includes(bp))) .map(bp => `pb-hide-${bp}`).join(' ');}
/** Groups breakpoints that render the block identically, so each distinct display is rendered once. */export function displayGroups(block: LayoutBlock): { display: BlockDisplay; bps: Breakpoint[] }[] { const groups: { key: string; display: BlockDisplay; bps: Breakpoint[] }[] = []; for (const bp of BPS) { const display = block.responsive?.[bp] ?? { variant: block.variant, variantOptions: block.variantOptions, limit: block.limit ?? 0 }; const key = JSON.stringify(display); const existing = groups.find(g => g.key === key); if (existing) existing.bps.push(bp); else groups.push({ key, display, bps: [bp] }); } return groups;}
/** CSS custom properties for a column. Spans are always present; an unset order means 0 (no inheritance). */export function columnStyle(span: PerBreakpoint<number>, order: Partial<PerBreakpoint<number>> = {}): string { return BPS.map(bp => `--span-${bp}:${span[bp]};--order-${bp}:${order[bp] ?? 0}`).join(';');}
/** Row margin/padding as classes, e.g. `pb-mt-lg pb-pb-none`; unset sides get no class, so your default applies. */export function spacingClasses(settings: Record<string, unknown> = {}): string { const prefixes: Record<string, string> = { marginTop: 'mt', marginBottom: 'mb', marginStart: 'ms', marginEnd: 'me', paddingTop: 'pt', paddingBottom: 'pb', paddingStart: 'ps', paddingEnd: 'pe', }; return Object.entries(prefixes).filter(([key]) => typeof settings[key] === 'string').map(([key, p]) => `pb-${p}-${settings[key]}`).join(' ');}5.3 Layout (src/components/pagebuilder/Layout.astro)
Section titled “5.3 Layout (src/components/pagebuilder/Layout.astro)”---import type { PageLayout } from '../../lib/pagebuilder/types';import { hideClasses, columnStyle, spacingClasses } from '../../lib/pagebuilder/display';import Block from './Block.astro';// streams: the lists you provide (§5.7); page: the category, publication, author or keyword a template shows (page_header);// article: the article an article template shows (§5.8)interface Props { layout: PageLayout; streams?: Record<string, any[]>; page?: any; article?: any }const { layout, streams = {}, page, article } = Astro.props;---<div class="pb-layout" dir={layout.direction}>{layout.rows.map(row => ( <section class:list={['pb-row-wrap', row.settings.fullWidth ? 'pb-full' : 'container', row.settings.background && `pb-bg-${row.settings.background}`, spacingClasses(row.settings), hideClasses(row.visibility)]} data-row={row.id}> <div class="pb-row"> {row.columns.map(col => ( <div class:list={['pb-col', hideClasses(col.visibility)]} style={columnStyle(col.span, col.order)}> {col.blocks.map(block => <Block block={block} streams={streams} page={page} article={article} />)} </div> ))} </div> </section>))}</div>5.4 Block switch (src/components/pagebuilder/Block.astro)
Section titled “5.4 Block switch (src/components/pagebuilder/Block.astro)”---import type { LayoutBlock } from '../../lib/pagebuilder/types';import { displayGroups, hideClasses } from '../../lib/pagebuilder/display';import ContentList from './blocks/ContentList.astro';import Tabs from './blocks/Tabs.astro';import AdSlot from './blocks/AdSlot.astro';import NewsletterSignup from './blocks/NewsletterSignup.astro';import SocialLinks from './blocks/SocialLinks.astro';import MessagingZone from './blocks/MessagingZone.astro';import Html from './blocks/Html.astro';import RichText from './blocks/RichText.astro'; // <Fragment set:html={block.data?.html} /> in your article-body stylesimport Image from './blocks/Image.astro';import Embed from './blocks/Embed.astro'; // <Fragment set:html={block.data?.html} />, like article embedsimport Cta from './blocks/Cta.astro';import Custom from './blocks/Custom.astro'; // your own components, by variant (§4 custom)import PageHeader from './blocks/PageHeader.astro'; // the category, author or keyword name and details (`page` prop)import Poll from './blocks/Poll.astro'; // your existing poll component; nothing when data is nullimport Form from './blocks/Form.astro'; // your existing form component; nothing when data is nullimport ArticleBody from './blocks/ArticleBody.astro'; // your existing article rendering, honouring the show* settingsimport AuthorBox from './blocks/AuthorBox.astro';import ArticleTags from './blocks/ArticleTags.astro';import Comments from './blocks/Comments.astro';
const COMPONENTS: Record<string, any> = { content_list: ContentList, tabs: Tabs, ad_slot: AdSlot, newsletter_signup: NewsletterSignup, social_links: SocialLinks, messaging_zone: MessagingZone, html: Html, rich_text: RichText, image: Image, embed: Embed, cta: Cta, custom: Custom, page_header: PageHeader, poll: Poll, form: Form, article_body: ArticleBody, author_box: AuthorBox, article_tags: ArticleTags, comments: Comments,};
interface Props { block: LayoutBlock; streams?: Record<string, any[]>; page?: any; article?: any }const { block, streams = {}, page, article } = Astro.props;// CMS-resolved lists carry `data`; external lists take their slice of a list you provide (§5.7)const source = block.stream ? (streams[block.stream.name] ?? []).slice(block.stream.offset) : (block.data ?? []);const Component = block.disabled ? null : COMPONENTS[block.type];if (!block.disabled && !Component && import.meta.env.DEV) console.warn(`[pagebuilder] unknown block type "${block.type}"`);const groups = Component ? displayGroups(block) : [];---{Component && groups.map(({ display, bps }) => ( <div class:list={['pb-block', `pb-${block.type}`, hideClasses(block.visibility, bps)]} data-block={block.id}> <Component block={block} display={display} streams={streams} page={page} article={article} items={source.slice(0, display.limit || undefined)} /> </div>))}5.5 Example block components
Section titled “5.5 Example block components”ContentList.astro: pick the design by variant and fall back to the default:
---import ThumbList from '../../news/ThumbList.astro'; // your existing componentsimport LeadGrid from '../../news/LeadGrid.astro';import Numbered from '../../news/NumberedList.astro';import SectionHeading from '../../news/SectionHeading.astro';const { block, display, items } = Astro.props;const VARIANTS: Record<string, any> = { thumb_list: ThumbList, lead_grid: LeadGrid, numbered: Numbered /* … */ };const Variant = VARIANTS[display.variant] ?? VARIANTS['thumb_list']; // default variant fallbackconst header = block.settings?.header ?? {};---{header.title && <SectionHeading title={header.title} link={header.link} />}<Variant items={items} options={display.variantOptions ?? {}} />MessagingZone.astro:
---const { block } = Astro.props;---<div data-wb-zone-key={block.settings.zoneKey}></div>Html.astro (server-rendered, so its scripts run on page load):
---const { block } = Astro.props;---<Fragment set:html={block.settings.html} />Tabs.astro: render block.children as tab panels with labels from child.settings.header.title. Each child is a content_list, so reuse ContentList with the child’s own responsive[bp] and data. Use display.variant === 'accordion' for the accordion style.
5.6 A page route
Section titled “5.6 A page route”---// src/pages/index.astro (homepage = CMS page 1, for example)import { getPageLayout } from '../lib/pagebuilder/api';import Layout from '../components/pagebuilder/Layout.astro';import LegacyHome from '../components/LegacyHome.astro'; // the current implementationconst result = await getPageLayout(1);---<BaseLayout title={result?.page.metadata?.title ?? 'Home'}> {result?.layout ? <Layout layout={result.layout} /> : <LegacyHome />}</BaseLayout>Keep the existing implementation as the fallback until every page has a published layout.
5.7 Templates and external lists
Section titled “5.7 Templates and external lists”A template is one layout used for many pages: every category page, every author page, and so on. Editors build it once in the CMS. Its lists can’t name a category, so they use the source External list, meaning “the items the frontend provides”. The layout is the same for every category and is cached once. You fetch the category’s content from the endpoints you already use, which are cached and purged on their own.
How a template tells you what to fetch:
layout.streamslists every external list and how many items it needs, e.g.{ "main": { "items": 20 } }. The number is the furthest item any block shows (offset + limit), so fetching exactly that many fills every block.- Each external block has
block.stream = { name, offset, items }. Show itemsoffsettooffset + limit − 1of that list, withlimitfromresponsive[bp].limitas usual. Several blocks share one list and slice it: a lead story (offset 0, 1 item), cards (offset 1, 4 items), a list (offset 5, 15 items). The slices never overlap, so nothing repeats. - Names:
mainis the page’s own list (the category’s or author’s articles). Any other name, e.g.related, is a list your template documents and provides. If a template asks for a list you don’t know, render those blocks empty and log it in development. - Other blocks still come resolved. Templates can mix in CMS-resolved blocks (most read, a fixed “Opinion” list, ads). They arrive with
dataas usual. - De-duplication: the CMS can’t de-duplicate between external lists and CMS-resolved blocks, since it never sees your items. Within one external list, the slices never overlap. If repeats between, say, “Most read” and the category list matter, skip IDs you’ve already rendered (optional).
Which template a category uses is decided in the CMS: editors set, in the template’s Layout settings → “Use for”, the categories and publications it applies to. The category view, GET /cms/category/{id}, tells you in its layoutTemplate field. The most specific match wins:
- a template used for the category itself,
- else for its nearest parent category (a template for “Sport” covers “Sport > Football”),
- else for its publication.
layoutTemplate says which template and why (matchedBy). layoutTemplate: null means none applies, or the page builder is off: render your existing category page. Otherwise fetch the template layout itself with its key (or ID). That response is the same for every category using the template, so it is cached once. GET /cms/category/{id}/layout applies this match for you (example below).
The page title is part of the template: editors place a page_header block where the category name goes (the author’s or keyword’s name on their templates). Pass the category as page and don’t add your own <h1> above the layout. If a template has no page_header, the editors chose to show no title.
A publication page works the same way: GET /cms/publication/{id} has a layoutTemplate field with the category template used for the publication itself.
Category page example (src/pages/[category].astro, SSR). Use GET /cms/category/{id}/layout: one call returns the category (with layoutTemplate), its template and the category’s stories already placed in the template’s main blocks. Those blocks come with data instead of stream, and main is gone from streams, so render the layout as is: no streams prop for main. Each page (?page_number={n}) is the next limit stories of the category in the same template. When no template applies, layout is null and data holds a page of stories, as /cms/category/{id}/content returns it.
---import { getCategoryLayout } from '../lib/pagebuilder/api';import Layout from '../components/pagebuilder/Layout.astro';const { category } = Astro.params; // ID or slugconst page = Number(Astro.url.searchParams.get('page') ?? 1);
const res = await getCategoryLayout(category!, page); // category + template + its stories, one callif (!res) return new Response(null, { status: 404 }); // unknown category, or page builder off: your existing pageconst hasMore = res.total > page * res.limit; // pagination: same template, next slice of the category---<BaseLayout title={res.category.name}> {res.layout ? <Layout layout={res.layout} page={res.category} /> : <LegacyCategoryPage category={res.category} items={res.data} />} {hasMore && <a href={`?page=${page + 1}`}>More stories</a>}</BaseLayout>The same can be built from three calls (/cms/category/{id} for layoutTemplate, the template layout, then /cms/category/{id}/content/page/{page}/limit/{streams.main.items} passed as streams.main). Prefer the single call: it is cached and purged as one response, when the category’s stories change and when the template is published or retargeted.
Author and keyword (tag) pages work the same way, with their own template kinds, author_template and keyword_template. Editors link a template to individual authors or keywords under “Use for”; there is no fallback chain. Use GET /cms/author/{id}/layout/{publicationIds} and GET /cms/keyword/{id}/layout/{publicationIds}: like the category endpoint, one call returns the object (as author or keyword, with layoutTemplate), its template and its stories in the publications already in main. Pass the object as page.
Most authors and keywords have no template of their own, so add default_template={key}: the key of your default template, agreed with the CMS team, e.g. author-template (editors build it as an author template and leave “Use for” empty). It is used when the object has no template, and ignored unless it is a published template of the same kind. layout: null then means neither exists: render your existing page from data. The category endpoint takes default_template too, for categories no template covers.
const res = await getAuthorLayout(slug, '1', page, 'author-template'); // author + their (or the default) template + their storiesif (!res) return new Response(null, { status: 404 });// res.layout ? <Layout layout={res.layout} page={res.author} /> : <LegacyAuthorPage author={res.author} items={res.data} />Pages the CMS can’t target (search pages…) have no layoutTemplate field: agree a key with the CMS team and fetch /cms/layout/key/{key} directly. Keys are the same on every environment.
5.8 Article templates
Section titled “5.8 Article templates”An article template decides what surrounds an article: the article_body block marks where the article goes, and the rest of the layout (related stories, author box, tags, comments, most read, ads, newsletter sign-up) is placed around it. The article itself still comes from /cms/content/{id}, as today.
Which template an article uses: the article view, GET /cms/content/{id}, has a layoutTemplate field next to data. The most specific match wins:
- a template used for the article’s content type (e.g. Video, Opinion, Live blog),
- else for its primary category, then that category’s parents, nearest first,
- else for its publication.
layoutTemplate: null means none applies, or the page builder is off: render your existing article page.
Use GET /cms/content/{id}/layout: one call returns what /cms/content/{id} returns (data: [article], layoutTemplate) plus layout, the template already resolved with the article’s related stories in its related blocks. Those blocks come with data instead of stream, and related is gone from streams. Add default_template={key} to use your own default article template when none applies. layout: null means no template: render your existing article page from data[0]. A 404 means the article doesn’t exist or isn’t available, or the page builder is off: fall back to /cms/content/{id}.
---// src/pages/[...slug].astro (SSR), article routeimport { getContentLayout } from '../lib/pagebuilder/api';import Layout from '../components/pagebuilder/Layout.astro';const { slug } = Astro.params;
const res = await getContentLayout(slug!); // article + its template, related stories filledconst article = res?.data?.[0];if (!article) return new Response(null, { status: 404 });---<BaseLayout title={article.name}> {res.layout ? <Layout layout={res.layout} article={article} /> : <LegacyArticlePage article={article} />}</BaseLayout>The same can be built from two calls (/cms/content/{id}, then the template from layoutTemplate, with streams.related filled from article.related). Prefer the single call: it is purged with the article and when the template changes.
Rules:
- Render
article_bodyexactly where it is, with your existing article rendering (body HTML, embeds, paywall, structured data). The template doesn’t change how the article looks, only what is around it. - The article blocks have no
data; take everything fromarticle. In the layout’s own previews (§6),articleis your sample article. - Article SEO tags (title, canonical, JSON-LD) still come from the article, not the template.
5.9 Purge webhook: cache tags
Section titled “5.9 Purge webhook: cache tags”If your site keeps its own cache of CMS responses (or of pages built from them) and is configured as the CMS cache purge webhook (Publisher Configuration: webhook URL and secret), the CMS calls it whenever content changes (see Caching and cache purging for the setup):
POST {webhook URL}Authorization: Bearer {secret}Content-Type: application/json
{ "urls": ["https://www.example.com/sport/some-story"], "tags": ["pblayout-mysite-12"] }urls: frontend pages to drop, as before.tags(new): drop every cached entry built from a CMS response carrying one of these tags. Either list can be empty; large purges arrive in several calls.
Build the tags yourself. The CMS sets them in a Cache-Tag response header, but Cloudflare removes that header before the response reaches you, so don’t rely on reading it. Tags follow a fixed pattern, where {site} is the CMS deployment name (ask the CMS team; keep it in config, e.g. CMS_SITE):
| Tag | Put it on | Purged when |
|---|---|---|
pblayout-{site}-{layout.id} |
Entries built from a category, author, keyword or content /layout response whose layout is not null. layout.id is in the response |
The template is published, renamed, disabled, deleted or retargeted |
pubcontent-{site}-{publicationId} |
Entries built from that publication’s content listings | The publication’s content changes |
// after getCategoryLayout / getAuthorLayout / getKeywordLayout / getContentLayoutconst tags = res?.layout ? [`pblayout-${import.meta.env.CMS_SITE}-${res.layout.id}`] : [];cache.set(url, html, { tags }); // the API response and the page rendered from itCaching and cache purging has full purge endpoint examples for Astro Cache and Cloudflare Workers Cache. Store the tags with the cache entries built from that response (the API response itself and the pages rendering it), and delete those entries when a webhook call names one of them. Ignore tags you don’t recognise: new ones may appear. Answer with a 2xx status, or the CMS reports the purge as failed.
6. Preview
Section titled “6. Preview”The CMS editor’s Preview button opens:
{preview base URL}/preview/layout/{layoutId}/{key}The base URL is configured in the CMS:
- Layouts of a page: the page’s Preview URL (Page settings), e.g.
https://www.example.com. - Layouts without a page (templates, standalone layouts): the Frontend preview URL in Publisher Configuration → Page Builder.
Both point at the same route. Add it to the frontend:
---// src/pages/preview/layout/[layoutId]/[key].astro (must be SSR, never prerendered)export const prerender = false;import { getLayoutPreview } from '../../../../lib/pagebuilder/api';import Layout from '../../../../components/pagebuilder/Layout.astro';const { layoutId, key } = Astro.params;const layout = await getLayoutPreview(layoutId!, key!);if (!layout) return new Response('Preview expired', { status: 404 });Astro.response.headers.set('Cache-Control', 'no-store');---<BaseLayout title="Preview" noindex> <Layout layout={layout} /></BaseLayout>Preview keys expire after 15 minutes. Mark the page noindex and never cache it.
Templates use the site-wide Frontend preview URL, since they have no page. The preview response has no items for external lists. In the preview route, fill main with a sample, e.g. the latest stories, so editors see the template populated:
// Any recent stories work as a sample, e.g. your homepage's "latest" list or a default categoryconst sample = layout.streams.main ? await getLatestStories(layout.streams.main.items) : [];<Layout layout={layout} streams={{ main: sample }} />In the CMS editor, editors pick their own sample under Layout settings → “Preview external lists with”.
Category, author and keyword templates with a page_header need a sample page too: pass any category, author or keyword (e.g. the first one from /cms/category, /cms/author or /cms/keyword, by layout.kind). Article templates also need an article for article_body, author_box, article_tags and comments. In the preview route, when layout.kind === 'article_template', fetch a recent article as the sample and pass it as article.
7. Variant catalogue: build every variant and hand it to the CMS
Section titled “7. Variant catalogue: build every variant and hand it to the CMS”Editors can only pick displays that are registered in the CMS (Publisher Configuration → Page Builder → Display variants). The CMS can’t discover your components, so you build them and the CMS team registers them from your manifest.
7.1 Build the variants
Section titled “7.1 Build the variants”For every block type in §4, build a variant component for each distinct design that type could use on this site:
- Inventory the site. Go through the existing templates (homepage, section fronts, article sidebars, landing pages) and list every distinct way a group of stories or a widget is displayed: lead + grid, thumbnail lists, numbered “most read”, sidebar text lists, cards, video player + playlist, cartoon/image-only, carousels on mobile, and so on.
- Map each design to a block type. Story lists →
content_list. Tabbed/accordion lists →tabs. Ads, newsletter boxes, social bars → their own types. Messaging zones and free HTML usually need onlydefault. - Implement each design as a variant component, registered in the variant map of that block’s component (§5.5).
- Make every visual difference that editors might want to toggle a variant option (show byline, show date, badge text, accent colour, number of columns…) rather than a separate variant.
- Create a separate variant only when the layout is genuinely different.
- Implement the seeded keys too (the
content_listtable in §4, plustabs/accordionfortabsanddefaultfor the other types). These are already registered on every CMS. If a seeded design makes no sense for this site, say so in the manifest so the CMS team disables it. - Each block type needs exactly one default variant. It must work on all three breakpoints and with every content source, because it’s the fallback for anything unknown.
- Respect the data contract: a variant receives
items(already sliced) andoptions. It must render sensibly with fewer items than expected, including zero.
7.2 Variant rules (enforced by the CMS)
Section titled “7.2 Variant rules (enforced by the CMS)”| Field | Rule |
|---|---|
key |
^[a-z0-9_]{1,64}$, unique per block type, can never be changed once registered (it’s the contract with the frontend). Use descriptive design names: lead_grid, opinion_sidebar, video_rail. |
label |
Name editors see, e.g. “Lead story + grid”. |
description |
One sentence telling editors when to use it. |
allowedSources |
content_list only: subset of section, query, mostread, mostcommented; null = all. Restrict designs that only make sense for some sources (e.g. a ranked list for mostread). |
breakpoints |
Subset of lg, md, xs; null = all. E.g. a carousel that only works on touch screens: ["xs", "md"]. The default variant must be null. |
limit |
List blocks: { "min", "default", "max" } items the design can show well (all ≥ 1, min ≤ default ≤ max). Omit for blocks without items. |
options |
Editor-facing fields, each { name, key, type, values?, default?, multiple?, required? }. key is what you read from variantOptions. required: true blocks publishing until editors fill it in. Types: • text, longtext (multi-line), number, boolean • list: needs values as a comma-separated string; multiple: true allows several • url: a link (https://, http://, a site path or mailto:, checked by the CMS) • image: a media library image; variantOptions holds its ID, the image is in optionData.images • content: an article picked by editors (multiple: true for several, in order); IDs in variantOptions, articles in optionData.content image and content take no default. Any block’s variants can use every type. |
isDefault |
Exactly one true per block type. |
7.3 Deliverable: pagebuilder-variants.json
Section titled “7.3 Deliverable: pagebuilder-variants.json”Put this file at the root of the frontend repo. It lists every variant you implemented, including the seeded ones, grouped by block type:
{ "site": "example-web", "generatedAt": "2026-10-09", "breakpointsPx": { "md": 768, "lg": 1024 }, "blocks": { "content_list": [ { "key": "lead_grid", "label": "Lead story + grid", "description": "One large lead story followed by a grid of smaller stories. Use for the top of a front.", "isDefault": false, "allowedSources": ["section", "query"], "breakpoints": null, "limit": { "min": 3, "default": 7, "max": 13 }, "options": [ { "name": "Lead badge", "key": "leadBadge", "type": "text", "default": "TOP STORY" }, { "name": "Show byline", "key": "showByline", "type": "boolean", "default": "true" }, { "name": "Grid columns", "key": "gridColumns", "type": "list", "values": "2,3", "default": "3" } ], "thumbnail": "pagebuilder-thumbnails/content_list--lead_grid.png", "component": "src/components/news/LeadGrid.astro", "status": "implemented" }, { "key": "image_only", "label": "Image only", "status": "disable", "note": "The site has no image-only listing; ask the CMS team to disable it." } ], "tabs": [ { "key": "tabs", "label": "Tabs", "isDefault": true, "breakpoints": null, "options": [], "status": "implemented" } ], "ad_slot": [ { "key": "default", "label": "Default", "isDefault": true, "breakpoints": null, "options": [], "status": "implemented" } ], "custom": [ { "key": "weather_widget", "label": "Weather", "description": "Today's forecast for one city.", "options": [ { "name": "City", "key": "city", "type": "text", "required": true } ], "component": "src/components/custom/Weather.astro", "status": "implemented" }, { "key": "promo_strip", "label": "Promo strip", "description": "Banner image, a short pitch, a link and up to three stories.", "options": [ { "name": "Title", "key": "title", "type": "text", "required": true }, { "name": "Pitch", "key": "pitch", "type": "longtext" }, { "name": "Banner", "key": "banner", "type": "image", "required": true }, { "name": "Link", "key": "link", "type": "url" }, { "name": "Stories", "key": "stories", "type": "content", "multiple": true } ], "component": "src/components/custom/PromoStrip.astro", "status": "implemented" } ] }}status:implemented(register or keep it),disable(a seeded variant the site doesn’t support), orplanned.componentandnotehelp reviewers; the CMS ignores them.options[].defaultis written as a string, the way the CMS form stores it ("true"for a checked boolean).customlists your own components (§4custom). They have nolimitorallowedSources; a default (isDefault) is allowed but unused, since custom components never fall back.
7.4 Preview images
Section titled “7.4 Preview images”For each implemented variant, add a screenshot or sketch at pagebuilder-thumbnails/{blockType}--{key}.png. About 600×400, cropped to the component, with realistic content. Editors see these in the CMS when choosing a display, so they should make the differences between variants obvious at a glance.
7.5 Register the variants through the API
Section titled “7.5 Register the variants through the API”Register the manifest yourself with the variants API. Don’t ask anyone to copy it into the CMS by hand.
API key. These endpoints need an API key linked to a CMS user whose role has the Page Builder settings permissions (“Admin - View page builder settings”, “Admin - Create page builder variant”, “Admin - Update page builder variant”). The read-only key used for rendering is not enough, and you get 403 with the missing permission in the message. Ask the CMS team for this key and keep it out of the frontend build: it’s only for this registration step (a script or CI job).
1. Check what’s registered.
curl -s -H "API-Key: $CMS_ADMIN_API_KEY" "$CMS_API_BASE/pagebuilder/variants"This returns { "blocks": { "content_list": [ … ], … } } in the same format as your manifest, including disabled variants. Each entry also has thumbnail (URL or null) and usedBy (how many published layouts use it).
2. Dry-run the manifest. Nothing is written:
curl -s -X PUT -H "API-Key: $CMS_ADMIN_API_KEY" -H "Content-Type: application/json" \ --data @pagebuilder-variants.json "$CMS_API_BASE/pagebuilder/variants?dry_run=1"The report lists one result per entry: created, updated (with the changed fields), unchanged, disabled, skipped (with a reason) or invalid (with errors). becomesDefault: true marks a variant that becomes its block’s default.
3. Fix every error, then run it for real (the same call without ?dry_run=1).
- All or nothing: if any entry is invalid, the API answers
422, writes nothing, and lists every problem inerrorsasblockType.key: message. - Safe to re-run: running the same manifest again reports everything
unchanged.
4. Upload the preview images, one call per implemented variant (PNG, JPEG, WebP or GIF, ≤ 5 MB):
curl -s -X POST -H "API-Key: $CMS_ADMIN_API_KEY" \ -F "file=@pagebuilder-thumbnails/content_list--lead_grid.png" \ "$CMS_API_BASE/pagebuilder/variants/content_list/lead_grid/thumbnail"How the import treats entries:
| Manifest entry | Result |
|---|---|
status: "implemented", key not registered |
Created (appended to the editor’s list for that block) |
status: "implemented", key registered |
Updated with exactly the manifest’s values: label, description, sources, breakpoints, limit, options. An omitted field is cleared, so always send the full entry. A disabled variant is re-enabled. |
status: "disable" |
The registered variant is disabled. Editors can no longer pick it; existing layouts fall back to the default. |
status: "planned" |
Skipped |
| A registered variant not in the manifest | Left untouched. The API never deletes; disable instead. |
Default variant rules:
- Each block type keeps exactly one default. It must be enabled on every screen size (
breakpoints: null). - Changing it: set
isDefault: trueon the new default in the same manifest. To disable the current default, mark a replacementisDefaultin the same request; otherwise the import is refused. - Block type with no variants yet: its first enabled entry that works on every screen size becomes the default automatically.
There is also PUT /cms/pagebuilder/variants/{blockType}/{key} with a single entry as the body (the key comes from the path), for adding one variant later. It follows the same rules and returns the same report.
Once registered, variants appear in /cms/pagebuilder/blocks and in the CMS editor straight away. Use the check in §8 to confirm the CMS and the frontend match.
8. Development checks
Section titled “8. Development checks”- Compare the CMS and the frontend: fetch
/cms/pagebuilder/blocksat build time or in dev, and warn about:- any
blocks[].typeorblocks[].variants[].keyyour frontend doesn’t map (editors may create variants before the frontend supports them; the fallback keeps pages working, but the team should know), - any
implementedentry inpagebuilder-variants.jsonthat isn’t registered in the CMS yet (editors can’t use it until it is). The quickest fix is to re-run the dry run from §7.5: anything notunchangedis out of sync.
- any
- Generate types from
blocks[].settings(JSON Schema) andvariants[].options(JSON Schema), if the project generates types. - Test with screen sizes: resize through all three breakpoints and check per-breakpoint variants, hidden blocks and column order on mobile.
9. Do / Don’t
Section titled “9. Do / Don’t”- ✅ Fetch server-side with the
API-Keyheader. ❌ Never expose the key in client JS. - ✅ Render every breakpoint in one HTML response with CSS. ❌ No user-agent sniffing; responses are cached per URL.
- ✅ Fall back: unknown variant → the block’s default variant (except
custom: nothing). Unknown type ordisabled→ nothing. - ✅ Slice
dataper breakpoint withresponsive[bp].limit. ❌ Don’t re-sort, re-query or de-duplicate content. - ✅ Keep hidden items in the HTML with
pb-hide-*classes. - ✅ Render
messaging_zoneas<div data-wb-zone-key="…"></div>only. - ✅ Server-render
htmlblocks so their scripts run. - ❌ Don’t call
/cms/page/{id}and/cms/page/{id}/layoutfor the same page. Likewise, a/layoutcall for a category, author, keyword or article already contains the object and its stories: don’t also fetch/cms/category/{id},/content, the template layout, etc. - ✅ Category, author, keyword and article pages: their
/layoutendpoint, one call with the template picked andmain(orrelated) filled (default_templatefor your default). ❌ Don’t pick the template or fetch the stories yourself. - ✅ Publication pages and other templates fetched by key: use the
layoutTemplatefield ofGET /cms/publication/{id}to pick the template, then fetchlayout.streams.main.itemsitems of the page’s own list and pass them asstreams.main. ❌ Don’t hard-code which template applies or how many items each block shows: editors change both in the CMS.