ContentBlocks
Renders a list of CMS blocks.
import { ContentBlocks } from "@evolve-storefront/feature-content-blocks/content-blocks";Renders inside ContentAudienceProvider.
Design notes
This is the whole public surface for putting content on a page: hand it a body
and a registry. There is no renderItem and no slot prop, because the registry
already is the per-item seam — it is keyed, so an override reaches one type
everywhere it appears rather than only in the list the prop was passed to, and
it reaches nested content that a top-level render prop could never see.
Server-renderable throughout: the registry travels as a prop, so no block needs
"use client" to find its children.
Usage
// app/[locale]/[...slug]/content-page.tsximport { ContentBlocks, defaultBlocks, PageHeading } from "@evolve-storefront/feature-content-blocks/content-blocks";import { contentPageQueryOptions } from "@evolve-storefront/feature-content-blocks/queries";import type { ClientFetcher } from "@labdigital/graphql-fetcher";import { QueryClient } from "@tanstack/react-query";
export async function ContentPage({ gqlClient, path }: { gqlClient: ClientFetcher; path: string }) { const page = await new QueryClient().fetchQuery(contentPageQueryOptions({ gqlClient, path })); if (!page) return null; return ( <main> <PageHeading page={page} /> <ContentBlocks blocks={page.body} registry={defaultBlocks} headingLevel={2} /> </main> );}Blocks
The blocks ContentBlocks renders out of the box, each shown from its default story in Storybook.
| Block | Preview |
|---|---|
| ArticleList Article cards, with a link to all of the section's articles. | Open in Storybook ↗ |
| BrandList Brand logos that link to their brand pages, under an optional title. | Open in Storybook ↗ |
| CampaignBanner A campaign: the image beside the text, never behind it, and one call to action. | Open in Storybook ↗ |
| CategoryList Category entrances: a grid of commerce categories, under an optional title. | Open in Storybook ↗ |
| Columns Two to four column entries side by side, under an optional title. | Open in Storybook ↗ |
| ContentTeaser An image beside rich text and up to two links, or the text alone. | Open in Storybook ↗ |
| Faq A titled FAQ: groups of questions, each group a single-open accordion. | Open in Storybook ↗ |
| FigureList Key figures: each a value with its label, and an optional source. | Open in Storybook ↗ |
| ProductList A titled rail of products the CMS picked. | Open in Storybook ↗ |
| PromoList Promo cards in a grid, under an optional title. | Open in Storybook ↗ |
| RichText Rich text. The default fragments select the vendor-neutral tree, which renders in full. Without a tree, as from a query of the site's own that selects only the CMS's document, the fallback: paragraphs, with the formatting dropped. | Open in Storybook ↗ |
| ShoppableImage An image with products pinned on it, listed beside or below it through the
registered | Open in Storybook ↗ |
| StepList Numbered steps that explain a process, each with an optional text and link. | Open in Storybook ↗ |
| TableOfContents Links to the anchors on the page. The CMS block carries only its title;
| Open in Storybook ↗ |
| Tip A highlighted tip: in a page body, or placed inside rich text, where it keeps its
own look. Styled in | Open in Storybook ↗ |
| UspList The selling-point band. Sized by container queries, so the same block is a row at page width and a stack in a narrow column, such as a cart summary. | Open in Storybook ↗ |
| YoutubeVideo An embedded YouTube video, from | Open in Storybook ↗ |
Props
| Prop | Type | Description |
|---|---|---|
blocks | readonly (RenderableBlock | null | undefined)[] | null | undefined | A page body, a snippet's content — nulls and gaps are skipped. |
registry | ContentBlockRegistry | Which component renders which type. Start from |
headingLevel? | HeadingLevel | The level for each block's own heading. Defaults to 2 — a page body sits
under the page's |
framed? | boolean | Give each block the page furniture it needs at the top level of a page body
— a |
Any other props go to the root element, like on a plain HTML element.
Registry components
ContentBlocks renders every block, and every piece of content inside one, with the component its registry holds under the GraphQL type name of that data. Start from defaultBlocks and register your own component under a key to replace it everywhere that type appears, nested blocks included; drop a key to turn that type off. Every component below is exported from @evolve-storefront/feature-content-blocks/content-blocks.
import { createRegistry } from "@evolve-storefront/feature-content-blocks";import { defaultBlocks } from "@evolve-storefront/feature-content-blocks/content-blocks";
const registry = createRegistry({ ...defaultBlocks, ContentAsset: NextImage,});
<ContentBlocks blocks={page.body} registry={registry} />;ArticleBylineWho wrote an article and when: the author's photo, name and role, and the publication date.
| Prop | Type | Description |
|---|---|---|
article | { author?: ArticleAuthor | null | undefined; publishedAt?: string | null | undefined; } | The article as queried: its |
registry? | ContentBlockRegistry | Renders the author's photo through its |
Any other props go to the root element, like on a plain HTML element.
ArticleTopicsWhat an article is about, as links to the topics' pages.
| Prop | Type | Description |
|---|---|---|
topics | ArticleTopic[] | The article's |
resolveTopicHref? | ArticleTopicHrefResolver | Routes a topic to its page. Without it, the topics are tags without links. |
renderLink? | ArticleTopicLinkRenderer |
Any other props go to the root element, like on a plain HTML element.
BlockHeaderThe title row above a row of cards, ported from evolve-reference-b2b.
The title carries the reference's typography("h4") classes inline — see
"Ported markup" in the README — but takes its level from the caller, since a
card row can sit at any depth.
The link goes through the registered ResourceLink wearing buttonProps(), so
the anchor carries the button classes and data-component="button". The
CMS's own name wins over the default label when an editor wrote one.
| Prop | Type | Description |
|---|---|---|
registry | ContentBlockRegistry | |
headingLevel | HeadingLevel | The level for the title. The calling block passes the one it was given. |
viewMoreLink? | ResourceLink | null | |
titleId? | string | Names the title element so the block's landmark can point |
Any other props go to the root element, like on a plain HTML element.
CampaignBannerA campaign: the image beside the text from md, above it on a phone, never behind it.
| Prop | Type | Description |
|---|---|---|
data | CampaignBanner | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
CategoryCardOne card in a CategoryList: a commerce ProductCategory, linked through the registered ResourceLink.
| Prop | Type | Description |
|---|---|---|
data | ProductCategory | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
CategoryListCategory entrances: a grid of the picked categories, else parentCategory's children, each through the registered ProductCategory.
| Prop | Type | Description |
|---|---|---|
data | CategoryList | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
ColumnOne column of a layout block: its entries, stacked.
Not a registry key — a column is a [ColumnEntry!]! field rather than a type
of its own, so there is nothing for a key to name. Its entries are keys, and
that is where an override goes.
| Prop | Type | Description |
|---|---|---|
entries | ColumnEntry[] | |
registry | ContentBlockRegistry | |
headingLevel | HeadingLevel |
Any other props go to the root element, like on a plain HTML element.
ContentAssetFallback image: a plain <img> at the CMS's own URL.
Register your own to get next/image — its sizing, format negotiation and
loader are app configuration (images.remotePatterns has to name the CMS
host), and Next is only a peer dependency here.
alt="" when the CMS left it empty is deliberate: an unlabelled decorative
image should be skipped by a screen reader, and a filename read aloud is worse
than silence.
A caller passing aria-hidden gets alt="" too, and that is the contract a
replacement has to honour. It is how a card says "my visible title already
names this link": a catalog card whose alt text repeats its own caption makes a
screen reader announce the category twice, which axe reports as
image-redundant-alt.
| Prop | Type | Description |
|---|---|---|
data | ContentAsset | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
ContentBlockRenders one piece of CMS content, its component looked up in the registry by the data's GraphQL type name.
No wrapper of its own, because a div between a grid and its child breaks
layout; the one exception is ContentAudience around a block for one audience.
| Prop | Type | Description |
|---|---|---|
data | unknown | The entry to render. Ignored when |
registry | ContentBlockRegistry | |
headingLevel | HeadingLevel | |
name? | keyof ContentBlockData | (string & {}) | The registry key to look up. Omit for a union field, where the key is the
value's own More
|
children? | ReactNode | |
attributes? | ContentBlockAttributes | |
frame? | boolean | Wrap the result in MoreIt lives here rather than in the registry because framing is a property of
the position, not of the type: one entry is reached from a page body, from a
column and from inside another block, and only the caller knows which. A
component may still opt out for itself with |
ContentImageA standalone image, or a pair side by side from sm and stacked on a phone.
One image adds no element and no name of its own: the asset keeps
data-component="content-asset" and takes the caller's attributes. A pair is
wrapped in content-image, which takes them instead.
| Prop | Type | Description |
|---|---|---|
data | ContentImage | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
ContentLinkA Link through the registered ResourceLink, so the site's routing reaches it unchanged: a page by its path, a product by its slug, a URL as it is.
| Prop | Type | Description |
|---|---|---|
data | ContentLink | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
ContentMessageA piece of the store's fixed system copy: the editor's text where the CMS has it, else children.
| Prop | Type | Description |
|---|---|---|
message | RichText | null | undefined | One field of |
registry | ContentBlockRegistry | |
headingLevel? | HeadingLevel | |
children? | ReactNode | The storefront's own copy, shown when the CMS has none. |
ContentRowThe page furniture a top-level block sits in: a vertical rhythm band with the brand's content width and gutters inside it.
A div. It was a section, which bought nothing: a section is only exposed as
a landmark once it has an accessible name, and this has none — the block inside
it is the thing worth naming, and each one now does. Two nested regions where the
outer is unnamed is noise in the landmark list.
The width comes from Container, so it follows --container-max and
--container-padding rather than a hardcoded max-w-*.
Applied by ContentBlocks around each entry of a page body, not by the
blocks themselves and not by the registry. Framing is a property of the
position: one registry entry is reached from a page body, from a column and
from inside another block, and only the caller knows which. A registry that
carried the frame would be right in one position and wrong in the rest.
No props of its own.
ContentSearchHitsThe pages and articles a search found, at most three, as links to place above the products.
| Prop | Type | Description |
|---|---|---|
hits | ContentSearchHit[] |
|
headingLevel? | HeadingLevel | The title's level in the page. Defaults to 2. |
resolvePageHref? | ContentSearchPageHrefResolver | Routes a hit's page by its type; the default roots its path, see |
renderLink? | ContentSearchLinkRenderer |
Any other props go to the root element, like on a plain HTML element.
FaqA titled FAQ, made of one or more groups of questions.
The groups sit under this block's own title, so they are handed subLevel()
rather than the level this block was given.
| Prop | Type | Description |
|---|---|---|
data | Faq | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
FaqGroupA titled set of FAQ items, as a single-open accordion.
ui's Accordion, which is single-open and collapsible by default — the
behaviour the reference got from radix. The primitive holds the client boundary,
so this block and its answers stay on the server.
A group without a title renders no heading, and its items keep the group's
level; under a title they get subLevel().
| Prop | Type | Description |
|---|---|---|
data | FaqGroup | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
FaqItemOne question and its answer, as a section of ui's Accordion.
A Server Component, which is what moving the accordion into ui buys: the
client boundary is inside the primitive, so the answer — rendered through the
registry, and reaching RichText and whatever renders its inline links — stays
on the server and crosses as children.
| Prop | Type | Description |
|---|---|---|
data | FaqItem | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
PageHeadingThe top of a page: its h1, then the intro (an article's lead) and the lead image.
| Prop | Type | Description |
|---|---|---|
page | PageHeadingSource | The page as queried: its |
registry? | ContentBlockRegistry | Renders the lead image through its |
Any other props go to the root element, like on a plain HTML element.
ProductListA titled rail of products the CMS picked.
This block owns the frame — the heading, the view-more link, the rail — and none
of the card. The rail is ui's Carousel, so it arrives with arrows that hide
at each end, a keyboard-scrollable viewport, and the list semantics WebKit drops
from an unstyled list.
It emits productListViewed once the rail has been seen, under data.title. The
block is the only place that knows both the list name and its items, the same
reason useAddToCart is where cartItemAdded comes from. productClicked is
not emitted here: the click belongs to whatever the consumer rendered, so
renderItem is handed the two facts a card cannot work out for itself.
The list part owns the key.
| Prop | Type | Description |
|---|---|---|
data | ProductListData<ProductVariant> | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
renderItem? | ProductVariantRenderer<ProductVariant> | Renders each product; omit it and the registry's |
ProductVariantFallback product card: image, name, SKU.
Register your own. A product card is a commerce surface with a route, a
price shape and a cart action behind it, all of which belong to the storefront.
This exists so a ProductList shows its products rather than an empty row
before that is wired, and to make the seam obvious in Storybook.
| Prop | Type | Description |
|---|---|---|
data | ProductVariant | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
PromoCardOne promo on ui's Card: its image, title and text, the whole card the link where it has one.
| Prop | Type | Description |
|---|---|---|
data | Promo | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
PromoListPromo cards in a grid, one to a row on a phone and up to four from lg, each through the registered Promo.
| Prop | Type | Description |
|---|---|---|
data | PromoList | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
ResourceLinkFallback link: a plain <a href> for the types whose value is already a URL, with anchor appended; newTab opens a new tab and says so to a screen reader.
Register your own. For PRODUCT, CATALOG, CONTENT and APPLICATION the
value is an identifier, and turning one into a URL is routing: which locale
segment a category sits under, which route a product id resolves to. So those
render their children unlinked, not as a working link to a 404.
| Prop | Type | Description |
|---|---|---|
data | ResourceLink | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
RichTextRich text from any CMS: the vendor-neutral tree that ContentBlocksRichTextFields selects, rendered in full through RichTextTree.
| Prop | Type | Description |
|---|---|---|
data | RichText | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
RichTextTreeRich text from the vendor-neutral tree, the same for every CMS.
| Prop | Type | Description |
|---|---|---|
data | RichTextTreeData | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
TableOfContentsLinks to the anchors on the page.
| Prop | Type | Description |
|---|---|---|
data | TableOfContents | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
TipA highlighted tip, in a page body or inside rich text: its label, or "Tip" where the editor left none, above the text.
| Prop | Type | Description |
|---|---|---|
data | Tip | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
UspOne selling point, optionally linked.
| Prop | Type | Description |
|---|---|---|
data | Usp | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
UspListThe CMS's unique selling points: a band at page width, a stacked list in a narrow column.
| Prop | Type | Description |
|---|---|---|
data | UspList | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |
YoutubeVideoAn embedded YouTube video: a plain <iframe> from youtube-nocookie.com, which defers YouTube's cookies until playback.
Renders YoutubeVideo and the content model's Video, which plays with controls
and no autoplay or loop, and names itself by its title.
| Prop | Type | Description |
|---|---|---|
data | Video | YoutubeVideo | |
registry | ContentBlockRegistry | The registry to render nested content with — pass it down unchanged. |
headingLevel | HeadingLevel | The level for the topmost heading this component renders. Derive a subtree's
with |
children? | ReactNode | Content this entry wraps. Only wrapping entries read it — |
attributes? | ContentBlockAttributes | Props for this entry's root element. Spread them last and merge
MoreThis is how a caller styles an entry it does not own: a block rendering a
link as a button passes |

