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 contextcontext.isPreview, context.integration, and (while an email is rendering) context.email.moduleKeys — and publishes values with {% set_global %}. Everything downstream sees the result as global.*.

The email-scoped facts follow from the ordering: moduleKeys is known before the script runs (it only depends on which blocks are in the email), 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 email exists at all — the Template 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 email: 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 template or module markup instead.

Module-level Only show if works differently: it controls whether a module is available in the Email 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 email 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 email; 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 only while an email is rendering — subject and preheader are null here because they render later (see stage 4); in the Template Editor there is no email 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 an email 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.