Part 9 finished on a question it didn’t answer. An editor adds a section to a page, looks at the result, and doesn’t see it. The cause might be that it’s still a draft, that a cached result set is being served, that the running deployment doesn’t support its content type, or that the section carries a visibility rule which excluded it. All four of those look absolutely identical in a browser.

So this article is about closing that gap, and doing so takes two mechanisms which usually get described separately: rules that decide whether content renders, and a preview pipeline that shows an editor what their own changes produce. I think they belong together, because each one is the other’s explanation.

The examples use a fictional document application and a generic headless CMS. The live-preview capabilities I describe exist in several platforms under various different names.

Table of Contents

Four Reasons a Section Can Be Missing

Let’s take a concrete case that runs through the whole article: an editor adds a documentation banner to a collection page, to be shown for one month while a new document set gets promoted.

They publish it, open the page, and the banner isn’t there. The four candidate causes come from four completely different parts of the system:

  1. the entry is still in draft, so the delivery API never returned it;
  2. the page’s cached result set from Part 9 predates the change;
  3. the banner’s content type is not supported by the deployed application;
  4. the banner has a date-range rule and the current date falls outside it, or the rule got evaluated with a context the editor wasn’t expecting.

Causes 1 and 4 need a preview that shows drafts and reveals rule outcomes. Cause 2 needs cache visibility. Cause 3 needs the unsupported-type fallback from Part 9 to report itself instead of rendering nothing. So the rest of this article builds the mechanisms and then comes back to this list with a way of telling the four apart.

Visibility Rules as Content

Editors want to control not just what a page contains but when it shows up: a banner for a date range, a section only on certain routes, a variant only for one experiment group. Implementing each of those as application logic means a code change, a review and a release for every single scheduling decision, which puts the decision in entirely the wrong hands.

So the content model gives every section an optional condition field holding a type and a configuration, and the application evaluates that while it renders:

flowchart TB SEC["Section: documentation banner<br/>condition: { type: 'dateRange',<br/>start, end }"] --> READ["Read condition during SSR"] READ --> FIND["Find the evaluator for this type"] FIND --> EVAL["evaluate(config, context)"] EVAL --> R{"Result"} R -->|true| RENDER["Render the section<br/>and load its entry"] R -->|false| SKIP["Omit the section entirely;<br/>its children are never fetched"]

Each condition type is then a small plugin sitting behind one contract:

interface ConditionEvaluator {
  type: string
  evaluate(config: Record<string, unknown>, context: EvaluatorContext): boolean | Promise<boolean>
}

Two properties of evaluating during SSR are worth spelling out. A section that fails its condition is simply absent from the HTML: not hidden with CSS, not rendered and then removed after hydration. So it costs you no bytes, no layout shift and no content fetches, which connects straight back to Part 9’s stub-and-load cascade, because a hidden branch of the content tree never gets requested at all. And because the decision happens on the server, the HTML a crawler receives is the HTML a visitor receives.

What the Evaluation Context May Contain

An evaluator needs facts about the request. A reasonable context gives it the route, cookies, query parameters, the user agent, whether we’re on the server or in the browser, and any experiment assignment:

interface EvaluatorContext {
  route: RouteLocationNormalized
  cookies: Record<string, string>
  query: Record<string, string>
  userAgent: string
  isSSR: boolean
  experiments: Record<string, string>
}

That covers date windows, campaign links, device targeting, returning visitors and experiment groups, all without a single evaluator importing an application module.

It also, rather quietly, makes every one of those inputs part of the page’s identity. That’s the connection the next section is about, and it’s the single most common way a conditional-content system ends up producing wrong pages.

Conditions and Cache Identity

Part 9 cached the set of content results per URL: after a successful render, every query result the page needed got persisted to Redis under one single key, the URL path. Adding conditions raises an obvious question. If two visitors on the same URL see different sections, how on earth does a single cached data set serve them both?

The answer depends entirely on what the cache stores. A cache that stores rendered HTML encodes one specific condition evaluation, so its key has to include everything that influenced the output. That’s the problem most conditional-content systems solve with per-variant keys or by switching caching off altogether. This architecture’s page cache stores query results rather than markup. Conditions get evaluated fresh on every SSR request, independently of the data cache, and the cache simply supplies data for whichever sections the current evaluation has made visible.

That distinction lets us take a different approach: the cache grows incrementally. When a condition that used to be false evaluates to true on some later request, the newly visible section’s children render and ask for their data through the composable from Part 9. The data isn’t in the cache, because it was never fetched, so the composable goes and fetches it from the CMS. And once the render completes, the now-larger set of entries gets persisted back:

