Skip to content

Guides

Render components

Draw the components editors place on pages with your own React components: the registry, the props each component gets, nested blocks and slots, images, rich text, links, shared sections, client components and the click-to-select preview.

How-to guide13 min read

Editors build pages from components, such as a hero, a card grid or an FAQ list. Your website draws each one with a React component of your own. This page covers everything about that. It assumes the client and types from Connect a website.

How a page is drawn

TypeScript
<CmsPage page={page} registry={registry} />

CmsPage goes through page.components in order. For each block it:

  1. Looks up block.type, such as hero, in the registry.
  2. Draws the blocks in each of its slots first, so they are ready to place.
  3. Renders your component with the props below.

A sharedSection block is replaced by the blocks inside it, so your registry never needs an entry for it. Published pages already arrive that way; the live preview sends the section itself, and the SDK unpacks it.

A block whose type is not in the registry renders a dashed box, "No renderer registered for …", in development, and nothing in production, so a new component never breaks the live website.

CmsPage renders fallback when the page is null or has no components: <CmsPage page={page} registry={registry} fallback={<p>Coming soon</p>} />.

To draw a list of blocks that is not a page body, such as the components of a site-wide entry, use CmsBlocks:

TypeScript
import { CmsBlocks } from '@bifrost-orbit-cms/sdk/react'<CmsBlocks blocks={footer.components} registry={registry} />

renderComponents(blocks, registry) does the same and returns an array, for when you need to wrap or count the results.

The props your component gets

block
Type
CmsBlock<'hero'>
Holds
The block's fields, plus type, id and variant, typed from the generated types. The easiest to use.
fields
Type
Record<string, unknown>
Holds
The same fields, untyped
id
Type
string
Holds
The block's id, unique on the page and stable across edits
variant
Type
string or null
Holds
A named look for the block, or null for the default
slots
Type
Record<string, ReactNode>
Holds
The blocks in each slot, already drawn with your registry

Type your component with BlockProps<'key'>:

TypeScript
import type { BlockProps } from '@bifrost-orbit-cms/sdk/react'export function Faq({ block }: BlockProps<'faq'>) {  return (    <section data-cms-id={block.id}>      <h2>{block.heading}</h2>    </section>  )}

If you prefer your own field types, CmsComponentProps<MyFields> types fields instead:

TypeScript
import type { CmsComponentProps } from '@bifrost-orbit-cms/sdk/react'export function Faq({ fields, id }: CmsComponentProps<{ heading: string }>) {  return <section data-cms-id={id}>{fields.heading}</section>}

The registry

cms/registry.tsxTypeScript
import '@/cms/types'import { defineRegistry } from '@bifrost-orbit-cms/sdk/react'import { Faq } from '@/components/blocks/faq'import { Hero } from '@/components/blocks/hero'export const registry = defineRegistry({  hero: Hero,  faq: Faq,})
  • defineRegistry checks that every component in the generated types has an entry, and that each entry accepts that component's props. It returns the object unchanged.
  • Entries for components that never appear on pages. Components used only in banners or popups, or only inside other components that draw them themselves, still need an entry to satisfy the types. Point them at () => null.
  • Extra entries are allowed, for example while a component is being added to the CMS.
  • Without generated types, defineRegistry accepts any object, and every block is untyped.
  • Several registries are fine. A campaign page can use a different registry from the rest of the website, and CmsBlocks takes whichever you pass.

Fields

Each field type arrives in a fixed shape. The ones that need care:

Text, Long text, Email, Address
Arrives as
string
Draw it with
As it is. React escapes it.
Rich text
Arrives as
string of HTML
Draw it with
RichText, below
Image
Arrives as
CmsImage or null
Draw it with
CmsImage, below
Images
Arrives as
CmsImage[]
Draw it with
CmsImage for each
Link
Arrives as
CmsLink or null
Draw it with
Check href, below
Reference
Arrives as
The entry's id, or the entry with include: ['references']
Select
Arrives as
One of the option values, typed as a union
Draw it with
A switch or a lookup table
Multi-select, Targets, Taxonomy terms
Arrives as
string[]
Number, Yes/No
Arrives as
number, boolean
Date, Time, Date and time
Arrives as
ISO 8601 string
Draw it with
new Date(…) and Intl.DateTimeFormat
Component, Components
Arrives as
The nested block or blocks
Draw it with
See "Nested blocks and slots" below
JSON
Arrives as
unknown
Draw it with
Check its shape before using it

Optional fields can be missing or null. The generated types mark them with ?, so TypeScript reminds you. Field types shows every type in full.

Rich text

TypeScript
import { RichText } from '@bifrost-orbit-cms/sdk/react'<RichText value={block.body} as="article" className="prose" />

RichText renders the HTML inside a div, or the element given in as, and renders nothing for an empty value. It uses dangerouslySetInnerHTML.

The HTML comes from your editors, through the rich text editor, the Management API, imports or AI agents. The SDK renders it as it is and does not clean it. If you want a guarantee that no script or unexpected markup reaches the page, clean it on your server with a library such as sanitize-html before rendering:

