DEV Community

Peter Hallander
Peter Hallander

Posted on

Per-country free shipping thresholds in Shopify — Markets API + Theme App Extensions

Most free shipping bars on the Shopify App Store assume one number. You set "$50 ships free" in the app settings, the bar appears, and that's the end of the configuration. Fine if you only sell in one country.

But if you've turned on Shopify Markets and you're shipping to the UK, the EU, Canada, Australia — that single global threshold is actively misleading. A UK shopper sees "$50 to free shipping" while their cart shows £35. A German shopper sees the dollar sign and quietly leaves. The bar that's supposed to nudge AOV up becomes a confidence-killer.

I ran into this exact problem on a store I worked on last year. Markets was configured for five regions, and the existing free shipping app — one of the popular ones with thousands of installs — was happily showing "$75 away from free shipping" to every shopper, regardless of which storefront they hit. The merchant didn't notice for weeks because she only ever tested on her US store.

So I built one that actually reads the Markets API and switches threshold per country. Here's how it works.

The shape of the problem

Shopify Markets lets a merchant configure separate currencies, prices, domains, and shipping zones per region. The public Storefront API exposes the active market via localization.country, and there's a markets query for the full list. What it doesn't give you is "the free shipping threshold for this market" — that's a property of the shipping rate config, which lives in the Admin and isn't exposed to the storefront.

That's a deliberate gap. Shopify's internal checkout knows about the rate; the storefront doesn't. So if you want to display a progress bar that says "you're £10 away from free shipping in the UK", you have two options:

  1. Have the merchant manually duplicate their shipping rate config into your app's settings, per market.
  2. Sync once via a private app + Admin API, then cache.

I went with option 1. Sounds tedious but it's actually less brittle — the Admin API exposes shipping rates differently across plans, and merchants on Basic vs Plus get different shapes back. Asking the merchant to type the number once into a metaobject is honest.

Storing thresholds as metaobjects

Metaobjects are the right primitive here. You define a shipping_threshold metaobject type with two fields — country_code (ISO alpha-2) and amount (money) — and the merchant adds one entry per market. They're queryable from Liquid in a Theme App Extension via shop.metaobjects, which means the data lives with the store, not in your app's database. If a merchant uninstalls the app and reinstalls a year later, the config is still there.

Here's what the app block schema looks like:

{% schema %}
{
  "name": "Free Shipping Bar",
  "target": "body",
  "settings": [
    {
      "type": "single_line_text_field",
      "id": "default_threshold",
      "label": "Default threshold (fallback)",
      "default": "50"
    },
    {
      "type": "single_line_text_field",
      "id": "default_currency",
      "label": "Default currency code",
      "default": "USD"
    },
    {
      "type": "single_line_text_field",
      "id": "message_progress",
      "label": "Progress message",
      "default": "Spend {{ remaining }} more for free shipping"
    },
    {
      "type": "single_line_text_field",
      "id": "message_unlocked",
      "label": "Unlocked message",
      "default": "You've got free shipping"
    },
    {
      "type": "color",
      "id": "bar_color",
      "label": "Bar color",
      "default": "#111111"
    },
    {
      "type": "select",
      "id": "display_mode",
      "label": "Display mode",
      "options": [
        { "value": "overlay", "label": "Overlay" },
        { "value": "push",    "label": "Push down content" }
      ],
      "default": "push"
    }
  ]
}
{% endschema %}
Enter fullscreen mode Exit fullscreen mode

The thresholds themselves come from a metaobject the merchant configures once in the Admin under Content → Metaobjects → Shipping Threshold. Reading them in Liquid:

{% liquid
  assign country = localization.country.iso_code
  assign matched_threshold = block.settings.default_threshold
  assign matched_currency  = block.settings.default_currency

  for entry in shop.metaobjects.shipping_threshold.values
    if entry.country_code.value == country
      assign matched_threshold = entry.amount.value | money_without_currency
      assign matched_currency  = entry.amount.currency
      break
    endif
  endfor

  capture thresholds_json
    echo '{'
    for entry in shop.metaobjects.shipping_threshold.values
      echo '"' | append: entry.country_code.value | append: '":'
      echo '{"amount":' | append: entry.amount.value | append: ','
      echo '"currency":"' | append: entry.amount.currency | append: '"}'
      unless forloop.last
        echo ','
      endunless
    endfor
    echo '}'
  endcapture
%}

<script>window.__freeShippingThresholds = {{ thresholds_json }};</script>

<free-shipping-bar
  data-threshold="{{ matched_threshold }}"
  data-currency="{{ matched_currency }}"
  data-country="{{ country }}"
  data-mode="{{ block.settings.display_mode }}"
  data-message-progress="{{ block.settings.message_progress | escape }}"
  data-message-unlocked="{{ block.settings.message_unlocked | escape }}"
