DEV Community

Cover image for Localizing a Shopify Customer Account Extension: 42 Languages, One Rails Server, and a Cache That Never Hit
hans
hans

Posted on

Localizing a Shopify Customer Account Extension: 42 Languages, One Rails Server, and a Cache That Never Hit

A customer in São Paulo opens their order status page. Shopify renders the whole page in Portuguese — and then our order editing widget shows up in the middle of it speaking English.

Worse: the merchant can't fix it. The copy is baked into the extension bundle at deploy time. "Cancel order" reads the same for a boutique in Berlin and a streetwear brand in Tokyo, in a language most of their customers don't shop in.

This is the story of how I built localization for PostCo's Order Editing app end-to-end — and the two edge cases that taught me more than the happy path did.

Context

PostCo Order Editing is a Shopify app that lets customers edit their own orders after checkout — add products, cancel the order, change the shipping address, switch payment methods — directly from the order status page. The customer-facing part is a Shopify customer account UI extension running in a sandboxed iframe; the backend and merchant admin are Rails 8 with Inertia and React.

Shopify UI extensions have built-in i18n: you bundle static JSON locale files and call shopify.i18n.translate(key). We shipped with English and French that way. It works, but the files are baked in at build time and identical for every shop:

  • Locale files are compiled into the extension bundle at deploy time.
  • Every shop receives the exact same strings.
  • Adding a language means a new release of the extension.
  • Merchants have no way to touch any of the copy.

The Problem

Two requirements converged.

Coverage. Merchants sell globally and Shopify localizes everything around our extension. We needed the extension to render in the customer's language — for the full set of locales Shopify supports, not two.

Customization. Merchants wanted to change the copy. Tone of voice, legal phrasing, a CTA that matches their theme. Bundled JSON gives them nothing — every shop gets identical strings.

Bundled locale files solve neither. Any solution had to put the copy somewhere our server controls, per shop, per language.

The Solution

A new server-owned translation system that:

  1. Makes Rails own the catalog — one YAML file per locale under config/order_editing_locales/, 42 locales, 378 keys each, with en.yml as the source of truth.
  2. Stores merchant overrides as sparse rows in a shop_translations table. Defaults are never copied into the database.
  3. Adds a Localization page in the admin where merchants edit any string in any language, plus the existing Customize tab writing to the same rows.
  4. Has the extension fetch resolved translations from Rails at runtime — GET /shopify_api/translations?locale=fr — falling back to its bundled copy if the request fails.
  5. Layers three small caches to make the runtime fetch cheap, because two Shopify platform constraints kill the standard playbook (more on this below).

The deliberate trade: we chose server-delivered translations over publishing to Shopify metafields. Metafields would give zero-network reads, but every merchant edit would need a background publish job, every new key a backfill across all shops, and the admin "save and preview" flow would inherit eventual-consistency bugs. Serving from Rails means one source of truth and instantly visible edits — at the cost of a network request we then have to make cheap.

Implementation

The catalog and locale resolution

YAML is nested for maintainability, but the extension consumes flat dotted keys (matching what shopify.i18n.translate already used), so the loader flattens on read:

def flatten(value, prefix = nil)
  case value
  when Hash
    value.each_with_object({}) do |(key, child), translations|
      flattened_key = [prefix, key].compact.join(".")
      translations.merge!(flatten(child, flattened_key))
    end
  else
    { prefix => value.to_s }
  end
end
Enter fullscreen mode Exit fullscreen mode

The loader also owns locale resolution, because what browsers report and what catalogs exist are different vocabularies:

LOCALE_ALIASES = { "pt" => "pt-BR", "zh" => "zh-CN" }.freeze

def resolve_locale(locale)
  return DEFAULT_LOCALE if locale.blank?
  return locale if supported?(locale)
  return LOCALE_ALIASES[locale] if LOCALE_ALIASES.key?(locale)

  base = locale.split("-", 2).first   # "fr-FR" -> "fr"
  return base if supported?(base)

  DEFAULT_LOCALE
end
Enter fullscreen mode Exit fullscreen mode

A CI test asserts every one of the 42 catalogs has exactly the same flattened key set as en.yml, every value is non-blank, and every {{interpolation}} placeholder in English survives in the translation. That parity test turns "did I remember to update all 42 files?" from a code-review prayer into a red build.

Sparse overrides

The shop_translations table has a unique index on (shop_id, locale, key) and one rule that shapes everything: defaults never get copied into the database. A row exists only when a merchant changed something. Clearing a field deletes the row:

if value.to_s.blank?
  scope.delete_all
else
  ShopTranslation.upsert(
    { shop_id: shop.id, locale: locale, key: key, value: value },
    unique_by: %i[shop_id locale key]
  )
end
Enter fullscreen mode Exit fullscreen mode

This keeps the table tiny (most shops have zero rows), means updated default copy ships to everyone on deploy with no backfill, and gives the admin UI a free "Default vs. Customized" badge — a row's existence is the customization state.

The merge is then one expression:

translations = overrides.empty? ? defaults : defaults.merge(overrides)
Enter fullscreen mode Exit fullscreen mode

Caching against the grain of the platform

Translations are read-heavy, so the instinct is "put it behind a CDN." Two Shopify constraints break that:

  • Session tokens rotate every ~5 minutes and travel in the Authorization header. HTTP caches key on the request — a rotated token is a new cache key, so browser Cache-Control and CDN caching are guaranteed misses. Cross-customer hit rate is zero by construction (every customer has a unique token).
  • The extension sandbox has no localStorage and no service workers. The only client-side persistence is JavaScript module scope — survives component re-mounts, dies on full page reload.

So instead of one big cache, three small layers.