flowchart TB REQ["SSR request for a URL"] --> LOAD["Load cached data set<br/>keyed by URL"] LOAD --> EVAL["Evaluate each section's condition<br/>against the current request"] EVAL --> HIT["Section visible,<br/>data in cache:<br/>resolve locally"] EVAL --> MISS["Section visible,<br/>data missing:<br/>fetch from CMS"] EVAL --> SKIP["Section hidden:<br/>skip render"] MISS --> SAVE["Append new entries<br/>to the cached set"] HIT --> HTML["Rendered HTML"] SAVE --> HTML SKIP --> HTML

So the cache key stays the URL. No per-condition variants, no key explosion, no need to exclude conditional pages from caching at all. Each URL’s cached data converges towards the union of every entry that any condition evaluation has ever needed, which is bigger than what a single request uses but avoids keeping a separate cached copy for every combination of condition inputs. An invalidation, whether from a webhook or a cache clear, resets the set, and it grows again from the first render.

The trade-off is the first request that flips a condition. The sections whose data isn’t in the cache yet trigger CMS fetches, which is the same partial cold start Part 9 described. Subsequent requests find the data already sitting there, regardless of which conditions happen to hold.

Time-based conditions work correctly under this design. When a date-range condition starts returning true, the banner’s data gets fetched and appended on the first request past the boundary. The condition is evaluated against the current request every time, so unlike a system that caches the evaluated output, there’s no stale decision waiting to expire.

The concern comes back at any layer that caches rendered output rather than data. If a reverse proxy or CDN caches the HTML response, then the condition evaluation is baked right into that markup and the first visitor’s result gets served to everybody. That cache’s identity has to include every input which varied the rendering, so experiment assignment, relevant cookies, time bucket, or conditional pages have to bypass it entirely. Part 11 picks up the experiment-assignment case specifically.

Composite and Asynchronous Conditions

Most complicated rules turn out to be combinations of simple ones, so a composite evaluator taking an operator and a list of conditions covers a large fraction of the requests without any new code whatsoever: during this date range, and on these routes, and not for this experiment group. New plugins are then only needed for genuinely new inputs.

Some inputs live somewhere else entirely, like a feature-flag service, a geo-IP resolver or an entitlement API, so evaluate may return a promise. Three rules keep that from turning into a performance or correctness problem.

During SSR the promise gets awaited, because you can’t produce HTML from an undecided condition. Which means an external service is now sitting in the critical path of a page render, so it needs the timeout and fallback policy Part 3 described. Decide what the condition means when the flag service doesn’t answer, because defaulting to visible and defaulting to hidden are both wrong for some sections.

The result has to be memoized per request. Five sections asking the same flag service should produce one call, and that memo has to be request-scoped for the reason Part 6 went into at length: a process-wide cache of “is this flag on for this visitor” is a cross-request leak.

And the decision has to be transferred to the browser rather than recomputed there. If the client re-evaluates an async condition during hydration, it can quite easily reach a different answer than the server did and produce a mismatch, which is the failure class Part 12 is all about. The evaluated outcome belongs in the payload.

Adding a Condition Type

New inputs mean a new evaluator, and the extension path is deliberately short. Implement the interface, register it in the plugin registry as a lazy import, and add the type to the CMS model so editors can actually select it.

export const deviceTypeEvaluator: ConditionEvaluator = {
  type: 'deviceType',
  evaluate(config, context) {
    const isMobile = /Mobile/i.test(context.userAgent)
    return config.device === 'mobile' ? isMobile : !isMobile
  },
}

Nothing in the section renderer changes at all, which is the whole payoff of the plugin shape. Two honest qualifications, though. This is a deployment: editors only gain a new condition type once the plugin has shipped and the content model has been updated, so “no code changes” applies to using the existing condition types and not to inventing new ones. And the lazy import means an evaluator’s code is a separate chunk loaded when a page actually uses that condition type, which matters most when an evaluator drags in a third-party SDK and matters very little indeed for a twelve-line date comparison.

Preview Renders Drafts Through the Same Pipeline

And now the other half. Editors need to see unpublished content, which means a second content API: a preview endpoint returning drafts alongside published entries, selected by a runtime flag.

The important design property here is that the flag switches the data source and absolutely nothing else. The same catch-all route, the same generated components, the same evaluators and the same SSR path produce the preview. An editor previewing a page is looking at the very rendering code production uses, which is the only version of preview worth building. A separate preview renderer is a second implementation, and it will disagree with the first sooner or later.

The flag has to switch off every caching layer at once, and “every” is very much the operative word: the per-URL result set from Part 9, any client-side normalized cache that would survive a navigation, and any HTTP or CDN caching applied to the response. Preview means the content changes on every keystroke, so one surviving cache leaves you with an editor watching a page that refuses to update and reporting that live preview is broken. It’s also worth making sure preview responses are marked non-indexable and aren’t reachable without the preview credential, because draft content is unpublished for a reason.

Live Updates Without a Reload

