Shopify apps that offer both checkout-time discounts and storefront widgets face an architectural problem that is rarely discussed systematically: the Function runtime and the Theme Extension runtime live in two isolated data planes that can't directly talk to each other. Your discount rules exist in one world, your storefront widgets live in the other, and keeping them consistent is a surprisingly hard engineering problem.

We built Markdo with 18 Liquid template widgets that display discount information on storefronts — tier tables, flash sale countdowns, cart goals, spin-to-win wheels, and more. This post covers the data sync architecture, the i18n bridge pattern for Liquid-to-JavaScript translation, the XSS hardening journey, and the production incidents that shaped the design.

The Two Isolated Planes

Shopify Functions (WASM modules that run at checkout) read their configuration from $app: namespace metafields — a private namespace scoped to the app itself, invisible to any other app or to the storefront. Theme Extensions (Liquid templates that render on storefronts) can only read from the app namespace, which is the public metafield namespace exposed to the storefront context — specifically app.metafields.app_config.*.

This creates a fundamental problem:

  • Plane 1 (Checkout): The Discount Function reads rules from $app:markdo.discount_rules — a JSON metafield containing all rule configurations. This drives what discount actually applies at checkout.
  • Plane 2 (Storefront): The 18 Liquid widgets read from app.metafields.app_config.widget_bindings — a completely separate JSON metafield that contains widget configuration and display data.

If a merchant creates a "20% off orders over $100" rule and enables the Smart Discount Banner widget, the Function needs the rule in Plane 1 to apply the discount, and the widget needs the rule data in Plane 2 to display "20% OFF orders $100+". If Plane 2 is stale or missing, the widget shows nothing — or worse, shows wrong information.

The solution is a sync system that runs on every rule change: the backend reads the canonical rules from Plane 1, builds display-optimized snapshots, and writes them into Plane 2.

The RuleSnapshot Bridge

Every time a merchant creates, edits, or toggles a rule, the sync service runs syncRuleSnapshotsToWidgets(). This function reads all widget bindings from the app_config metafield, finds the matching rule for each binding, and generates a RuleDisplaySnapshot:

function buildRuleSnapshot(rule: ShopifyRuleConfig): RuleDisplaySnapshot { const snapshot: RuleDisplaySnapshot = { discountType: rule.discountType, discountValue: rule.discountValue, ruleName: rule.name, ruleActive: rule.isActive, ruleVersion: rule.revision, }; switch (rule.discountType) { case "volume": if (rule.discountTiers?.length) { snapshot.tiers = rule.discountTiers.map(t => ({ min: t.minQty, value: t.discount })); snapshot.minQuantity = rule.discountTiers[0].minQty; } break; case "spend_save": if (rule.spendTiers?.length) { snapshot.tiers = rule.spendTiers.map(t => ({ min: t.minAmount, value: t.discount })); snapshot.minPurchase = rule.spendTiers[0].minAmount; } break; case "mix_match": snapshot.mixMatchConfig = { minItems: rule.mixMatchConfig.minItems, discountType: rule.mixMatchConfig.discountType, discountValue: rule.mixMatchConfig.discountValue, }; break; case "bogo": case "advanced_bogo": snapshot.bogoConfig = { buyQty: rule.bogoBuyQty, getQty: rule.bogoGetQty, getDiscount: rule.bogoGetDiscount, }; break; } return snapshot; } 

The snapshot is a flattened, display-optimized projection of the rule. It doesn't contain the full rule configuration — just what the storefront needs to render. This matters because metafield values have size limits — most types are capped at 64KB, and JSON metafields at 128KB — and with 18 widgets each potentially bound to a rule, the data needs to be compact.

The Liquid template reads the snapshot through the binding:

{% assign bindings = app.metafields.app_config.widget_bindings.value %} {% assign binding = bindings['smart-discount-banner'] %} {% if binding and binding.enabled == true %} {% assign snap = binding.ruleSnapshot %} {% assign discount = snap.discountValue | default: 0 %} {% assign min_purchase = snap.minPurchase | default: 0 %} <div class="mk-banner" data-discount="{{ discount | escape }}" data-min="{{ min_purchase | escape }}"> {{ 'smart_discount_banner.qualify_pct' | t: value: discount | escape }} </div> {% endif %} 

Three Metafield Channels

The widget system actually uses three separate metafield channels, each serving a different purpose:

  1. widget_bindings — The primary channel. A JSON object keyed by widget handle (e.g., "spin-to-win", "tier-table"). Each entry contains enabled, settings (visual config like colors), ruleId, ruleActive, ruleVersion, and ruleSnapshot. Every widget reads from this.

  2. widget_tier_rules — A JSON array of full tier rule data for product-page widgets (tier-table, quantity-discount-badge). These widgets need the complete tier structure with product conditions, not just a snapshot.

  3. widget_mix_match — A JSON array of mix-and-match rule configs with eligible collection and product type data. The mix-match-picker widget needs this to filter which products qualify.