Layer 1 — process memoization. YAML files are static between deploys, so the loader memoizes the parsed-and-flattened result in a class-level hash. One subtlety: the result is a Data.define struct, and Data freezes the struct but not the hash inside it. Without an explicit .freeze on the translations hash, any caller could mutate the shared cached copy for every subsequent request in that process.

Layer 2 — Redis for the merged payload. With YAML in memory, the expensive repeated work is the Postgres query confirming "this shop has no overrides" — which is the answer for most shops on most requests. We cache the merged result per translations:merged:{shop_id}:{locale} with a 1-hour TTL. The key uses the resolved locale, so pt, pt-anything, and pt-BR all share one entry instead of proliferating keys.

Layer 3 — client-side skip. The extension keeps the translation map in module scope and records which locale it loaded. On re-mount (customers bounce between order pages more than you'd think), it skips the fetch entirely:

const translationsPromise = isLocaleLoaded(locale)
  ? Promise.resolve(null)
  : fetchTranslations({ backendUrl, locale, sessionToken }).catch(() => null);
Enter fullscreen mode Exit fullscreen mode

The .catch(() => null) matters. Translations load in a Promise.all alongside the order-actions request, and a translations failure must never block the whole block from rendering — the extension degrades to its bundled shopify.i18n.translate copy instead.

Invalidation: the callback that would never fire

My first instinct for cache invalidation was an after_commit callback on the model. It would have compiled, looked correct in review, and never fired once — because both write paths use upsert and delete_all, which bypass ActiveRecord callbacks entirely.

Worse than not working, a callback would actively mislead the next developer into assuming invalidation is automatic. So each write site calls the bust explicitly after its transaction commits:

Translations::MergeLocaleService.bust_cache(current_shop, locale)
Enter fullscreen mode Exit fullscreen mode

Less magic, impossible to misread. The merchant who saves a translation and immediately previews the extension sees the new copy.

The cache that never hit

After shipping Layer 3, everything worked — copy rendered correctly in every language. But for some locales, the client-side cache never hit. The extension re-fetched translations on every single mount, and nothing in the UI looked wrong.

The cause: the client and server speak different locale dialects. Shopify reports the customer's language as, say, pt. The server resolves that to pt-BR and returns the resolved locale in the payload. My skip-check stored the server's value:

// before: stores "pt-BR"
markLocaleAsFetched(response.locale);

// next mount: client asks about "pt"
isLocaleLoaded("pt")  // "pt" !== "pt-BR" → miss, forever
Enter fullscreen mode Exit fullscreen mode

For any aliased locale, the cache could never hit. And because a miss just means "do the fetch," the failure was invisible: correct output, wasted requests.

The fix clarified something I'd been conflating — there are two locale values with two different jobs. translationsLoadedForLocale must store the client-side locale, because its job is cache identity: "did I already fetch for what the client is asking?" A separate serverLocale stores the server-resolved locale, because its job is plural selection. Which brings me to —

Pluralization doesn't come for free anymore

shopify.i18n.translate handles plurals automatically from the bundled JSON structure. The moment you serve a flat key-value map from your own server, you've opted out of that, and nobody warns you. "1 Day" / "3 Days" quietly breaks.

The catalog stores plural forms as separate keys (time.days.one, time.days.other), and the client helper reimplements selection with the platform primitive:

if ("count" in params) {
  const form = new Intl.PluralRules(serverLocale).select(params.count);
  const pluralRaw = serverTranslations[`${key}.${form}`];
  if (typeof pluralRaw === "string") return interpolate(pluralRaw, params);
}
Enter fullscreen mode Exit fullscreen mode

Note serverLocale — the resolved one. Intl.PluralRules needs to know it's doing Brazilian Portuguese plural rules regardless of what the browser reported. The same normalization that broke my cache is load-bearing for plurals.

Decisions worth calling out

A few choices that punched above their weight:

  • Two-phase rollout for the contract change. The extension previously read its labels from the order-actions endpoint. PR one made the extension read from the new translations endpoint while the old endpoint kept shipping the copy nobody read — dead payload, instant rollback if anything broke. Only after that baked in production did PR two strip the copy server-side.
  • One deprecated field survived the cleanup. The extension and backend deploy independently, so an old extension version can hit a new backend. We kept label in the order-actions payload as a fallback for stale clients; removing it is its own ticket, not a rider on this one.
  • Interpolation validation at write time. If the English source contains {{count}}, a merchant's override must too — otherwise the save is rejected with the missing placeholders listed. Catching this at write time beats a customer seeing a literal {{count}} on their order page.
  • The Customize tab and Localization page share rows via one mapping module. A single KeyMapping constant is the source of truth for "this Customize-tab field is that translation key." Edit a label in one surface, the other shows it customized. No sync logic, because there's nothing to sync.
  • The admin editor classifies rows as short, long, or plural and renders the right input for each — plus highlights {{interpolation}} tokens as non-editable segments, so merchants can't accidentally translate a variable name.

What's next

  • The three cache layers are holding, but the escape hatches are documented if load outgrows them: a public no-auth URL for shops with zero overrides (CDN-cacheable), or metafield delivery as the nuclear option.
  • Generating the extension's bundled fallback JSON from the server YAML in CI, so the two can't drift.
  • Removing the deprecated label field once old extension versions age out.

The feature is live across 42 locales — 378 keys each, roughly 15,800 translated strings — with merchants able to override any one of them per language.


Thanks for reading. If you've built on Shopify customer account extensions: how did you handle the rotating session tokens? The "your auth design makes HTTP caching impossible" constraint felt like the most underdocumented part of the platform, and I'd love to hear how others worked around it.

Top comments (0)