Show navigationHide navigation
Conditional
Content variants with multiple branches, all editable simultaneously. At query time, select which branch to resolve. Use for A/B testing, feature flags, user segments, locale-based content, and any scenario where the same entry needs multiple content variants.
Usage#
typescripthero: { format: 'conditional', label: 'Hero Section', conditional: { branches: [ { key: 'control', label: 'Control', fields: { headline: { format: 'string', label: 'Headline', required: true }, subline: { format: 'text', label: 'Subline' }, }, }, { key: 'experiment', label: 'Experiment', fields: { headline: { format: 'string', label: 'Headline', required: true }, heroImage: { format: 'image', label: 'Hero Image' }, }, }, ] as const, // ← required for type-safe branch keys }, },
as conston thebranchesarray enables compile-time type checking —.conditions({ hero: 'typo' })becomes a TypeScript error.
Options#
Each branch is one of:
| Type | Config | Description |
|---|---|---|
| Inline | { key, label, fields: { ... } } | Fields defined directly in the branch |
| Reference | { key, label, collection: 'name' } | Points to an entry in another collection |
There is no limit on the number of branches.
Admin UI#
The editor renders a tabbed panel — one tab per branch. All tabs are always visible and editable. Editors fill content for every variant simultaneously.
- Inline branches show their field editors (same components as regular entry fields)
- Reference branches show an entry selector/editor (same as a
referencefield withcardinality: 'one')
Example: A/B testing a landing page hero#
A landing page with two hero variants — a text-only control and an image-heavy experiment. The variant is selected server-side based on a cookie.
Schema:
typescript// cms/octocms.config.ts landingPage: { label: 'Landing Page', fields: { title: { label: 'Title', format: 'string', entryTitle: true, required: true }, hero: { format: 'conditional', label: 'Hero Section', conditional: { branches: [ { key: 'control', label: 'Control', fields: { headline: { format: 'string', label: 'Headline', required: true }, subline: { format: 'text', label: 'Subline' }, }, }, { key: 'experiment', label: 'Experiment', fields: { headline: { format: 'string', label: 'Headline', required: true }, heroImage: { format: 'image', label: 'Hero Image' }, ctaLabel: { format: 'string', label: 'CTA Button' }, }, }, ] as const, }, }, }, },
Server-side branch selection — pick the variant from a cookie and resolve only that branch:
tsx// src/app/landing/page.tsx import { cookies } from 'next/headers'; import Image from 'next/image'; import { query } from 'cms/__generated__/query'; export default async function LandingPage() { const cookieStore = await cookies(); const variant = cookieStore.get('ab_hero')?.value === 'experiment' ? 'experiment' : 'control'; const page = await query('landingPage') .conditions({ hero: variant }) .first(); if (!page) return null; const hero = page.fields.hero; return ( <main> <h1>{page.fields.title}</h1> <section> <h2>{hero.headline}</h2> {'subline' in hero && hero.subline && <p>{hero.subline}</p>} {'heroImage' in hero && hero.heroImage && ( <Image src={hero.heroImage.src} alt={hero.heroImage.alt} width={hero.heroImage.width ?? 800} height={hero.heroImage.height ?? 400} /> )} {'ctaLabel' in hero && hero.ctaLabel && ( <button>{hero.ctaLabel}</button> )} </section> </main> ); }
What .conditions() returns:
When you call .conditions({ hero: 'control' }), the returned hero is the resolved branch value directly — not wrapped in a keyed object:
typescript// With .conditions({ hero: 'control' }): page.fields.hero // → { headline: 'Welcome', subline: 'Get started today.' } // With .conditions({ hero: 'experiment' }): page.fields.hero // → { headline: 'See It In Action', heroImage: { src, alt, ... }, ctaLabel: 'Try Free' }
Client-side branch selection#
When the variant decision happens in the browser (client-side A/B library), query without .conditions(). All branches are returned as a keyed object.
tsx// Server Component — pass all branches export default async function LandingPage() { const page = await query('landingPage').first(); return <HeroSection branches={page.fields.hero} />; } // Client Component — pick branch in browser 'use client'; export function HeroSection({ branches }: { branches: Record<string, any> }) { const variant = useExperiment('hero_test'); // your A/B library const hero = branches[variant]; return <h2>{hero.headline}</h2>; }
Without .conditions():
typescriptpage.fields.hero // → { control: { headline: '...', subline: '...' }, experiment: { headline: '...', ... } }
Runtime validation#
When .conditions() is called, every top-level conditional field must have a matching key. Missing keys throw a descriptive error:
Missing condition for conditional field 'hero'. Available branch keys: control, experiment
Nested conditionals#
A branch's inline fields can contain another conditional field:
typescripthero: { format: 'conditional', label: 'Hero', conditional: { branches: [ { key: 'promo', label: 'Promo', fields: { banner: { format: 'conditional', label: 'Banner Style', conditional: { branches: [ { key: 'simple', label: 'Simple', fields: { text: { format: 'string', label: 'Text' } } }, { key: 'rich', label: 'Rich', fields: { body: { format: 'markdown', label: 'Body' } } }, ] as const, }, }, }, }, ] as const, }, }
Nested keys are flat in .conditions():
typescript// Resolve both outer and inner const page = await query('page') .conditions({ hero: 'promo', banner: 'rich' }) .first(); // Resolve outer only — inner returns all branches const page2 = await query('page') .conditions({ hero: 'promo' }) .first(); // page2.fields.hero.banner = { simple: {...}, rich: {...} }
Nested conditionals without a matching key silently return all branches — no error is thrown.
Reference branches#
A branch can point to an entry in another collection instead of defining inline fields:
typescriptbranches: [ { key: 'custom', label: 'Custom', fields: { headline: { format: 'string', label: 'Headline' } } }, { key: 'template', label: 'From Template', collection: 'heroTemplate' }, ] as const,
Reference branches are resolved to full entry objects, same as reference fields.
Storage#
All branches are stored simultaneously as a keyed object:
json{ "hero": { "control": { "headline": "Welcome", "subline": "Get started today." }, "experiment": { "headline": "See It In Action", "heroImage": "uuid-123", "ctaLabel": "Try Free" } } }
This differs from Keystatic, which stores only the active branch. Storing all branches enables A/B testing — no data is lost when switching variants.
Query result#
| Query | Result |
|---|---|
.conditions({ hero: 'control' }) | Resolved branch value directly |
.conditions({}) | Error — missing key for hero |
No .conditions() | All branches as keyed object { control: {...}, experiment: {...} } |
Key design choice#
Unlike CMS platforms that store only the active branch, OctoCMS stores all branches simultaneously. This means switching variants in code never loses data — every branch is always fully populated and editable.