Skip to main content
StormyCMS

Components & Schema

The core idea of StormyCMS: define your components once, in code, and both your site and your editor use them. This page explains how the shared component schema works.

The Export Map

Your project keeps a single "CMS export map" — a default export mapping component names to CMS-registered React components. In the boilerplate this is packages/components/index.ts:

import { Outlet, withCMS, type CMSExportsMap } from '@stormycms/react/cms';
import { Heading } from './src/heading';
import { Text } from './src/text';
export default {
Outlet, // Built-in: marks where page content renders inside a layout
Heading: withCMS(
{
export: 'Heading',
label: 'Heading',
fields: [
{ label: 'Text', prop: 'text', field: 'input', required: true, defaultValue: 'Heading' },
{
label: 'Level',
prop: 'level',
field: 'select',
options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
required: false,
defaultValue: 'h2',
},
],
noChildren: true,
},
Heading,
),
Text: withCMS(
{
export: 'Text',
label: 'Text',
fields: [
{ label: 'Content', prop: 'content', field: 'textarea', required: true, defaultValue: 'Add body copy.' },
],
noChildren: true,
},
Text,
),
};

Both apps import this same map:

  • The public site passes it to ComponentTree / LayoutTree to render stored pages.
  • The admin editor reads the attached metadata (label, fields, placement rules) to build its insertion palette and edit forms — and uses the components themselves for live previews.

When the CMS stores a component named "Heading", rendering works because "Heading" is a key in this map. Names that don't match any key are skipped.

withCMS

withCMS(cmsProps, Component) wraps a React component and attaches CMS metadata:

PropertyTypeDescription
exportstringMust match the component's key in the export map
labelstringHuman-friendly name shown in the editor's component picker
fieldsCMSField[]The editable form fields (below)
noChildrenbooleanSet true for leaf components (headings, images). Default false.
validChildrenstring[]?Restrict which components may be nested inside this one
validParentsstring[]?Restrict which components this one may be nested inside
layoutOnlybooleanOnly available in the layout editor (e.g. regions, Outlet). Default false.
editablebooleanWhether the component shows an edit form. Default true.
uniquebooleanOnly one instance allowed per parent (e.g. a header region). Default false.

Fields

Each entry in fields describes one editable prop:

{
label: 'Level', // Form label in the editor
prop: 'level', // The React prop it populates
field: 'select', // Control type
options: ['h1', 'h2'], // For 'select' and 'radio' only
required: false,
defaultValue: 'h2',
}

Field types

fieldEditor controlValue
inputSingle-line textstring
textareaMulti-line textstring
markdownMarkdown editorstring (markdown)
checkboxCheckboxboolean
dateTimeDate/time pickerstring
selectDropdown (requires options)string
radioRadio group (requires options)string
imageMedia picker (uses your S3-backed media library)ImageAsset
textListRepeatable text inputsstring[]
numberListRepeatable number inputsnumber[]
checkboxListRepeatable checkboxesstring[]
textareaListRepeatable textareasstring[]
keyValueListRepeatable key/value pairsrecord
listRepeatable list of typed fieldsunknown[]
complexListRepeatable sub-formunknown[]
noopNo control; computed via a provided fnany

For list, specify fieldTypes: CMSFieldType[] to constrain the allowed fields inside each item. For complexList, specify subFields: CMSField[] for a full nested form.

The image field and ImageAsset

The image field renders a media picker in the admin. The value stored on the component is an ImageAsset:

type ImageAsset = {
src: string;
alt?: string;
mediaId?: string;
name?: string;
tags?: string[];
};

The boilerplate admin uploads files to your S3 bucket via POST /api/media, creates a Stormy CMS Media record, and stores the resulting URL as src, the media ID as mediaId, and any alt text/tags as alt/tags. Components receive the same ImageAsset shape on the public site, so they can render <img src={image.src} alt={image.alt} /> directly.

Placement Rules in Practice

From the boilerplate's Section component:

Section: withCMS(
{
export: 'Section',
label: 'Section',
fields: [{ label: 'Label', prop: 'label', field: 'input', required: false, defaultValue: '' }],
validChildren: ['Heading', 'Text', 'RichText', 'Image', 'CallToAction', 'Group', 'Card', 'CardGroup', 'Hero'],
},
Section,
),

The editor will only offer those child components inside a Section. Combine validChildren, validParents, unique, and layoutOnly to encode your design system's composition rules so editors can't create invalid structures.

Layouts and the Outlet

Layout-only components (like the boilerplate's Region, RegionHeader, RegionFooter) build page chrome. The built-in Outlet component (exported by @stormycms/react/cms) marks where page content renders inside a layout. Include it in your export map and place it in your layouts via the layout editor.

Layouts can nest: a child layout (say, "Blog") sets parentLayoutId to the site-wide layout, and buildLayoutTree assembles the chain at render time.

Generating a Starting Schema

Already have components? Let the CLI infer a first draft:

Terminal window
stormy generate --components ./packages/components/src --output ./packages/components/generated.ts

Generation maps prop types to sensible field controls. Review the output and refine labels, defaults, and placement rules — that's expected workflow, not a shortcoming. The boilerplate's own export map started generated and was then adapted by hand.

Why This Matters for AI Agents

Because the schema is TypeScript in your repository, an AI coding agent can:

  1. Create a new React component file
  2. Register it in the export map with appropriate fields
  3. Use the GraphQL API to publish pages that use it

…all without logging into any external system. The full content model is inspectable with nothing but file reads.

Next Steps

Last updated: 7/9/26, 6:42 AM

StormyCMSThe headless CMS where you own the management panel
Community
StormyCMS