Modern CMS-driven frontends live in a constant tension: editors want instant visual feedback and full control over what appears, when, and to whom, while developers need SSR correctness, bundle discipline, and maintainable architectures.
This masterclass walks through two complementary patterns that resolve that tension on a Nuxt 4 + Contentful stack:
- Real-time WYSIWYG editing with Contentful Live Preview in SSR
- Content-driven conditional rendering via an evaluator plugin system
Together, they give editors a “what you see is what you ship” experience while keeping the codebase modular, testable, and production-ready.
Real-Time WYSIWYG Editing in SSR with Contentful Live Preview
In a headless CMS world, content editors work in one interface and verify results in another. The classic loop:
> edit → save → preview → check → repeat
Every minor tweak requires a full page reload. Contentful’s Live Preview SDK promises to break this loop: edits in the sidebar instantly update the embedded preview iframe, no reload required. The reality with Nuxt SSR is more nuanced — the docs cover the essentials, but integrating this into an SSR pipeline requires careful architecture.
The Three-Level Window Hierarchy
The first obstacle is structural. Live Preview embeds the site inside Contentful using a chain of iframes:
flowchart TD A["Window Level 1:<br/>Contentful Web App<br/>(contentful.com)"]:::level1 A --> B["Window Level 2:<br/>Contentful Preview Frame<br/>(internal iframe)"]:::level2 B --> C["Window Level 3:<br/>Your Application<br/>(your-app.example.com)<br/><br/>This is where your Nuxt app runs.<br/>postMessage must traverse TWO levels<br/>to reach Contentful's listener."]:::level3 classDef level1 fill:#f5f5f5,stroke:#555,stroke-width:1px; classDef level2 fill:#ffffff,stroke:#777,stroke-width:1px; classDef level3 fill:#e8f5ff,stroke:#2276c9,stroke-width:1px;
The application (Level 3) must send postMessage calls that ultimately reach Contentful (Level 1). But postMessage only talks to the immediate parent, not window.top. To be robust, messaging code needs to handle both:
window.parent(Contentful’s preview iframe)window.top(the main Contentful web app)
Miss one of these and messages will quietly vanish.
How Live Preview Works
Contentful’s Live Preview provides two core capabilities that you wire into a Nuxt app:
- Inspector Mode (click-to-edit)
- Live Updates (real-time content sync)
Both sit on top of the iframe messaging model.
1. Inspector Mode (Click-to-Edit)
With Inspector Mode enabled, hovering over content in the preview shows a blue overlay with the field name. Clicking it jumps the CMS sidebar to that exact field.
This is powered by DOM data attributes:
data-contentful-entry-iddata-contentful-field-id
The Live Preview SDK scans the DOM for these and wires up the overlay and click behavior.
<!-- Rendered HTML: -->
<h1
data-contentful-entry-id="abc123"
data-contentful-field-id="title"
>
Welcome to Our Store
</h1>
<!-- Inspector Mode overlay model: -->
flowchart LR A["DOM Element<br/><h1> #quot;Welcome to Our Store#quot;"]:::node A -->|"Has data-contentful-entry-id<br/>and data-contentful-field-id"| B[Live Preview SDK]:::sdk B -->|On hover| C["Blue overlay with field name<br/>(e.g. #quot;title#quot;)"]:::overlay B -->|On click| D["CMS sidebar navigates<br/>to matching entry & field"]:::cms classDef node fill:#ffffff,stroke:#555,stroke-width:1px; classDef sdk fill:#f0f5ff,stroke:#2276c9,stroke-width:1px; classDef overlay fill:#e0f3ff,stroke:#2276c9,stroke-width:1px; classDef cms fill:#f5f5f5,stroke:#777,stroke-width:1px;
The application’s responsibility is to ensure those attributes are present in the right places. The SDK does the rest.
2. Live Updates (Real-Time Content Sync)
When an editor changes a field in the CMS sidebar, Contentful sends a postMessage into the preview iframe containing the updated entry payload. The SDK receives this message and updates the app’s data in real time.
sequenceDiagram
participant E as CMS Sidebar
participant F as Preview Frame / Your Nuxt App
participant S as Live Preview SDK
participant V as Vue Reactive State
participant H as DOM h1
E->>E: Editor changes title<br/>to "New Title"
E-->>F: postMessage { field: "title",<br/>value: "New Title" }
F->>S: Message received
S->>V: Update reactive data.title = "New Title"
V->>H: Re-render h1 with "New Title"
note over H: No page reload.<br/>Sub-second update.
The key is making those SDK-driven updates land in the same reactive state the SSR pipeline uses.
The SSR Integration Challenge
Nuxt SSR adds constraints that you cannot ignore if you want reliable, flicker-free live previews.
Problem 1: Preview vs. Published API
Live Preview must pull from the Preview API (draft+published) instead of the Delivery API (published only). The GraphQL client needs to switch based on a runtime flag:
flowchart LR
subgraph PublishedModeproduct["Published Mode (production)"]
A[Apollo Client] --> B[Contentful Delivery API]
B --> C[Only published content]
C --> D[Aggressive caching]
end
subgraph PreviewModeeditorpre["Preview Mode (editor preview)"]
E[Apollo Client] --> F[Contentful Preview API]
F --> G[Draft + published content]
G --> H["No caching<br/>(content changes constantly)"]
end
I["Runtime flag:<br/>contentful.preview"] --> A
I --> E
I --> D
I --> H
A single runtime flag like contentful.preview should:
- Point the GraphQL client at the Preview API
- Turn off all caching across:
- Apollo
- Any GraphQL server
- Page-level or HTTP caches
If any cache remains enabled in preview mode, “live” updates will lag or appear broken.
Problem 2: SSR State Must Match Client State
Nuxt’s SSR flow:
- Server renders HTML using preview data.
- HTML is sent to the browser.
- Client hydrates that HTML into a live Vue app.
flowchart LR A[Request arrives] --> B["SSR data fetch<br/>via useAsyncData<br/>(Preview/Delivery API)"] B --> C[Server-side render HTML] C --> D[HTML sent to browser] D --> E["Client-side hydration<br/>rehydrate Vue app<br/>from SSR state"] E --> F["Live Preview SDK<br/>patches useAsyncData cache"] F --> G["Vue reactivity<br/>updates DOM"]
When the Live Preview SDK pushes updates, it must update the same reactive state used during SSR. If the client uses a different data path, Vue will:
- Throw hydration errors, or
- Silently diverge (the worst outcome: subtle bugs)
A robust pattern:
- Fetch data via
useAsyncDatain both SSR and client. - Wire the SDK into a client-only plugin that:
- Subscribes to Live Preview messages.
- Patches the
useAsyncDatacache directly.
Vue’s reactivity then re-renders the view without disrupting the SSR → hydration pipeline.
Problem 3: Data Attribute Propagation for Inspector Mode
Inspector Mode depends on CMS metadata making it all the way to the DOM. That requires an explicit path from the CMS model through components to the HTML element.
Introduce a small CMS wrapper component to centralize this:
<!-- Component receives CMS data -->
<Section :model="section">
<!-- model.sys.id = "abc123" -->
<!-- model.title = "Welcome" -->
</Section>
<!-- Wrapper adds data attributes -->
<div
data-contentful-entry-id="abc123"
data-contentful-field-id="title"
>
{{ model.title }}
</div>
flowchart LR A["CMS Entry<br/>sys.id = abc123<br/>title = #quot;Welcome#quot;"] --> B["Section component<br/>props: model"] B --> C["CMS wrapper<br/>adds data-contentful-*"] C --> D["DOM element<br/><div data-contentful-entry-id=#quot;abc123#quot;<br/>data-contentful-field-id=#quot;title#quot;>Welcome</div>"] classDef default fill:#ffffff,stroke:#555,stroke-width:1px;
Instead of sprinkling data-contentful-* attributes throughout the component tree, the wrapper guarantees:
- Every CMS-backed field can be wired into Inspector Mode.
- The mapping between DOM nodes and CMS fields remains consistent.
Retrofitting this after the fact is painful; design for it early.
Problem 4: Rich Text Live Updates
Contentful rich text fields are deeply nested JSON trees with embedded entries and assets. When an editor edits a paragraph, the SDK sends the full updated rich text JSON.
The simplest and most reliable pattern:
- Treat the rich text field as an atomic value.
- When an update arrives, replace the entire rich text value for that component.
- Use a fully reactive renderer that accepts rich text JSON as a prop and re-renders when the prop changes.
flowchart LR A["CMS rich text field<br/>JSON tree"] --> B["Nuxt data store<br/>(atomic value)"] B --> C["RichTextRenderer component<br/>prop: content"] C --> D[Rendered HTML] E["Live Preview SDK update<br/>full rich text JSON"] --> B B -->|reactive change| C
Attempting fine-grained tree diffs or node-level patching tends to be brittle and unnecessary; full re-renders of rich text are usually cheap enough.
DevTools: Closing the Loop for Developers
Live Preview is not just for editors. A custom Contentful DevTools tab turns it into a powerful developer tool:
flowchart TB
subgraph DevToolsContentfulTa["DevTools — Contentful Tab"]
A["Status:<br/>● Connected to Live Preview"]
B["Current Page<br/>Entry ID: abc123<br/>Content Type: Page<br/>Open in Contentful ↗"]
C["Sections on this page<br/>HeroSection (def456) Edit ↗<br/>FeatureGrid (ghi789) Edit ↗<br/>FAQ (jkl012) Edit ↗"]
D["Live Preview Events<br/>12:34:56 title updated → #quot;New Title#quot;<br/>12:34:58 heroImage updated → asset:xyz"]
end
This view gives:
- A mapping from the currently rendered page to its CMS entries.
- One-click navigation to edit any section in Contentful.
- A real-time log of Live Preview events (including timing and payloads), invaluable for debugging data flows and performance.
Lessons from Live Preview Integration
- The three-level iframe hierarchy is the first thing you will debug.
Handle both window.parent and window.top from day one, or expect mysterious postMessage failures.
- Preview mode must disable all caches.
Live preview means “content can change on every keystroke.” Any caching layer will eventually serve stale data and ruin the experience.
- Data attributes for Inspector Mode need architectural support.
A wrapper or HOC that guarantees data-contentful-entry-id/field-id reach the DOM is vastly better than ad-hoc attribute sprinkling.
- Live Preview is a developer experience feature, too.
Instant feedback accelerates component implementation and debugging. DevTools integration amplifies that effect.
With robust live preview in place, editors can see exactly what they’re building. The next step is giving them nuanced control over when content appears — without asking developers for another deployment.
Content-Driven Conditional Rendering: The Evaluator Plugin System
Editors need to control not just what appears on a page, but whether it appears at all:
- Campaign banners that show only during a specified date range.
- Sections that are visible only in certain regions or routes.
- Pricing variants that target specific A/B test buckets or feature flags.
Hardcoding these rules in application logic leads to a familiar pattern: every change requires a code change, PR, review, and deployment.
The goal is different:
> Allow editors to define conditions on CMS sections, have the application evaluate them at render time (SSR and client), and support new condition types without shipping new code.
The solution is a plugin-based evaluator architecture.
The Architecture: Evaluator Plugins
Each condition type is implemented as its own evaluator plugin. The CMS content model adds a generic condition field to every section. At render time, the application loads the appropriate evaluator and checks the condition before deciding whether to render.
flowchart TB
subgraph CMS
A["Section: #quot;Summer Promotion Banner#quot;<br/><br/>Content:<br/>title: #quot;Summer Sale — 20% Off#quot;<br/>image: hero-summer.jpg<br/><br/>Condition:<br/>type: #quot;dateRange#quot;<br/>config:<br/>startDate: #quot;2025-06-01#quot;<br/>endDate: #quot;2025-08-31#quot;<br/><br/>Visible only if:<br/>current date is within range"]
end
A --> B[Application at render time]
B --> C[Load section from CMS]
C --> D["Read condition<br/>{ type: #quot;dateRange#quot;, config: {...} }"]
D --> E["Find evaluator plugin:<br/>dateRangeEvaluator"]
E --> F["Execute evaluate(config, context)"]
F --> G{Result?}
G -->|true| H[Render section]
G -->|false| I[Skip section entirely]
This pattern mirrors how you might structure the Live Preview SDK integration:
- A central runtime that knows what is needed (condition type).
- A registry that knows how to load the right plugin.
- Plugins that encapsulate behavior and can evolve independently.
The Evaluator Interface
Every evaluator plugin implements a simple contract:
Evaluator Plugin Interface:
┌─────────────────────────────────────────────────┐
│ interface ConditionEvaluator { │
│ type: string // e.g. "dateRange"
│ evaluate( │
│ config: Record<string, any>, // CMS-defined params
│ context: EvaluatorContext // request context
│ ): boolean | Promise<boolean> │
│ } │
│ │
│ interface EvaluatorContext { │
│ route: RouteLocationNormalized │
│ cookies: Record<string, string> │
│ query: Record<string, string> │
│ userAgent: string │
│ isSSR: boolean │
│ abTests: Record<string, string> │
│ } │
└─────────────────────────────────────────────────┘
flowchart LR
A["ConditionEvaluator<br/>type: string<br/>evaluate(config, context)"] --> B["config<br/>CMS-defined params"]
A --> C["context<br/>EvaluatorContext"]
C --> D[route]
C --> E[cookies]
C --> F[query]
C --> G[userAgent]
C --> H[isSSR]
C --> I[abTests]
B --> J{evaluate<br/>returns}
C --> J
J -->|"boolean<br/>or Promise<boolean>"| K[Render decision]
The context object exposes request-level details:
- Route (for path and params)
- Cookies (for personalization and persisted state)
- Query parameters (for campaign links, tracking, etc.)
- User agent (for device/agent segmentation)
- SSR flag (to distinguish server vs client behavior)
- A/B test assignments (for experiment targeting)
This gives immense flexibility without tying conditions to specific application modules.
Built-In Evaluator Types
A typical system ships with a set of core evaluators:
| Evaluator | Condition | Example Config |
|---|---|---|
dateRange | Section visible during a date range | { start: "2025-06-01", end: "2025-08-31" } |
abTest | Section visible for a specific A/B test variant | { testId: "pricing", variant: "B" } |
route | Section visible on specific URL patterns | { patterns: ["/products/*", "/offers"] } |
cookie | Section visible when a cookie has a specific value | { name: "returning", value: "true" } |
queryParam | Section visible when a URL parameter is present | { param: "promo", value: "summer" } |
composite | Combines multiple conditions with AND/OR logic | { operator: "and", conditions: [...] } |
The composite evaluator is where the power really emerges.
flowchart TB
A["Composite Condition<br/>type: #quot;composite#quot;<br/>operator: #quot;and#quot;"] --> B["Condition 1:<br/>dateRange<br/>2025-06-01 → 2025-08-31"]
A --> C["Condition 2:<br/>abTest #quot;pricing#quot; variant #quot;B#quot;"]
A --> D["Condition 3:<br/>route patterns: /checkout/*<br/>negate: true"]
B --> E{All true?}
C --> E
D --> E
E -->|true| F[Section visible]
E -->|false| G[Section hidden]
Editors compose complex logic from simple building blocks — no code changes, no deployments.
Dynamic Plugin Loading
Evaluator plugins are loaded on demand. If a section has a "dateRange" condition, only the dateRange evaluator is ever imported:
flowchart TB
A["Section condition<br/>{ type: #quot;dateRange#quot;, ... }"] --> B[Plugin Registry]
subgraph Registry
B --> C["'dateRange' → lazy import evaluators/dateRange"]
B --> D["'abTest' → lazy import evaluators/abTest"]
B --> E["'route' → lazy import evaluators/route"]
end
C --> F["Load dateRange evaluator<br/>into bundle"]
D -. not used .- X["(Not loaded)"]
E -. not used .- Y["(Not loaded)"]
This mirrors how you might isolate heavy Live Preview logic to preview-only builds or routes. Dynamic imports ensure:
- Production bundles stay lean.
- Pages only pay for the condition types they actually use.
- Heavy dependencies (e.g. external SDKs for feature flags) are kept off most pages.
Async Evaluators
Some conditions need to talk to external systems:
- Feature flag services
- Experiment platforms
- Geo-IP resolvers
- Permissions or entitlement services
The evaluator interface supports async evaluation by allowing Promise:
Async Evaluator Example: Feature Flag
┌─────────────────────────────────────────────────┐
│ evaluator: "featureFlag" │
│ config: { flag: "new-checkout-flow" } │
│ │
│ async evaluate(config, context) { │
│ const flags = await fetchFeatureFlags() │
│ return flags[config.flag] === true │
│ } │
│ │
│ This evaluator fetches feature flags from an │
│ external service and checks if the specified │
│ flag is enabled. The result is cached for the │
│ duration of the SSR request. │
└─────────────────────────────────────────────────┘
sequenceDiagram participant R as Request participant Eval as featureFlag evaluator participant FF as Feature Flag Service participant SSR as SSR Renderer R->>Eval: evaluate(config, context) Eval->>FF: fetchFeatureFlags() FF-->>Eval: flags payload Eval-->>SSR: boolean result<br/>(flag enabled?) SSR->>SSR: Cache result for<br/>duration of SSR request
Key behavior:
- During SSR, async evaluators are awaited so the server sends HTML matching the current feature state.
- During hydration, results are hydrated from SSR payloads; no additional round-trip or re-evaluation is required.
This lines up cleanly with the Live Preview strategy: both systems rely on SSR as the single source of truth for initial HTML, then reuse that state on the client.
Adding a New Condition Type
The plugin pattern shines when you want to extend behavior without touching the core rendering pipeline.
Adding a new evaluator is a three-step process:
- Create the evaluator file — implement the
ConditionEvaluatorinterface. - Register it in the plugin registry — add a lazy import entry.
- Expose the condition type in the CMS model — so editors can use it.
Adding a new evaluator:
Step 1: Create evaluator
evaluators/deviceType.ts
┌────────────────────────────────────────┐
│ export const deviceTypeEvaluator = { │
│ type: 'deviceType', │
│ evaluate(config, context) { │
│ const isMobile = /Mobile/i.test( │
│ context.userAgent │
│ ) │
│ return config.device === 'mobile' │
│ ? isMobile │
│ : !isMobile │
│ } │
│ } │
└────────────────────────────────────────┘
Step 2: Register
registry.ts
┌────────────────────────────────────────┐
│ 'deviceType': lazy(() => │
│ import('./evaluators/deviceType')) │
└────────────────────────────────────────┘
Step 3: CMS content model
Add "deviceType" to the condition type enum
Content editors can now create device-specific sections
flowchart LR A["Step 1:<br/>Create deviceType evaluator<br/>evaluators/deviceType.ts"] --> B["Step 2:<br/>Register in registry.ts<br/>'deviceType': lazy(() => import(...))"] B --> C["Step 3:<br/>Update CMS model<br/>add #quot;deviceType#quot; to enum"] C --> D["Editors can configure<br/>device-specific sections"]
No changes to the section renderer, no framework rewiring, no deployment choreography beyond shipping the plugin and updating the CMS model.
The Rendering Pipeline
Section rendering now becomes a simple, declarative pipeline:
flowchart TB A["CMS Page<br/>sections: S1..S5"] --> B[For each section] B --> C[S1: No condition] C --> C1[Result: RENDER] B --> D[S2: dateRange] D --> D1["Evaluate → true"] D1 --> D2[Result: RENDER] B --> E["S3: abTest(pricing)"] E --> E1["Evaluate → false"] E1 --> E2[Result: SKIP] B --> F["S4: composite(AND)"] F --> F1["Evaluate → true"] F1 --> F2[Result: RENDER] B --> G[S5: dateRange] G --> G1["Evaluate → false"] G1 --> G2[Result: SKIP] C1 --> H["Rendered page<br/>S1, S2, S4"] D2 --> H F2 --> H
Sections that fail their conditions are completely omitted from the HTML:
- No hidden DOM.
- No client-side toggling.
- No wasted rendering time or CLS risk.
This is especially powerful when combined with Live Preview: editors can see in real time whether a condition hides or reveals a section, and the system maintains SSR performance and correctness.
Lessons from the Evaluator System
- The plugin pattern is the right abstraction for open-ended condition types.
A fixed set of conditions becomes restrictive quickly. Plugins let you extend behavior without touching core rendering logic.
- Dynamic loading prevents evaluator code bloat.
Loading every evaluator upfront makes every page carry every possible condition implementation. Dynamic imports keep bundles slim, which matters even more when some evaluators depend on heavy SDKs.
- The composite evaluator covers most complex conditions.
Most “advanced” conditions are just compositions of simpler ones: “date range AND A/B variant AND URL pattern.” Only truly new logic requires new plugins.
- Content editors should not need to understand the evaluator system.
The CMS UI should present a simple configuration experience: pick a condition type, fill in fields, publish. The plugin machinery stays behind the curtain.
Bringing It All Together
When Live Preview is combined with a content-driven conditional rendering system, the result is a powerful, editor-centric platform suitable for large enterprise applications:
- Editors can:
- See every change in real time, in the actual Nuxt SSR app.
- Configure complex visibility rules without developer involvement.
- Validate that conditions behave as expected via live previews.
- Developers can:
- Maintain a clean SSR data flow with consistent reactive state.
- Avoid cache pitfalls in preview mode via a single
previewflag. - Extend behavior by adding small, testable plugins rather than touching core systems.
- Use DevTools to observe Live Preview events and condition outcomes in detail.
The result is a CMS integration that respects both editor velocity and engineering rigor — a foundation that carries well into e-commerce, SaaS dashboards, content sites, and other complex frontend applications.





