Skip to main content

Rendering pipeline

Every preview and every export runs the same pipeline. Knowing its order explains almost everything that otherwise feels arbitrary about Liquid in Better Email: why the Global Script can't read the subject line, why Only show if can't see context.email, and why an {% assign %} from one block never shows up in another.

The stages​

1. Global Script​

The Global Script runs before anything else. It reads the raw Global inputs and the render context — context.isPreview, context.integration, and (while a campaign is rendering) context.email.moduleKeys and context.email.language — and publishes values with {% set_global %}. Everything downstream sees the result as global.*.

The campaign-scoped facts follow from the ordering: moduleKeys and language are known before the script runs (they only depend on the campaign itself, not on anything rendered), but context.email.subject and context.email.preheader are null here — they are rendered using the script's output, so giving the script access to them would be circular. context.block is always null: the script runs once, not per block. And where no campaign exists at all — the Design System Editor's calculated-values panel and previews — context.email is null entirely, so guard with {% if context.email %}.

2. Visibility conditions​

Only show if conditions on settings and inputs are evaluated next, and the values of hidden settings/inputs are dropped before rendering — a hidden input never leaks into the output, even if it still holds a value. Conditions can reference setting values, template.*, global.*, and context.integration.id.

The rest of the context is deliberately not available in conditions — on settings, inputs, and modules alike — and conditions that reference it are rejected when you save. What's visible must agree between the editor UI and every render of the same campaign: preview and export differ on context.isPreview by definition, and context.integration.id is the only integration field editor-side checks can reconstruct. Use the fuller context inside design system or module markup instead.

Module-level Only show if works differently: it controls whether a module is available in the Campaign Editor's module list, not whether an already-added module renders. See conditional module visibility.

3. Dynamic authoring values​

Input authoring fields in FX mode — hints, placeholders, select option names and values, color option values — are resolved now, with the same scope as visibility conditions plus the module's own setting values.

4. Subject line and preheader​

The subject renders first, then the preheader, then the campaign body. Each step can reference the finished values of the ones before it: the preheader can use the rendered subject as {{ context.email.subject }}, and the body sees both rendered. Only true self-reference sees raw text — inside the subject, context.email.subject is the saved source, because a value cannot reference its own result.

5. Template base​

The template base renders with everything except context.block — there is no "current block" at this point.

6. Blocks​

A block is one instance of a module placed in the campaign; the same module can appear as several blocks. Each block renders in isolation — independently of every other block, possibly in parallel: Better Email combines the template base and the block's module code into one document and renders them together. Two consequences:

  • {% assign %} variables set in the template base (before the content zone) are visible inside every block.
  • {% assign %} variables set in one block are not visible in any other block — that isolation is what keeps modules reusable.

Module code is the only place where context.block is populated: id, moduleKey, and the neighboring blocks as previous/next ({ id, moduleKey }, resolved within the block's content zone, null at the edges).

If one block needs a value another block also needs, don't try to pass it between them — compute it once in the Global Script and read it from global.*.

7. Post-processing​

After Liquid, Better Email applies segmentation variants, replaces merge tags with the connected integration's personalization syntax, and applies any ESP-specific transformations (for example AMPscript handling for Salesforce Marketing Cloud). Merge tags are untouched by Liquid — they pass through and are substituted here.

What is available where​

The full matrix, one row per place you can write Liquid:

SurfaceOwn setting valuestemplate.*global.*context.isPreview context.integrationcontext.emailcontext.block
Global Script— ¹—✓ (raw inputs)✓✓ ⁴—
Only show if conditions✓✓✓✓ ⁵——
FX-mode authoring fields✓✓✓✓✓ ⁴—
Subject line & preheader—✓✓✓✓ ²—
Template base— ³✓✓✓✓—
Module code✓✓✓✓✓✓

¹ The Global Script reads Global inputs as global.*; it has no other settings of its own.

² Raw saved text, not the rendered result — see stage 4.

³ The template base reaches its own settings through template.*.

⁴ Only moduleKeys and language, and only while a campaign is rendering — subject and preheader are null here because they render later (see stage 4); in the Design System Editor there is no campaign and context.email is null.

⁵ context.integration.id only — the rest of the context is not available in conditions, and conditions referencing it are rejected at save time (see stage 2).

The pattern behind the matrix: global.*, context.isPreview, and context.integration exist everywhere; context.email exists once a campaign is being rendered; context.block exists only while a block renders, inside module code. Setting values exist wherever the setting itself is in scope.

Rules of thumb​

  • Compute once, read everywhere. Anything needed by more than one module belongs in the Global Script, not copy-pasted per module.
  • Branch on context.integration.id for integration-dependent behavior — it works in every surface, including the Global Script and Only show if.
  • Don't fight block isolation. If you're trying to smuggle state between blocks with assign, the design wants a global.* value instead.
  • Remember the preview runs the same pipeline. context.isPreview is the only difference between preview and export — keep preview branches visually representative.