Multilingual Ecommerce: Modelling Locale, Currency and Regional Catalogue Variance
A shopper in Munich lands on your product page and sees a price in dollars, a shipping estimate in miles, and a "Buy now" button that fails because the SKU is not stocked in the EU warehouse. That is not one bug.
A shopper in Munich lands on your product page and sees a price in dollars, a shipping estimate in miles, and a "Buy now" button that fails because the SKU is not stocked in the EU warehouse. That is not one bug. It is four modelling decisions colliding: language, currency, regional availability, and legally required content variance, all crammed into a single flat document that assumed one market.
Most teams discover this the hard way, after the German launch, when a field-by-field translation hack meets a catalogue that differs by region. Copying documents per locale doubles the editorial surface and drifts within a sprint. Nesting every locale inside every field turns queries into archaeology.
This guide treats locale, currency, and regional catalogue variance as three distinct axes that should be modelled separately, then composed at query time. Get the axes right and a new market becomes a configuration change, not a migration. Get them wrong and every launch is a rebuild.
Why does per-locale document duplication break at the second market?
Per-locale document duplication breaks because it conflates translation with catalogue structure, and those two things scale differently. When you clone an entire product document for each locale, a five-locale catalogue of 2,000 products becomes 10,000 documents, and every structural change (a new field, a renamed reference, a schema migration) has to land in all ten thousand or you get silent drift between markets.
The failure mode is subtle at first. Launch English, clone for German, and everything looks clean. Then a merchandiser fixes a spec on the English product and forgets the German clone. Now the same SKU shows two different weights across markets, and there is no structural link telling anyone the documents are meant to be the same product. Translation memory cannot help because the tool sees two unrelated documents, not one product with two language fields.
The cleaner model separates the axis that varies by language (marketing copy, descriptions, SEO metadata) from the axis that is intrinsic to the product (dimensions, material, base SKU) from the axis that varies by region (price, availability, compliance copy). In Sanity, this is field-level and document-level modelling working together: an internationalized array of localized fields for the translatable copy, plain shared fields for the intrinsic attributes, and referenced region documents for the regional variance. One product, one canonical record, language and region composed on top. A schema change lands once, not once per market.
Duplication feels faster on day one because you can see every locale as a whole page. It costs you on every day after, because consistency is now a manual discipline rather than a structural guarantee.
How should you model locale versus currency versus regional catalogue variance?
Locale, currency, and regional catalogue variance are three independent axes, and the modelling mistake is treating them as one. Locale is a language plus formatting concern (de-DE renders copy in German and dates as DD.MM.YYYY). Currency is a pricing and settlement concern (EUR with two decimals, VAT-inclusive display). Regional catalogue variance is an availability and compliance concern (this SKU ships to the EU, that ingredient warning is legally required in California). A shopper in Austria and a shopper in Germany share a locale but may differ on currency handling and shipping region.
When you collapse them, you get combinatorial explosion: a variant for every language times currency times region, most of which are meaningless. Model them separately and you compose only the combinations that exist. Language lives in localized fields on the product. Region lives in its own document type that owns price, availability windows, tax treatment, and any market-specific legal copy. The product references the regions it is sold in.
In Sanity, GROQ does the composition at read time. A single query projects the product's shared attributes, pulls the caller's locale out of the internationalized array with a projection, and dereferences the matching region document with the `->` operator, all in one round trip. You ask for exactly the shape the storefront needs (`"title": title[$lang], "price": region->price`) rather than fetching a fat document and filtering on the frontend. The Content Lake resolves the references, so the storefront receives a single locale-and-region-resolved payload instead of assembling it client-side.
The payoff is that adding a currency, a language, or a market becomes an additive change to one axis, not a new product variant multiplied across every other axis.
Should currency be a display format or a first-class pricing model?
Currency should be a first-class pricing model, not a display format, because exchange-rate conversion at render time produces prices you cannot legally or commercially stand behind. If you store one USD price and multiply by a live rate to show EUR, you get psychologically wrong prices (19.37 EUR instead of a merchandised 19.99 EUR), rounding that drifts across a catalogue, and VAT-inclusive display that is either wrong or bolted on in the frontend. In many EU markets, showing a tax-inclusive price is a legal requirement, not a preference.
The correct model stores an explicit price per region and currency, set by a merchandiser, alongside the tax treatment for that market. The base product carries no price at all; price is a property of the region document. This lets a merchandiser price a product at 19.99 USD in North America and 21.99 EUR in the EU without either number being derived from the other. Promotional pricing, tiered pricing, and market-specific discounts all attach to the region, where they belong.
In Sanity, you model the region price as a structured object (amount, currency code, tax-inclusive boolean) inside the region document, and a GROQ query returns the resolved figure for the caller's market directly. Because prices are real stored values rather than computed ones, Content Releases lets you stage a coordinated price change across regions and publish it atomically at a scheduled time, so a Black Friday price and its regional variants go live together rather than trickling out as caches expire.
Store the price you actually charge. Compute nothing that a customer will see on an invoice. Currency is a modelling decision that lives in your content, not a formatting decision that lives in your view layer.
How do you handle catalogue variance where a SKU exists in one region but not another?
You handle regional catalogue variance by making availability a property of the region-to-product relationship rather than a flag buried on the product. The naive approach adds a boolean like `availableInEU` for every market, which does not scale past a handful of regions and gives you nowhere to hang the details availability actually needs: a stocking warehouse, a launch date, an end-of-life date, or a compliance note that only applies in that market.
The robust model treats each region as owning a list of the products it sells, or each product as referencing the region documents where it is available, with the availability metadata living on that reference. A discontinued SKU is removed from a region without touching the canonical product. A product that launches in Japan three months after the US launch carries a future availability date on the Japanese region only. Compliance copy (an ingredient disclosure, a warranty term, an age restriction) attaches to the same region reference, so the legal variance travels with the availability variance instead of being a separate afterthought.
In Sanity, GROQ filters do the regional gating in the query. A storefront asks for products available in a given region with a filter like `*[_type == "product" && $region in regions[]->code]`, and the same query projects the region-specific price and compliance copy in the projection. A shopper never receives a SKU they cannot buy, because the availability filter runs in the Content Lake before the payload is assembled, not on the client after a full catalogue has already crossed the wire.
Availability is a relationship with attributes, not a boolean. Model it that way and a market launch, a discontinuation, or a legally mandated regional warning is a data change on one edge of the graph, not a schema migration across the whole catalogue.
How do you keep translation workflows from drifting out of sync with the source?
You keep translations in sync by anchoring every localized field to a single source document and making the translation state visible, rather than forking documents that lose their connection to the original. The drift problem is structural: once a German product is a separate document from its English source, nothing tells an editor that an English copy change has invalidated the German translation. The German market ships stale content and no one notices until a customer does.
Field-level localization solves this by keeping all languages on the same document. The English and German descriptions are two entries in the same internationalized array, so an editor looking at the product sees every locale in one place and can tell at a glance which languages are filled and which are empty. When the source language changes, the stale translations are visibly attached to the same record, not orphaned in a parallel document tree.
In Sanity, the internationalized array pattern plus the Studio's customisable editor means a merchandiser can see all locales side by side, and a translation status field on each locale entry can drive a Structure Builder view that lists exactly which products are missing a German description. App SDK and Functions let you automate the mechanical steps: a Function can fire when the source description changes and flag the dependent locales as needing review, or route the field to a translation service and write the result back as a draft for human approval. Because the automation reads and writes the same structured fields the editor uses, the machine step and the human step operate on one source of truth rather than two diverging copies.
The governing principle: one canonical document, many locales as fields, translation state as data. Sync stops being a manual reconciliation job and becomes a queryable property of the content.
What does a locale-and-region-aware query actually return in one request?
A locale-and-region-aware query returns a fully resolved storefront payload, copy in the caller's language, price and availability for the caller's region, and market-specific compliance content, in a single round trip, so the frontend does no assembly. This matters because the alternative (fetch the product, fetch the translations, fetch the regional price, fetch availability, then stitch) is four requests and a pile of client-side logic that every storefront reimplements and every one gets subtly wrong.
GROQ is designed for exactly this shape of read. In one query you filter to products available in the region, dereference the region document for price and tax treatment, pull the correct locale out of the internationalized array by parameter, and project only the fields the page renders. The result is the shape of the page, not the shape of the database. Because GROQ resolves references server-side in the Content Lake, the storefront receives `{ title, price, currency, taxInclusive, availability, complianceCopy }` already localized and regionalized, rather than a generic document it has to interpret.
Contrast this with a GraphQL-first stack, where composing across the product, its translations, and a regional price entity often means multiple typed queries or a bespoke resolver layer, and where asking for a projection of exactly the fields you need is not the default. GROQ's `->` dereferencing, parameterized locale selection, and arbitrary projections put the composition in the query language itself. TypeGen then generates TypeScript types from those queries, so the storefront gets a typed, locale-and-region-shaped response and a compile-time guarantee that the fields it reads exist.
One request, one payload, shaped like the page. The multilingual, multi-currency, multi-region complexity is resolved in the query, not smeared across the frontend.