Skip to content

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.

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:

  1. fetch the layout,
  2. render the grid,
  3. 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 variant is a design name, like lead_grid, that your design system implements. It is never CSS.

  1. The renderer: fetching, grid, block components, category and article templates, preview route (§1–§6).
  2. The variant catalogue: a variant component for every block type, covering every display the site already has, plus a pagebuilder-variants.json manifest 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.

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 returns issue and dynamicSections for 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: null means the page has no published layout. Fall back the same way.
  • Which template applies is a layoutTemplate field on the views you already fetch: GET /cms/category/{id}, GET /cms/publication/{id}, GET /cms/content/{id} (next to data), GET /cms/author/{id} and GET /cms/keyword/{id}. It is TemplateMatch or null; always null when the page builder is off (§5.7, §5.8). Don’t confuse it with the category’s older template field (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 /layout responses 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 tags too (see §5.9).
  • Full schemas: the API reference (PageLayout, PageLayoutBlock, PageBuilderBlocks, …) has every field. This guide covers what you need to render.

export type Breakpoint = 'lg' | 'md' | 'xs'; // desktop, tablet, mobile
export 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 template
export 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;
}

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).

  • 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. Use grid-column: span N.
  • column.order[bp], when present, is the CSS order at 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, dark or brand. Map these to your theme.
  • Row spacing: margin* (space outside the row) and padding* (space inside it, within its background), each with Top, Bottom, Start and End. Start and end are logical sides, like CSS margin-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 follow dir (§3.2), no code per direction. Each is a size name, none, xs, sm, md, lg or xl, never a pixel value: map them to your own spacing scale (none is 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. lg is 2rem on mobile, 4rem on desktop).
  • layout.direction (ltr or rtl): the direction editors laid the page out in. Columns are listed in reading order, and CSS grid places them according to dir, 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.

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.

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.

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:

  1. Group the three breakpoints by identical {variant, variantOptions, limit}.
  2. Render one copy of the block per group, with pb-hide-* classes for the breakpoints not in that group.
  3. 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.

  • Map type → component, then variant → 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 with variantFallback: false in /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.id and column.id as stable keys. They’re unique within a layout.
  • data is ready to render. It’s ordered, de-duplicated across the page (a story never appears twice), and the offset has already been applied. Don’t re-sort, re-query or de-duplicate.
  • Limits: data.length ≤ limit. Each breakpoint shows data.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.
  • Empty lists: data can 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 no data, only stream: { name, offset }. Its items come from a list you fetch, typically the category or author being rendered by a template. See §5.7.

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.

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 breakpoint
const 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>

settings.channelId, plus optional title, description and buttonLabel. Submit with POST /cms/channel/{channelId}/subscribe, the same flow as your existing newsletter forms.

settings.title (optional) and settings.links: [{ network, label?, url }]. network is one of facebook, instagram, x, whatsapp, telegram, tiktok, youtube, threads, linkedin, rss.

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).

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.image is the image in the article API’s v3 shape: url, url_<size> per configured size, width, height, focal, transforms.
  • data.alt and data.caption fall back to the library’s own description and name when the editor leaves them empty.
  • data.credit is a string, possibly empty.
  • Link: settings.link (optional, safe http(s)/path/mailto) and settings.newTab.
  • data is null if 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.

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.

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: true when the component is disabled or unregistered; this covers components you registered but haven’t deployed yet.
  • No data, no settings: everything is in variantOptions (and responsive[bp].variantOptions when editors change it per screen size).
  • Images and content fields hold IDs in variantOptions; their resolved objects are in optionData.images[id] and optionData.content[id] (same shapes as everywhere else). An ID missing from optionData was deleted or unpublished: skip it.
  • Required fields are enforced by the CMS on publish, but still render defensively.
blocks/Custom.astro
---
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.

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.