></free-shipping-bar>
Enter fullscreen mode Exit fullscreen mode

The localization.country.iso_code is the line that does the work. It's populated by Shopify's edge based on the active market — same source the prices on the storefront use, so there's no inconsistency between what the cart shows and what the bar reads. If Markets isn't configured at all, it falls back to the shop's primary country, and the default threshold from the block settings kicks in.

The <script> tag at the bottom inlines the full per-country lookup table so the JS layer can re-resolve the threshold without a network round-trip if the shopper switches markets mid-session.

Detecting the country client-side too

Liquid runs once on render. The cart, on the other hand, mutates. If a shopper switches market mid-session — and on stores with country selectors they do — the Liquid-rendered country goes stale. So I read it again on the JS side.

There are three sources for the active country in the browser:

  1. The Shopify global, specifically Shopify.country and Shopify.currency.active. Set by Shopify's storefront runtime.
  2. The HTML element's lang attribute (locale, not country — careful).
  3. A cookie called cart_currency for the currency, no built-in cookie for country.

Of these, Shopify.country is the only one that's reliable across the Online Store 2.0 themes I've tested against. It's not officially documented as a public API, which is the usual Shopify reality — half the things that work are technically internal. I read it with a fallback to the data attribute that Liquid wrote.

class FreeShippingBar extends HTMLElement {
  constructor() {
    super();
    this.country   = this.detectCountry();
    this.currency  = this.detectCurrency();
    this.threshold = parseFloat(this.dataset.threshold);
    this.subtotal  = 0;
    this.debounceTimer = null;
  }

  detectCountry() {
    if (window.Shopify?.country) return window.Shopify.country;
    if (this.dataset.country)    return this.dataset.country;
    return 'US';
  }

  detectCurrency() {
    if (window.Shopify?.currency?.active) return window.Shopify.currency.active;
    return this.dataset.currency || 'USD';
  }

  connectedCallback() {
    this.fetchInitialCart();
    document.addEventListener('cart:updated', this.handleCartUpdate.bind(this));
    // Theme variants — none of these are standard, all are common in the wild
    document.addEventListener('cart:refresh', this.handleCartUpdate.bind(this));
    document.addEventListener('cart:change',  this.handleCartUpdate.bind(this));
  }

  async fetchInitialCart() {
    const res  = await fetch('/cart.js', { credentials: 'same-origin' });
    const cart = await res.json();
    this.subtotal = cart.total_price / 100;
    this.render();
  }
}

customElements.define('free-shipping-bar', FreeShippingBar);
Enter fullscreen mode Exit fullscreen mode

Two notes here. The fallback chain matters more than it looks — on a few legacy themes I tested, window.Shopify doesn't exist by the time the custom element initializes, so the data-country attribute that Liquid wrote is what saves you. And Shopify.currency.active returns a 3-letter ISO code, which is exactly what Intl.NumberFormat wants downstream, so no translation layer.

The real-time update

This is where most free shipping bars I've taken apart get sloppy. They listen for one event, re-render the whole component, and trigger a layout shift on every keystroke in a quantity input. On a slow Android device that compounds badly.

The pattern I landed on: debounce the event handler, hash the relevant cart state, and only re-render when something material actually changes.

handleCartUpdate(event) {
  clearTimeout(this.debounceTimer);
  this.debounceTimer = setTimeout(() => {
    this.refreshFromCart(event?.detail?.cart);
  }, 120);
}

async refreshFromCart(cartFromEvent) {
  let cart = cartFromEvent;
  if (!cart || typeof cart.total_price === 'undefined') {
    const res = await fetch('/cart.js', { credentials: 'same-origin' });
    cart = await res.json();
  }

  const newSubtotal = cart.total_price / 100;
  const newCountry  = this.detectCountry();

  // Bail if nothing material changed — avoids re-render storms
  if (newSubtotal === this.subtotal && newCountry === this.country) return;

  this.subtotal = newSubtotal;

  if (newCountry !== this.country) {
    this.country  = newCountry;
    this.currency = this.detectCurrency();
    this.threshold = this.lookupThreshold(newCountry);
  }

  this.render();
}

lookupThreshold(country) {
  // Inlined by Liquid above — no fetch needed
  const map = window.__freeShippingThresholds || {};
  return map[country]?.amount ?? parseFloat(this.dataset.threshold);
}

render() {
  const remaining = Math.max(0, this.threshold - this.subtotal);
  const progress  = Math.min(100, (this.subtotal / this.threshold) * 100);
  const unlocked  = remaining <= 0;

  const message = unlocked
    ? this.dataset.messageUnlocked
    : this.dataset.messageProgress.replace(
        '{{ remaining }}',
        this.formatMoney(remaining, this.currency)
      );

  this.innerHTML = `
    <div class="fs-bar" role="status" aria-live="polite">
      <div class="fs-bar__fill" style="width:${progress}%"></div>
      <div class="fs-bar__msg">${message}</div>
    </div>
  `;
}

