██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

Content Controls Guide

Content controls are the building blocks for page-level content in Verevoir. A page's content field holds a polymorphic array of blocks — each item has a type discriminator and type-specific data. Controls define the schema and rendering for each type.

The Pattern

A content control bundles three things:

  1. A block definition — the schema for editing and validation
  2. A renderer — the React component for public display
  3. A type string — the discriminator stored with each content block
import { defineBlock, text, richText } from '@verevoir/schema';
import type { ControlDefinition } from '@verevoir/editor';

const quoteBlock = defineBlock({
  name: 'content-quote',
  fields: {
    text: richText('Quote Text'),
    attribution: text('Attribution').optional(),
  },
});

function QuoteRenderer({ data }: { data: { text: string; attribution?: string } }) {
  return (
    <blockquote data-control="quote">
      <p>{data.text}</p>
      {data.attribution && <footer>{data.attribution}</footer>}
    </blockquote>
  );
}

export const quoteControl: ControlDefinition = {
  type: 'quote',
  label: 'Quote',
  block: quoteBlock,
  Renderer: QuoteRenderer,
};

Built-in Controls

The editor package ships three generic controls:

ControlFieldsUse case
heroControltitle, body, imageUrl, ctaText, ctaUrlFull-width hero section
contentControlheading (optional), bodyGeneral rich text block
carouselControlslides (array of hero-like items)Rotating content slideshow
import { heroControl, contentControl, carouselControl } from '@verevoir/editor';

These are starting points. Most apps define their own controls tailored to their design system.

Defining App-Specific Controls

Controls live in a controls/ directory alongside your blocks. Each file exports a single control definition.

// src/controls/cta.tsx
import { defineBlock, text } from '@verevoir/schema';

const block = defineBlock({
  name: 'content-cta',
  fields: {
    label: text('Button Label'),
    url: text('URL'),
  },
});

function Renderer({ data }: { data: { label: string; url: string } }) {
  return (
    <div>
      <a href={data.url} role="button">{data.label}</a>
    </div>
  );
}

export const cta = { type: 'cta', label: 'Call to Action', block, Renderer };

Registry

Collect controls into a registry for lookup by type:

// src/controls/index.ts
import { heading } from './heading';
import { paragraph } from './paragraph';
import { cta } from './cta';
import { quote } from './quote';
import { image } from './image';

export const controls: Record<string, ContentControl> = {
  heading, paragraph, cta, quote, image,
};

export const controlList = Object.values(controls);

Storage Format

Controls are stored as items in a page's content array. Each item is a flat object with a type discriminator:

{
  "content": [
    { "type": "heading", "text": "Welcome" },
    { "type": "paragraph", "body": "Some **markdown** content." },
    { "type": "cta", "label": "Register", "url": "/register" }
  ]
}

The type key maps to a control in the registry. Unknown types are skipped during rendering.

Rendering on the Public Site

Loop over the content array, look up the control, and render:

import { controls } from '@/controls';

function ContentBlocks({ blocks }: { blocks: ContentBlock[] }) {
  return (
    <div>
      {blocks.map((block, index) => {
        const control = controls[block.type];
        if (!control) return null;
        const { Renderer } = control;
        return <Renderer key={index} data={block} />;
      })}
    </div>
  );
}

Editing Controls

Each control's block is a standard block definition, so BlockEditor renders the editing form:

import { BlockEditor } from '@verevoir/editor';

function ContentBlockEditor({ control, data, onChange }) {
  return (
    <BlockEditor
      block={control.block}
      value={data}
      onChange={(updated) => onChange({ ...updated, type: control.type })}
    />
  );
}

The admin interface adds a block picker (listing controlList) and reordering controls around these editors. The @verevoir/editor-premium package provides an InteractiveContentBlocks component with drag-and-drop reordering out of the box.

Filtering Controls

Some contexts should only allow a subset of controls. For example, a footer singleton might restrict content to paragraphs and CTAs — a hero panel makes no sense there.

Filter the control list before passing it to the block picker:

const allowedTypes = ['paragraph', 'cta'];
const filteredControls = controlList.filter(c => allowedTypes.includes(c.type));

This pattern is used by singletons (site-level content like headers and footers) to restrict which controls are available in each context.

Combining with Page Versioning

Pages typically use publishFields() from the editor alongside the content array:

import { defineBlock, text, array } from '@verevoir/schema';
import { publishFields } from '@verevoir/editor';

export const page = defineBlock({
  name: 'page',
  fields: {
    title: text('Title'),
    slug: text('Slug'),
    content: array('Content', object('Block', { type: text('Type') })),
    ...publishFields(),
  },
});

Multiple page documents can share the same slug. At most one has status: 'published' at a time. The public route uses isLive() to resolve which version (if any) is currently visible — see the Integration Guide for details.