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#

typescript
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' },
        },
      },
    ] as const, // ← required for type-safe branch keys
  },
},

as const on the branches array enables compile-time type checking — .conditions({ hero: 'typo' }) becomes a TypeScript error.

Options#

Each branch is one of:

TypeConfigDescription
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 reference field with cardinality: '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():

typescript
page.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:

typescript
hero: {
  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:

typescript
branches: [
  { 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#

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