Low angle view of a modern skyscraper with a sleek, contemporary facade. Hero image credit: Photo by Possessed Photography on Pexels

Part 10 of the Nuxt and .NET series.

Part 9 ended on an unanswered question. An editor adds a section to a page, looks at the result, and does not see it. The cause could be that it is still a draft, that a cached result set is being served, that the running deployment does not support its content type, or that the section carries a visibility rule that excluded it. All four look identical in a browser.

This article is about closing that gap, which takes two mechanisms that are usually described separately: rules that decide whether content renders, and a preview pipeline that shows an editor what their own changes produce. 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 described exist in several platforms under different names.

Table of Contents

Four Reasons a Section Can Be Missing

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 is promoted.

They publish it, open the page, and the banner is absent. The four candidate causes come from four 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 is outside it — or the rule was evaluated with a context the editor did not expect.

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 rather than render nothing. The remainder of this article builds the mechanisms and then returns to this list with a way of telling them apart.

Visibility Rules as Content

Editors want to control not only what a page contains but when it appears: 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 scheduling decision, which is the wrong owner for the decision.

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

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 a small plugin 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 making explicit. A section that fails its condition is absent from the HTML — not hidden with CSS, not rendered and removed after hydration — so it costs no bytes, no layout shift and no content fetches, which connects directly to Part 9’s stub-and-load cascade: a hidden branch of the content tree is never requested. And because the decision is made 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 exposes the route, cookies, query parameters, the user agent, whether this is the server or 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 without any evaluator importing an application module.

It also, quietly, makes every one of those inputs part of the page’s identity — which is the connection the next section is about, and the single most common way a conditional-content system produces wrong pages.

Conditions and Cache Identity

Part 9 cached the set of content results per URL. Part 3 insisted that a cache key must include everything that varies the result. Conditions are exactly such a variation, so the moment a page contains a section whose visibility depends on a cookie, a query parameter or an experiment assignment, the URL alone is no longer its cache identity.

Get this wrong and the failure is not subtle: the first visitor’s evaluation is baked into a cached page served to everyone. A section meant for one experiment group appears for all of them; a section meant for returning visitors greets a first-time visitor.

There are only two sound responses, and choosing between them is a design decision worth making deliberately:

  • Include the input in the cache key. Correct, and it multiplies the number of cached variants by every distinct value, which is affordable for a two-variant experiment and not for a free-text query parameter.
  • Exclude the page from caching when a per-visitor condition is present, and pay the full cascade on every request.

Time-based rules deserve their own paragraph, because they look harmless and are not. A date-range condition changes its answer without anything happening in the system: nobody publishes, no webhook fires, no key changes. A cached page produced yesterday will happily state that the banner is still hidden for as long as its entry lives. So a page with a time-based condition needs a time-bounded cache — a TTL shorter than the precision the editor expects, or a key that includes the relevant time bucket. Explaining to an editor that their banner will appear “within an hour of midnight” is a design decision; discovering it after a campaign launch is not.

Composite and Asynchronous Conditions

Most complex rules turn out to be combinations of simple ones, so a composite evaluator that takes an operator and a list of conditions covers a large fraction of requests without any new code: 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 elsewhere — a feature-flag service, a geo-IP resolver, an entitlement API — so evaluate may return a promise. Three rules keep that from becoming a performance or correctness problem.

During SSR, the promise is awaited, because HTML cannot be produced from an undecided condition. That means an external service is now 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 does not answer, since defaulting to visible and defaulting to hidden are both wrong for some sections.

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

And the decision must be transferred to the browser rather than recomputed. If the client re-evaluates an async condition during hydration, it can reach a different answer than the server did and produce a mismatch — the failure class Part 12 is 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 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, which is the payoff of the plugin shape. Two honest qualifications, though. This is a deployment — editors gain a new condition type only after the plugin ships and the content model is updated, so “no code changes” applies to using existing condition types, 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; that matters most when an evaluator pulls in a third-party SDK, and matters very little for a twelve-line date comparison.

Preview Renders Drafts Through the Same Pipeline

Now the other half. Editors need to see unpublished content, which means a second content API — a preview endpoint that returns drafts alongside published entries — selected by a runtime flag.

The important design property is that the flag switches the data source and 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 rendering code production uses, which is the only version of preview worth building; a separate preview renderer is a second implementation that will disagree with the first.

The flag has to turn off every caching layer at once, and “every” is 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 produces an editor watching a page that will not update and reporting that live preview is broken. It is also worth ensuring preview responses are marked non-indexable and are not reachable without the preview credential — draft content is unpublished for a reason.

Live Updates Without a Reload

Preview with a page reload per change is usable; live updates are what editors actually want. The platform’s preview SDK delivers them over postMessage: 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 the 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 must receive a message is not necessarily the immediate parent. Messaging code that only addresses window.parent works in one embedding and silently does nothing in the other, so handle both the parent and the top window — and validate the origin of incoming messages rather than trusting anything that arrives.

Updates must land in the state SSR used. This is the part that decides whether live preview is reliable:

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 — its own store, or a component’s local copy — the page ends up with two sources of truth, and the symptoms are either hydration errors or, worse, a view that diverges from its data without complaining. The workable pattern is a client-only plugin that subscribes to preview messages and patches the cache the page’s data fetch populated.

Rich text is the one case worth handling specially, and the advice is to do less: treat the whole rich-text value as atomic and replace it wholesale when an update arrives, rather than attempting to patch nodes inside the tree. Re-rendering a rich-text field is cheap; a partial tree update that goes wrong is a rendering bug 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 which field it came from, and clicking it opens that field in the sidebar. That is powered by data attributes the SDK looks 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 reach the right elements. Doing it by hand in every component is both tedious and unreliable — the attribute is missing exactly where nobody thought about it — so a small wrapper component that takes the CMS model and the field name and emits the attributes is worth having from the start. Retrofitting this across a component library that already renders dozens of content types is a genuinely unpleasant task, and it is the part of preview integration most often deferred and most regretted.

Why Preview Alone Does Not Answer the Question

Here is where the two mechanisms meet, and why this article treats 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 appears in preview can be absent in production, and both results can be correct. Preview has shown the editor a true answer to a different question.

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

This is also a developer tool. The same panel is how you find out that a condition is being evaluated with a context field you thought was populated, which is a five-minute answer with the panel and an afternoon of logging without it.

The Missing Section Diagnosed

Back to the documentation banner. With both mechanisms in place, the editor’s question 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 interesting on its own. Being able to tell them apart in under a minute is the entire deliverable, and it is what makes editorial autonomy workable rather than a source of tickets that begin “the page is broken.”

The transferable rule: when you give someone else control over what renders, you owe them an explanation mechanism with the same reach as the control. A rule engine without a way to see its decisions moves the deployment bottleneck into a support conversation, which is not obviously an improvement.

What’s Next


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