Preview with a page reload for every change is usable. Live updates are what editors actually want. The platform’s preview SDK delivers those over postMessage, the browser API that lets two documents on different origins exchange data when one is embedded in the other. It’s the only channel available to us here, since the CMS and the application are separate sites and neither can touch the other’s DOM directly. So the editor types in the sidebar, the CMS posts the updated entry into the embedded application, and the page updates in place.

Two structural facts govern this integration.

The application is nested more than one level deep. The CMS web app embeds a preview frame, which embeds the application, so the window that has to receive a message isn’t necessarily the immediate parent. Messaging code that only addresses window.parent works in one embedding and silently does nothing at all in the other, so handle both the parent and the top window.

Origin validation belongs in exactly the same place. A postMessage handler receives messages from whichever window sent them, so any page that embeds your preview route can post one. And since these messages get written straight into the data the page renders from, an unchecked handler is a way for another site to put arbitrary content into your application’s view. Every message carries the sender’s origin, so compare it against the CMS’s known origin and drop anything else.

Updates have to land in the state SSR used. This is the part that decides whether live preview is reliable or not:

sequenceDiagram participant S as Nuxt server participant B as Browser participant P as Client-only preview plugin participant CMS as CMS sidebar S->>B: HTML + payload from the preview API B->>B: Hydrate using that payload CMS->>P: postMessage with the updated entry P->>P: Patch the same request-scoped data store<br/>the page rendered from P->>B: Vue reactivity re-renders the affected component

If the SDK writes to a separate data path, whether that’s its own store or a component’s local copy, then the page ends up with two sources of truth, and the symptoms are either hydration errors or, rather worse, a view that quietly diverges from its data without complaining about it. The pattern that works is a client-only plugin which subscribes to preview messages and patches the very cache the page’s data fetch populated.

Rich text is the one case worth handling specially, and my advice there is to do less. Treat the whole rich-text value as atomic and replace it wholesale when an update arrives, rather than trying to patch individual nodes inside the tree. Re-rendering a rich-text field is cheap. A partial tree update that goes wrong is a rendering bug buried inside content nobody can reproduce.

Click-to-Edit Needs Attributes in the DOM

The other preview capability is inspection: hovering over content in the preview shows you which field it came from, and clicking it opens that field in the sidebar. That’s powered by data attributes the SDK goes looking for in the rendered DOM, identifying the entry and the field:

<h1 data-cms-entry-id="abc123" data-cms-field-id="title">
  Quarterly report collection
</h1>

The application’s job is making sure those attributes actually reach the right elements. Doing that by hand in every component is both tedious and unreliable, because the attribute ends up missing in exactly the place nobody thought about. So a small wrapper component that takes the CMS model and the field name and emits the attributes is well worth having from day one. Retrofitting this across a component library that already renders dozens of content types is a genuinely miserable task, and it’s the part of preview integration most often put off and most regretted afterwards.

Why Preview Alone Does Not Answer the Question

And here’s where the two mechanisms meet, which is why I’ve treated them as one subject.

Preview shows an editor what their request produces. Conditions mean the page depends on the request: their cookies, their query string, their experiment assignment, the current date. So a banner that turns up in preview can be entirely absent in production, and both results can be perfectly correct. Preview has given the editor a true answer to a different question.

What closes the loop is making the evaluation visible rather than just its outcome. What you want is a diagnostic panel, available in preview and in non-production environments per Part 6’s environment gating, which lists for the current page each section, its content type, whether its condition was evaluated, with which relevant context values, and what the result came out as. Add whether the page was served from a cached result set and what its cache identity was, and the four causes from the opening turn into four distinguishable statements instead of one blank space.

This is a developer tool as much as an editorial one. That same panel is how you find out a condition is being evaluated with a context field you were quite sure was populated, which is a five-minute answer with the panel and an afternoon of logging without it.

The Missing Section Diagnosed

So, back to our documentation banner. With both mechanisms in place, the editor’s question finally has an answer path:

Symptom Check Cause
Absent in preview and production Entry state in the CMS Still a draft
Present in preview, absent in production Cache identity and age of the cached result set Stale cache, or an invalidation that did not reach every worker
Present in preview, absent in production, cache warm and current Condition result with the production context The rule excluded it: different date, cookie, or experiment group
Absent everywhere, panel reports an unsupported type Deployed version versus content model Content type not in this release

None of those four answers is remotely interesting on its own. Being able to tell them apart in under a minute is the entire deliverable, and it’s what makes editorial autonomy workable rather than a steady source of tickets that open with “the page is broken”.

So here’s the transferable rule. When you hand somebody else control over what renders, you owe them an explanation mechanism with the same reach as the control you gave them. A rule engine with no way to see its own decisions just moves the deployment bottleneck into a support conversation, and that isn’t obviously an improvement on anything.

What’s Next


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.