Render nothing. New types appear in /cms/pagebuilder/blocks before editors can use them.


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 helpers
src/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.astro
import type { PageWithLayout, PageLayout, TemplateMatch, TemplatePage, ContentWithLayout } from './types';
const BASE = import.meta.env.CMS_API_BASE; // e.g. https://cms.example.com/cms
const 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 styles
import Image from './blocks/Image.astro';
import Embed from './blocks/Embed.astro'; // <Fragment set:html={block.data?.html} />, like article embeds
import 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 null
import Form from './blocks/Form.astro'; // your existing form component; nothing when data is null
import ArticleBody from './blocks/ArticleBody.astro'; // your existing article rendering, honouring the show* settings
import 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>
))}

ContentList.astro: pick the design by variant and fall back to the default:

---
import ThumbList from '../../news/ThumbList.astro'; // your existing components
import 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 fallback
const 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.

---
// 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 implementation
const 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.

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.streams lists 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 items offset to offset + limit − 1 of that list, with limit from responsive[bp].limit as 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: main is 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 data as 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:

  1. a template used for the category itself,
  2. else for its nearest parent category (a template for “Sport” covers “Sport > Football”),
  3. 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 slug
const page = Number(Astro.url.searchParams.get('page') ?? 1);
const res = await getCategoryLayout(category!, page); // category + template + its stories, one call
if (!res) return new Response(null, { status: 404 }); // unknown category, or page builder off: your existing page
const 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 stories
if (!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.

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:

  1. a template used for the article’s content type (e.g. Video, Opinion, Live blog),
  2. else for its primary category, then that category’s parents, nearest first,
  3. 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 route
import { 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 filled
const 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_body exactly 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 from article. In the layout’s own previews (§6), article is your sample article.
  • Article SEO tags (title, canonical, JSON-LD) still come from the article, not the template.

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 / getContentLayout
const 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 it

Caching 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.


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 category
const 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.

For every block type in §4, build a variant component for each distinct design that type could use on this site:

  1. 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.
  2. 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 only default.
  3. 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.
  4. Implement the seeded keys too (the content_list table in §4, plus tabs/accordion for tabs and default for 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.
  5. 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.
  6. Respect the data contract: a variant receives items (already sliced) and options. It must render sensibly with fewer items than expected, including zero.
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), or planned.
  • component and note help reviewers; the CMS ignores them.
  • options[].default is written as a string, the way the CMS form stores it ("true" for a checked boolean).
  • custom lists your own components (§4 custom). They have no limit or allowedSources; a default (isDefault) is allowed but unused, since custom components never fall back.

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.

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.

Terminal window
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:

Terminal window
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 in errors as blockType.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):

Terminal window
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: true on the new default in the same manifest. To disable the current default, mark a replacement isDefault in 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.

  • Compare the CMS and the frontend: fetch /cms/pagebuilder/blocks at build time or in dev, and warn about:
    • any blocks[].type or blocks[].variants[].key your frontend doesn’t map (editors may create variants before the frontend supports them; the fallback keeps pages working, but the team should know),
    • any implemented entry in pagebuilder-variants.json that 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 not unchanged is out of sync.
  • Generate types from blocks[].settings (JSON Schema) and variants[].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.
  • ✅ Fetch server-side with the API-Key header. ❌ 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 or disabled → nothing.
  • ✅ Slice data per breakpoint with responsive[bp].limit. ❌ Don’t re-sort, re-query or de-duplicate content.
  • ✅ Keep hidden items in the HTML with pb-hide-* classes.
  • ✅ Render messaging_zone as <div data-wb-zone-key="…"></div> only.
  • ✅ Server-render html blocks so their scripts run.
  • ❌ Don’t call /cms/page/{id} and /cms/page/{id}/layout for the same page. Likewise, a /layout call 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 /layout endpoint, one call with the template picked and main (or related) filled (default_template for your default). ❌ Don’t pick the template or fetch the stories yourself.
  • ✅ Publication pages and other templates fetched by key: use the layoutTemplate field of GET /cms/publication/{id} to pick the template, then fetch layout.streams.main.items items of the page’s own list and pass them as streams.main. ❌ Don’t hard-code which template applies or how many items each block shows: editors change both in the CMS.