formatMoney(amount, currency) {
  return new Intl.NumberFormat(undefined, {
    style: 'currency',
    currency,
    minimumFractionDigits: 0
  }).format(amount);
}
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out.

role="status" and aria-live="polite" matter because the bar updates asynchronously and screen readers should hear the new state without being interrupted mid-sentence. Most free shipping bars I audited had no ARIA at all.

The threshold map is read from window.__freeShippingThresholds — that's the JSON the Liquid layer inlined. You already paid for the metaobject lookup at render time, so don't pay for it again from JS.

The debounce on the event handler is something I tuned by hand. Too aggressive and you can still get double renders from theme code that fires cart:change twice in quick succession (looking at you, Dawn fork). Too lax and the bar feels laggy — you can see the visual delay between adding an item and the bar updating. The window I landed on felt right across the themes I tested.

The bail-out check at the top — if (newSubtotal === ... && newCountry === ...) — sounds obvious but the legacy bars I took apart re-render unconditionally. On a cart drawer that re-fires cart:updated every time a quantity field gets focus, that adds up.

Why Theme App Extension over a script tag

This part is going to feel preachy if you've been reading Shopify dev posts for the last two years, but a lot of free shipping bars on the App Store are still legacy script tag apps. They inject a <script src="https://cdn.theirapp.com/bar.js"> into theme.liquid via the ScriptTag API, the script does its own DOM injection, and the bundle is render-blocking by default.

Theme App Extensions replaced that pattern. The arguments are familiar:

  • The block lives in the merchant's theme editor. They drag it into the section they want, configure it visually, and remove it the same way. No support tickets that start with "I uninstalled the app but the bar is still there."
  • No theme.liquid modification. When the merchant updates their theme or switches to a new one, the app block follows the section it's attached to, not a hardcoded edit.
  • Liquid runs server-side at render. Country detection, threshold lookup, message rendering — all happen before the first byte ships, so the bar appears in the initial HTML. No flash, no CLS.
  • The JS is loaded as a separate asset and you can defer it. The bundle stays small.
  • App Store review actively flags new submissions that use the ScriptTag API for store-facing UI. It's not strictly deprecated yet but it's on the way.

The migration cost from a script tag app to a TAE is real — you rewrite the rendering layer in Liquid, you re-architect the settings as schema, you give up a chunk of runtime flexibility. The result is faster, cleaner, and survives theme updates.

What I learned shipping this

A few things came out of building and shipping this that I didn't expect.

The metaobject route is more discoverable than an admin UI inside the app. Merchants who'd already used metaobjects for product specs or recipe cards picked up the threshold config in seconds. The ones who'd never touched them needed a screenshot. I added a one-time setup helper inside the app's embedded admin that writes the metaobject definition for them — that cut the setup-related support emails noticeably, though I don't have a clean before/after number I'd publish.

The cart:updated event fragmentation across themes is worse than I assumed going in. Dawn fires it. Some popular paid themes don't fire anything at all on quantity changes — they re-render the cart drawer's HTML wholesale and rely on the next /cart.js fetch. For those, polling /cart.js on a short interval when the cart drawer is open is the only reliable signal. It's ugly. I wish Shopify would standardize this.

Push-down display mode wins almost always over overlay mode in the merchant feedback I've gathered. Overlay covers content above the fold and people complain about it. Push down respects the existing layout. Defaults matter — most merchants don't change the default display mode.

The localization.country fallback chain catches more shoppers than I expected. From talks I've had with merchants running international stores, a non-trivial slice of traffic hits the storefront before Shopify's edge has resolved a market — VPN users, edge cases on certain CDN configurations, shoppers landing via hreflang on the wrong subdomain. Having both a Liquid-side default and a JS-side fallback meant nobody saw a broken bar, even in those edge paths.

I run an NPS-style review prompt that routes unhappy merchants to email instead of straight to the public review form. This isn't a dark pattern — they can still leave a public review if they want — but it gives me a chance to fix the actual issue first. Most "I'd give this 2 stars" responses turn into "thanks, that fixed it" once I reply. From the merchants I've talked to, this matters way more for product quality than any in-app feature.

I made an app for this — AOV Free Shipping Bar Upsell — if you'd rather not roll your own Liquid + JS + metaobject plumbing, it's on the Shopify App Store. The free plan covers the progress bar, all three placements (site-wide embed, product page block, cart page block) and analytics. The Markets-aware per-country thresholds are on the paid tier, alongside multi-store analytics and weekly email reports. Happy to share more about the implementation if it's useful.

Top comments (0)