Why three channels instead of one? Size limits and access patterns. The bindings metafield is read on every page load by every widget — it needs to be small. The tier rules and mix-match configs are only read by specific widgets on specific pages, so they can afford to be larger.

One prerequisite worth mentioning: for Theme Extensions to read these metafields, the app must define them with storefront access set to PUBLIC_READ in the metafield definition (via the Admin API's MetafieldDefinition input). Without this, the app.metafields.app_config.* namespace is invisible to the storefront context even if the metafield exists.

The i18n Bridge: Liquid to JavaScript

All 18 widgets support 6 languages (English, German, French, Spanish, Japanese, Chinese). Shopify's | t filter handles Liquid-side translation, but JavaScript running in the browser can't call | t. The solution is an "i18n bridge" pattern that serializes all translated strings into a JSON data attribute at render time:

{%- assign t_spin_wheel = 'spin_to_win.spin_wheel' | t -%} {%- assign t_spin_now = 'spin_to_win.spin_now' | t -%} {%- assign t_spinning = 'spin_to_win.spinning' | t -%} {%- assign t_try_again = 'spin_to_win.try_again' | t -%} {%- assign t_you_won = 'spin_to_win.you_won' | t -%} {%- assign j_spin_wheel = t_spin_wheel | json -%} {%- assign j_spin_now = t_spin_now | json -%} {%- assign j_spinning = t_spinning | json -%} {%- assign j_try_again = t_try_again | json -%} {%- assign j_you_won = t_you_won | json -%} {%- assign i18n_json = '{"spinWheel":' | append: j_spin_wheel | append: ',"spinNow":' | append: j_spin_now | append: ',"spinning":' | append: j_spinning | append: ',"tryAgain":' | append: j_try_again | append: ',"youWon":' | append: j_you_won | append: '}' -%} <div class="mk-spin-fab" data-i18n="{{ i18n_json | escape }}"> 

The key detail is the two-phase approach. Phase 1 applies | json to each translation string individually via intermediate {% assign %} variables. This is critical because in Liquid's pipe chain, | json operates on the accumulated result — writing append: t_spin_wheel | json would JSON-encode the entire concatenated string {"spinWheel":<translation>, not just the translation. Phase 2 assembles the pre-encoded fragments into a valid JSON string, then applies | escape once to HTML-entity-encode the whole thing for safe embedding in an HTML attribute.

Then in JavaScript:

var fab = document.querySelector('.mk-spin-fab'); var t = JSON.parse(fab.dataset.i18n || '{}'); // Later: button.textContent = t.spinning; resultEl.textContent = t.youWon; 

The | json filter is critical here — it properly escapes strings for JSON embedding, handling quotes, unicode characters, and newlines. But it must be applied to each value before assembly, not during the append chain. The final | escape filter HTML-entity-encodes the complete JSON string, so a French translation containing an apostrophe ("l'offre") or double quotes won't break the attribute boundary. The browser decodes HTML entities before JavaScript reads dataset.i18n, so JSON.parse() receives clean JSON.

This pattern means zero server round-trips for translations. The Liquid template renders once with the customer's locale, bakes all translated strings into the DOM, and JavaScript reads them instantly.

The Currency Symbol Problem

Every widget that displays monetary amounts needs the currency symbol. Shopify does provide {{ localization.currency.symbol }} — the localization object is populated in multi-currency stores based on the buyer's selected currency, but in single-currency stores, or in some App Block rendering contexts, it may be unavailable or return an empty currency. You also can't use it in external JavaScript files where Liquid isn't evaluated.

We maintain a shared snippet (_currency-symbol.liquid) that maps 20 supported currencies as a reliable fallback. It reads localization.currency.iso_code when available, falling back to shop.currency (the store's default):

{% assign active_currency = localization.currency.iso_code | default: shop.currency %} {% case active_currency %} {% when 'USD' %}{% assign currency_symbol = '$' %} {% when 'EUR' %}{% assign currency_symbol = '€' %} {% when 'GBP' %}{% assign currency_symbol = '£' %} {% when 'JPY' %}{% assign currency_symbol = '¥' %} {% comment %} ... 16 more currencies ... {% endcomment %} {% endcase %} 

When localization is unavailable (single-currency store, or certain App Block rendering contexts), the | default: shop.currency fallback ensures we always resolve to something. If localization itself doesn't exist in the rendering context, Liquid treats localization.currency.iso_code as nil and the default filter kicks in — no error, just a silent fallback to shop.currency. The map then gives us a consistent symbol regardless of context.

Widgets originally attempted {% render '_currency-symbol' %} — but {% render %} runs in an isolated scope, so {% assign currency_symbol %} inside the snippet never reaches the parent template. The parent sees currency_symbol as nil. To make a shared snippet work, you'd need a return-style pattern where the snippet outputs the value and the parent captures it:

{% comment %} _currency-symbol.liquid — return-style {% endcomment %} {% assign active_currency = localization.currency.iso_code | default: shop.currency %} {% case active_currency %} {% when 'USD' %}{% assign currency_symbol = '$' %} {% when 'EUR' %}{% assign currency_symbol = '€' %} {% when 'GBP' %}{% assign currency_symbol = '£' %} {% when 'JPY' %}{% assign currency_symbol = '¥' %} {% comment %} ... 16 more currencies ... {% endcomment %} {% endcase %} {{- currency_symbol -}} 

The parent template wraps the render in {% capture %} and strips whitespace:

{% capture currency_symbol %}{% render '_currency-symbol' %}{% endcapture %} {% assign currency_symbol = currency_symbol | strip %} 

This works, but adds verbosity and the {% render %} output still introduces newlines that can break inline <script> blocks. Most of our widgets that need the symbol in Liquid templates use the {% capture %} pattern. The exit-intent.liquid widget ended up inlining the entire currency map to sidestep both problems:

{% comment %} Inline currency symbol — avoids whitespace from {% render %} breaking inline JS {% endcomment %} {% assign active_currency = localization.currency.iso_code | default: shop.currency %} {% case active_currency %}{% when 'EUR' %}{% assign curr_sym = '€' %}...{% endcase %} 

For widgets that use currency in external JavaScript files, the Liquid-side map can't help — the JS needs its own copy. We pass the active currency code via a data-currency attribute (rendered inside the {% if widget_visible %} block, so it only exists when the widget actually renders) so the JS knows which entry to look up:

<div class="mk-widget" data-currency="{{ active_currency | escape }}"> 
var _cm = {USD:'$',EUR:'€',GBP:'£',JPY:'¥',CNY:'¥',KRW:'₩',INR:'₹',BRL:'R$', CAD:'C$',AUD:'A$',CHF:'Fr.',SEK:'kr',NOK:'kr',DKK:'kr',PLN:'zł', MXN:'$',NZD:'NZ$',SGD:'S$',HKD:'HK$',TWD:'NT$'}; var el = document.querySelector('.mk-widget'); var sym = _cm[el.dataset.currency] || '$'; 

Three parallel implementations of the same currency map — return-style Liquid snippet (via {% capture %}), inline Liquid, and JavaScript — all manually kept in sync. It's not elegant, but it works across all three rendering contexts (Liquid template, inline script, external JS file).

XSS Hardening: 18 Templates, 6 Vulnerabilities

Our security audit found XSS vulnerabilities in 6 of the 18 Liquid templates. The pattern was consistent: user-controlled content (merchant-entered text in block settings) was being injected into the DOM via innerHTML without escaping.

The FOMO notification widget was the most severe case. It displayed customizable notification messages like "John from London just purchased!" — the Liquid template used {{ block.settings.message_text }} for the initial render, and JavaScript for dynamic updates:

<!-- Liquid output without explicit | escape filter --> <div class="fomo-text">{{ block.settings.message_text }}</div> 
// BEFORE (vulnerable) txEl.innerHTML = d.text; // d.text from merchant config 

Shopify's fork of Liquid includes built-in HTML auto-escaping at the template compilation level, so {{ block.settings.message_text }} does get escaped by default. But relying on implicit auto-escaping is fragile — content can be marked safe through various code paths, and the protection isn't always obvious to audit. The safer practice is to always apply an explicit | escape filter as defense-in-depth. The more immediate vulnerability in our case, though, was the JavaScript side: innerHTML interprets any HTML in the string, so a merchant config value containing <img src=x onerror=...> would execute regardless of what Liquid does server-side.

The fix was switching to textContent for all dynamic text updates:

// AFTER (safe) txEl.textContent = d.text; 

The exit-intent widget had a similar issue — it built HTML strings from merchant-configured discount codes and inserted them via innerHTML. The fix was to use DOM APIs (createElement, textContent) instead of string concatenation.

The tier-table widget builds HTML table rows from tier rule data. Since the data comes from parsed JSON (numeric minQty and discount values), the risk is lower — but we still switched to textContent for cell content and only use innerHTML for the static table structure.

The audit also led to extracting all inline JavaScript from Liquid templates into external .js asset files. This serves two purposes: it satisfies Content Security Policy (CSP) requirements that prohibit inline scripts, and it separates concerns — Liquid handles data binding, JavaScript handles interactivity.

Three widgets got their JS extracted: mix-match-picker.liquid, spin-to-win.liquid, and exit-intent.liquid. The pattern is consistent: Liquid renders the DOM structure with data-* attributes containing all necessary data, and the external JS file reads those attributes to initialize behavior.

The Binding Bootstrap: A Three-State Gate

Every one of the 18 widgets starts with an identical "binding bootstrap" — a Liquid preamble that reads the widget's configuration from the shared metafield:

{% assign bindings = app.metafields.app_config.widget_bindings.value %} {% assign widget_enabled = false %} {% assign widget_visible = false %} {% if bindings %} {% assign binding = bindings['flash-sale'] %} {% if binding %} {% if binding.enabled == true %} {% assign widget_enabled = true %} {% assign widget_visible = true %} {% if binding.planLocked == true %} ...
Source:https://dev.to/yuntongsoft/the-dual-plane-problem-syncing-discount-data-across-shopifys-two-isolated-worlds-4mgb