TypeScript
import sanitize from 'sanitize-html'<RichText value={block.body ? sanitize(block.body) : null} />

To style rich text, give the wrapper a class and style the elements inside it, such as .prose h2 and .prose a.

To turn links inside rich text into Next.js links or add rel attributes, parse the HTML with a library such as html-react-parser and replace <a> elements as you render.

Images

TypeScript
import { CmsImage } from '@bifrost-orbit-cms/sdk/react'<CmsImage image={block.image} sizes="(min-width: 768px) 50vw, 100vw" className="rounded" />
image
Means
The image field. Nothing is rendered for null.
sizes
Means
How wide the image is shown, as for <img sizes>. With it, a srcset of copies 640, 960, 1280 and 1920 pixels wide is added, leaving out any wider than the original, so browsers download the smallest that fits. Without it, only the original is used.
priority
Means
Load straight away, with high priority, for the main image near the top of the page. Otherwise images load lazily as they scroll into view.
alt
Means
Overrides the alt text editors gave the image. Pass alt="" for a purely decorative image.
className, style
Means
Passed to the <img>

The image's width and height are set on the <img>, so the page does not jump while it loads. Give it max-width: 100%; height: auto in your CSS so it scales.

An image has id, url, mimeType, width, height, alt, title and, for videos, subtitles. To build your own markup, use the helpers:

TypeScript
import { cmsImageSrcSet, cmsImageUrl } from '@bifrost-orbit-cms/sdk'cmsImageUrl(image.url, { width: 800 })                 // a copy 800 pixels widecmsImageUrl(image.url, { width: 800, format: 'avif' }) // the same, as AVIFcmsImageSrcSet(image.url, image.width)                 // "…?w=640 640w, …?w=960 960w, … 2400w"

Both only change addresses with /uploads/ in them, which the CMS can resize; any other address is returned unchanged, and CmsImage then uses the original only.

With next/image, allow the host your images are served from in next.config.ts. Copy it from an image's url in Configuration › API playground:

next.config.tsTypeScript
const nextConfig = {  images: { remotePatterns: [{ protocol: 'https', hostname: 'media.example.com' }] },}export default nextConfig

Then pass the fields through:

TypeScript
import Image from 'next/image'{block.image ? (  <Image src={block.image.url} alt={block.image.alt} width={block.image.width ?? 1200} height={block.image.height ?? 800} sizes="100vw" />) : null}

Next.js then resizes images on your own server, which costs server time but works for any image address.

Videos and other files arrive in the same shape with their mimeType. Check it before choosing an element:

TypeScript
{block.media?.mimeType.startsWith('video/') ? <video src={block.media.url} controls /> : <CmsImage image={block.media} />}
TypeScript
function CmsLinkTo({ link, children }: { link: CmsLink | null | undefined; children: ReactNode }) {  if (!link?.href) return null  return (    <a href={link.href} target={link.target ?? undefined} rel={link.target === '_blank' ? 'noopener noreferrer' : undefined}>      {children ?? link.label}    </a>  )}
kind
Holds
url for an address, content for a page in the CMS, email, or asset for a file
href
Holds
Where the link goes now: an address on your website such as /services, a full address, a mailto: link or a file address. null when it points at a page that is not published.
label
Holds
The link text editors wrote, or null
target
Holds
_blank to open in a new tab, _self, or null
contentId, assetId
Holds
The id of the page or file it points at

A link to a page carries that page's current address, so renaming a page updates every link to it. A link to a page that is unpublished or deleted has href: null: render the text without a link, or nothing.

Use next/link for addresses on your own website, so navigation is instant:

TypeScript
import Link from 'next/link'link.href.startsWith('/') ? <Link href={link.href}>{label}</Link> : <a href={link.href}>{label}</a>

Nested blocks and slots

Some components hold other components, such as the cards in a card grid or the button in a hero. In the CMS these are Component and Components fields, called slots. Each slot arrives twice, so you can use whichever suits the component:

  • On the field, as the block itself: block.cards is a list of nodes, and block.button is one node or null. A node has id, type, fields, children and variant. The generated types know which components each slot allows.
  • In slots, already drawn with your registry: slots.cards is ready to place.

Use slots to place nested blocks as they are:

TypeScript
export function CardGrid({ block, slots }: BlockProps<'cardGrid'>) {  return (    <section data-cms-id={block.id}>      <h2>{block.heading}</h2>      <div className="grid">{slots.cards}</div>    </section>  )}

Use the field when the parent decides how its children look, such as numbering them or passing extra props:

TypeScript
export function Steps({ block }: BlockProps<'steps'>) {  return (    <ol data-cms-id={block.id}>      {block.items?.map((step, index) => (        <li key={step.id} data-cms-id={step.id}>          <span>{index + 1}</span>          <h3>{step.fields.title}</h3>          <p>{step.fields.body}</p>        </li>      ))}    </ol>  )}

