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:
- A block definition — the schema for editing and validation
- A renderer — the React component for public display
- 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:
| Control | Fields | Use case |
|---|---|---|
heroControl | title, body, imageUrl, ctaText, ctaUrl | Full-width hero section |
contentControl | heading (optional), body | General rich text block |
carouselControl | slides (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.