Flat or Nested? Weighing Your Content Modeling Options in Optimizely SaaS CMS
When building a headless site on Optimizely SaaS CMS, one of the earliest and most critical design milestones your team will face is defining your content model.
Unlike traditional monolithic setups where visual layout often dictates data structure, headless modeling requires you to design a clear, reusable contract between two very different groups of people: your content authors (working in the CMS UI) and your developers (writing front-end components).
Because developers are trained to eliminate duplicate code (the "DRY" principle), they often instinctively design highly complex, nested, or consolidated data structures. Authors, on the other hand, require flat, intuitive, and self-explanatory editing interfaces.
When these two instincts collide, tension arises.
Below, we’ll walk through a common, real-world scenario—modeling standard videos versus gated lead-generation videos—and lay out three valid modeling options, complete with their architectural trade-offs, so your team can make a well-informed choice.
The Scenario: One Concept, Two Realities
Let's say your site needs to support videos in two very different business contexts:
-
Standard Video: An editorial or informational video with autoplay, looping, and visual player controls.
-
Gated Video: A lead-generation asset that plays a brief preview, pauses at a specific timestamp, and displays an email capture form synced automatically with an external Marketing Automation Platform (MAP).
|
Feature |
Standard Video |
Gated Video |
|
Author sets |
Video file, Poster image, Title, Autoplay toggle, Loop toggle, Show controls |
Video file, Poster image, Title |
|
Sourced from MAP |
Not applicable |
Form ID, Gate Timestamp (seconds to trigger form) |
|
Front-end behavior |
Continuous playback, native player controls |
Pauses playback at Gate time and overlays lead capture form |
To model this, your team has three distinct structural approaches to consider. Let's look at the trade-offs of each.
Option 1: The Consolidated "Mega-Block" (All Fields in One Type)
This approach creates a single, highly flexible content type—such as VideoComponent—containing every possible property. You then rely on the author's training (or inline help text) to know which fields to fill.
Consolidated Block: VideoComponent
├── VideoFile
├── PosterImage
├── Title
├── Autoplay (Fill only for Standard)
├── Loop (Fill only for Standard)
├── ShowControls (Fill only for Standard)
├── FormId (Fill only for Gated)
└── GateTimestamp (Fill only for Gated)
The Trade-Offs
-
Pros:
-
Simple Setup: Only one content type to define, register, and sync to the CMS.
-
Single Component: Developers maintain just one straightforward React component with simple conditional rendering.
-
-
Cons:
-
Author Cognitive Load: Authors are forced to look at "dead fields." An editor adding a gated marketing video must mentally ignore Autoplay, Loop, and Controls.
-
Validation Gaps: You cannot easily make fields like FormId required for gated videos without making them required for standard videos too, risking incomplete data.
-
Option 2: The Nested "Wrapper Block" (Composition via Content Area)
This approach seeks to avoid database field duplication by pulling the shared properties (VideoFile, PosterImage, and Title) into a parent VideoItem block. This block then contains a nested Content Area where the author inserts a secondary "extension block" containing the specific properties.
Parent Block: VideoItem
├── VideoFile
├── PosterImage
├── Title
└── Content Area (Allows only StandardProperties OR GatedProperties)
The Trade-Offs
-
Pros:
-
Strictly "DRY" Database Schema: The base fields are declared exactly once, preventing schema duplication.
-
Modularity: You can reuse the "Standard" or "Gated" property blocks in other components across the system.
-
Configurable guardrails: Content Areas are not a free-for-all. Built-in validation fields let you constrain them declaratively—allowedTypes / restrictedTypes restrict exactly which extension types can be dropped in, and maxLength: 1 caps the area to a single item—so wrong-type and duplicate insertions are preventable without any custom code.
-
-
Cons:
-
Editing Friction: Authors must execute a two-step process to build a single video. They fill out the parent, click "Create New Block" inside the Content Area, and then fill out the child block—slower than picking one flat type up front.
-
The "empty wrapper" gap: While allowedTypes and maxLength handle type and cardinality, enforcing a strict minimum of one item is not cleanly supported for content areas—so an author can still save an empty wrapper with no extension inside.
-
GraphQL Query Noise: Even when correctly constrained, every query to fetch a video must navigate a nested fragment structure, increasing the payload and query complexity compared to a flat type.
-
Option 3: The Split-Type "Symmetrical" Pattern (Recommended)
This approach splits the concept into two distinct, flat content types: StandardVideo and GatedVideo. The author's choice of which component to insert in their page layout is the disambiguation.
To keep things clean, we use Optimizely's built-in features to resolve the duplication: Property Groups for authors, and SDK Contracts for developers.
Standard Video (Type 1)
├── [Video Settings Group] ← via SDK Contract
│ ├── VideoFile
│ ├── PosterImage
│ └── Title
└── [Player Options Group]
├── Autoplay, Loop, ShowControls
Gated Video (Type 2)
├── [Video Settings Group] ← via SDK Contract
│ ├── VideoFile
│ ├── PosterImage
│ └── Title
└── [Lead Capture Group]
├── FormId, GateTimestamp
The Trade-Offs
-
Pros:
-
Excellent Author UX: Editors see only the fields that are directly relevant to what they are creating. No noise, no confusion, and the choice happens up front in a single step.
-
Precise Validation: You can make FormId strictly required on Gated Videos and VideoFile required on both, with no overlaps—and no "empty wrapper" edge case to guard against.
-
Evolvable Schemas: If you need to add an "External Tracker ID" to Gated Videos later, you add it to the Gated type. The Standard Video type remains completely untouched, preventing regressions.
-
Flat GraphQL queries: Each type resolves directly, with no nested content-area fragment to traverse.
-
-
Cons:
-
Minor Code Duplication: In some architectures, this might mean registering two types instead of one (though Optimizely's SDK makes this incredibly clean, as shown below).
-
Implementing Option 3 Cleanly with the Content JS SDK
⚠️ Implementation Disclaimer: The following code snippets are illustrative implementations demonstrating the structure and APIs of the @optimizely/cms-sdk. Depending on your active SDK version, environment settings, and target packages, always verify the exact properties, imports, and field parameters in your sandbox prior to deploying content types to production.
1. Define a Shared Contract in Code
The SDK's contract() function lets you declare the shared fields once. It keeps your codebase DRY, but merges the properties directly into each schema under the hood so authors get a flat, non-nested editor form:
// src/contracts/VideoBaseContract.ts
import { contract } from '@optimizely/cms-sdk';
export const VideoBaseContract = contract({
key: 'VideoBase',
properties: {
videoFile: { type: 'contentReference', allowedTypes: ['_video'], required: true },
posterImage: { type: 'contentReference', allowedTypes: ['_image'], required: true },
title: { type: 'string', required: true },
},
});
2. Extend the Contract in Your Content Types
Both content types simply extend our base contract:
// src/components/StandardVideo.ts
export const StandardVideoContentType = contentType({
key: 'StandardVideo',
baseType: '_component',
extends: VideoBaseContract, // <-- Merges shared fields
properties: {
autoplay: { type: 'boolean', group: 'playerOptions' },
loop: { type: 'boolean', group: 'playerOptions' },
showControls: { type: 'boolean', group: 'playerOptions' },
},
});
// src/components/GatedVideo.ts
export const GatedVideoContentType = contentType({
key: 'GatedVideo',
baseType: '_component',
extends: VideoBaseContract, // <-- Merges shared fields
properties: {
formId: { type: 'string', group: 'leadCapture' },
gateTimestamp: { type: 'integer', group: 'leadCapture' },
},
});
3. Resolve to a Single React Component
Here is the developer's favorite trick: even though we have two schemas in the CMS, we can map them both to a single React component.
In our registry setup, we point both type keys to our <VideoItem /> component. The component inspects __typename (always supplied by Optimizely Graph) to decide how to render:
// src/app/layout.tsx — Map both keys to the same component
initReactComponentRegistry({
resolver: {
StandardVideo: VideoItem,
GatedVideo: VideoItem,
},
});
// src/components/VideoItem.tsx — One entry, two layouts
export default function VideoItem({ content }: Props) {
if (content.__typename === 'GatedVideo') {
return <GatedVideoView content={content} />; // Typed as GatedVideoProps
}
return <StandardVideoView content={content} />; // Typed as StandardVideoProps
}
Conclusion: Choosing What's Right for Your Team
There is rarely a single "correct" answer in software architecture—only choices and their consequences.
-
If your team is very small, your content needs are highly fluid, and you prioritize a rapid setup above all else, Option 1 (The Consolidated Block) might serve you well.
-
If your design system relies heavily on composable, modular sub-assemblies and your editors are comfortable with multi-step nesting, Option 2 (The Nested Wrapper) offers high structural reuse.
-
If you prioritize editor productivity, robust validation, and clean GraphQL queries, Option 3 (The Split-Type Pattern) combined with the SDK's single component routing gives you the best of both worlds: flat schemas for authors, and type-safe, DRY code for developers.
By laying these options on the table, your team can align on a modeling philosophy that matches your technical maturity and authoring workflows.
Resources: Content JS SDK documentation · Optimizely content modeling principles · Content base types
Comments