A node's fields are under fields, not on the node itself: step.fields.title.

To draw a node with the component registered for it, build its props with a small helper:

TypeScript
import type { BlockProps, CmsBlock } from '@bifrost-orbit-cms/sdk/react'import type { CmsBlockTypes, CmsNode } from '@bifrost-orbit-cms/sdk'export function nested<K extends keyof CmsBlockTypes & string>(node: CmsNode<CmsBlockTypes[K]>, type: K): BlockProps<K> {  return {    id: node.id,    variant: node.variant,    fields: node.fields as Record<string, unknown>,    slots: {},    block: { ...node.fields, type, id: node.id, variant: node.variant } as CmsBlock<K>,  }}// in a component{block.button ? <Button {...nested(block.button, 'button')} /> : null}

To draw a mixed list with the whole registry, renderComponents(block.items ?? [], registry) works too. Importing the registry into a block component that the registry imports is fine, because it is only read while rendering.

Slots can be nested as deep as the schema allows. Each level arrives the same way, with images and links filled in.

Variants

variant names a different look of the same component, such as dark or compact. It is null for the default look. Handle null and any value you do not recognise as the default, so content made before a variant existed still renders:

TypeScript
const look = { dark: 'bg-black text-white', compact: 'py-4' }[block.variant ?? ''] ?? 'py-12'

For choices editors make in the editor, such as a background colour, a Select field on the component is usually clearer, and it is typed.

Shared sections

A shared section is a group of blocks editors reuse on many pages, such as a newsletter sign-up. Published pages arrive with its blocks in place of the section, so your components draw them like any other blocks. CmsPage, CmsBlocks and renderComponents also unpack a sharedSection block if one arrives, as in the live preview. Publishing a shared section refreshes every page, because any page may show it.

Click to select in the preview

Put data-cms-id={block.id} on the outermost element of every component. In the preview, the bridge selects the nearest element with it when an editor clicks, and outlines it when they hover.

  • Nested blocks you draw yourself need it too: data-cms-id={step.id}. Otherwise clicking a step selects the whole list.
  • Components that render nothing, or a fragment, cannot be selected; clicking selects their parent.
  • Several elements: put it on a wrapper.
  • Visitors never load the bridge, so the attribute does nothing for them.

Components that need the browser

Your components are React Server Components by default: they run on the server and send only HTML. A component that needs state, effects or event handlers, such as an accordion or a carousel, needs 'use client':

components/blocks/faq.tsxTypeScript
'use client'import { useState } from 'react'import type { BlockProps } from '@bifrost-orbit-cms/sdk/react'export function Faq({ block }: BlockProps<'faq'>) {  const [open, setOpen] = useState<string | null>(null)  return (    <section data-cms-id={block.id}>      {block.items?.map((item) => (        <details key={item.id} data-cms-id={item.id} open={open === item.id} onToggle={() => setOpen(item.id)}>          <summary>{item.fields.question}</summary>          <p>{item.fields.answer}</p>        </details>      ))}    </section>  )}
  • A server component may render client components, and pass them the block, which is plain data. The registry can mix both.
  • slots contains React elements, which can be passed to a client component as children.
  • With live preview, every block component is also loaded in the browser while previewing, because CmsLivePage draws the page there. Keep block components free of server-only code, such as reading files or secret settings. Fetch extra data in the page route instead and pass it down, or in a separate server component the block renders.

Components that need more data

A block sometimes needs data the page does not carry, such as the latest blog posts for a "Latest news" block. A server component can fetch it:

components/blocks/latest-posts.tsxTypeScript
import type { BlockProps } from '@bifrost-orbit-cms/sdk/react'import { cms } from '@/lib/cms'export async function LatestPosts({ block }: BlockProps<'latestPosts'>) {  const posts = await cms.list({ type: 'blogPost', sort: '-publishedAt', perPage: block.count ?? 3 })  return (    <section data-cms-id={block.id}>      <h2>{block.heading}</h2>      <ul>        {posts.items.map((post) => (          <li key={post.id}>{post.url ? <a href={post.url}>{post.name}</a> : post.name}</li>        ))}      </ul>    </section>  )}

The answer is cached with the cms:list tag, which every publish refreshes. Async components cannot be drawn in the browser, so with live preview, render these from a server component around LivePage, or give LivePage the data as props.

Template fields outside components

A page also has its own fields, defined by its content type, such as a blog post's author and date. Draw them in the page route, around the components:

TypeScript
if (result.page.contentType === 'blogPost') {  const fields = result.page.fields as BlogPostFields  return (    <article>      <h1>{result.page.name}</h1>      <time dateTime={fields.date}>{new Date(fields.date).toLocaleDateString('de-DE')}</time>      <PageBody page={result.page} />    </article>  )}

BlogPostFields comes from cms/types.ts. In live preview, useLivePage(page) gives the page with the editor's latest fields as well as components, so a client template can show both changing as they type.

Was this page helpful?

Read next