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

Source: https://docs.bifrostorbit.com/cms/developers/render-blocks/

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](https://docs.bifrostorbit.com/cms/developers/connect-a-website/).

## How a page is drawn

```tsx
<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`:

```tsx
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

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

Type your component with `BlockProps<'key'>`:

```tsx
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:

```tsx
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

```tsx
// cms/registry.tsx
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:

| Field type | Arrives as | Draw it with |
| --- | --- | --- |
| Text, Long text, Email, Address | `string` | As it is. React escapes it. |
| Rich text | `string` of HTML | `RichText`, below |
| Image | `CmsImage` or `null` | `CmsImage`, below |
| Images | `CmsImage[]` | `CmsImage` for each |
| Link | `CmsLink` or `null` | Check `href`, below |
| Reference | The entry's id, or the entry with `include: ['references']` | See [Menus, site-wide content and lists](https://docs.bifrostorbit.com/cms/developers/menus-globals-and-lists/) |
| Select | One of the option values, typed as a union | A `switch` or a lookup table |
| Multi-select, Targets, Taxonomy terms | `string[]` | |
| Number, Yes/No | `number`, `boolean` | |
| Date, Time, Date and time | ISO 8601 `string` | `new Date(…)` and `Intl.DateTimeFormat` |
| Component, Components | The nested block or blocks | See "Nested blocks and slots" below |
| JSON | `unknown` | 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](https://docs.bifrostorbit.com/cms/developers/field-types/) shows every type in full.

### Rich text

```tsx
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:

```tsx
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

```tsx
import { CmsImage } from '@bifrost-orbit-cms/sdk/react'

<CmsImage image={block.image} sizes="(min-width: 768px) 50vw, 100vw" className="rounded" />
```

| Prop | Means |
| --- | --- |
| `image` | The image field. Nothing is rendered for `null`. |
| `sizes` | 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` | 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` | Overrides the alt text editors gave the image. Pass `alt=""` for a purely decorative image. |
| `className`, `style` | 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:

```ts
import { cmsImageSrcSet, cmsImageUrl } from '@bifrost-orbit-cms/sdk'

cmsImageUrl(image.url, { width: 800 })                 // a copy 800 pixels wide
cmsImageUrl(image.url, { width: 800, format: 'avif' }) // the same, as AVIF
cmsImageSrcSet(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**:

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

Then pass the fields through:

```tsx
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:

```tsx
{block.media?.mimeType.startsWith('video/') ? <video src={block.media.url} controls /> : <CmsImage image={block.media} />}
```

### Links

```tsx
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>
  )
}
```

| Property | Holds |
| --- | --- |
| `kind` | `url` for an address, `content` for a page in the CMS, `email`, or `asset` for a file |
| `href` | 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` | The link text editors wrote, or `null` |
| `target` | `_blank` to open in a new tab, `_self`, or `null` |
| `contentId`, `assetId` | 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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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'`:

```tsx
// components/blocks/faq.tsx
'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:

```tsx
// components/blocks/latest-posts.tsx
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:

```tsx
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.
