The Multi-Currency Challenge
Shopify Markets makes selling in multiple currencies straightforward at the admin level, define your markets, assign currencies, and let Shopify handle the conversion. But for developers building custom themes, apps, or headless storefronts, multi-currency introduces a class of problems that aren't obvious until they surface in production.
The core issue: prices rendered in Liquid work perfectly because Shopify's | money filter handles formatting, symbol placement, and locale-specific separators server-side. Prices calculated or formatted in JavaScript don't have this luxury. The gap between Liquid's server-side formatting and JavaScript's client-side rendering is where most multi-currency bugs live, and it's exactly the gap that shopify-money-format-utils was built to close.
Shopify Markets Basics
Shopify Markets lets you define geographic markets, US, EU, Asia-Pacific, and so on, each with its own currency, pricing strategy, and domain or subfolder routing. When a customer visits your store, Shopify detects their location via IP geolocation and presents prices in the corresponding market's currency. You configure whether prices are auto-converted from your base currency using daily exchange rates (with an optional percentage adjustment to protect margins) or manually set per product for tighter control.
Automatic conversion produces prices that look unpolished. A product priced at $29.99 USD might convert to €28.37 EUR, which feels arbitrary to the customer. Shopify counters this with rounding rules that snap converted prices to psychologically appealing numbers like .99 or .95. In Liquid, the | money filter handles all of this formatting automatically, correct symbol, correct separators, correct position. But JavaScript running on the client has no built-in equivalent, and that's where the problems begin.
The Seven Common Multi-Currency Problems
These are the issues that catch developers in production. Each one seems minor in isolation, but together they create a fragmented pricing experience that undermines customer trust.
Problem 1: Price format varies by locale. $1,134.65 (US) vs €1.134,65 (Germany) vs CHF 1'134.65 (Switzerland) vs 1 134,65 € (France). Four different thousand separators, two different decimal separators, different symbol positions. If your JavaScript uses toFixed(2) and inserts commas for thousands, it produces valid US formatting but wrong output for every other locale.
Problem 2: Currency symbol position. USD puts $ before the amount. EUR in French locales puts € after. Some currencies use the ISO code instead of a symbol, SEK, NOK, and DKK display as "kr" rather than a single glyph. Your JavaScript must respect the store's money_format setting to determine whether the symbol goes at the start or end of the formatted string.
Problem 3: Rounding rule mismatches. Shopify applies rounding rules to converted prices, round to .99, .95, or the nearest whole number. If your JavaScript calculates a price independently using exchange rates, it won't match the Shopify-rounded price. The customer sees €28.99 on the product page and €28.37 in your app's widget. This kind of inconsistency erodes trust immediately.
Problem 4: Parsing formatted prices back to numbers. You have the string "€1.134,65" on screen and need the number 1134.65 for a calculation. JavaScript's parseFloat("1.134,65") returns 1.134, it stops at the comma because it doesn't recognize commas as decimal separators. You need a locale-aware parser that understands which character is the thousand separator and which is the decimal, based on the format type.
Problem 5: Third-party app price displays. Many apps were built before Shopify Markets existed and still reference prices in the store's base currency. Review apps, loyalty widgets, upsell modals, and bundling apps can all show incorrect amounts if they haven't been updated. Before launching a multi-currency store, audit every installed app by testing with a VPN set to a non-default market and verifying that all prices render correctly.
Problem 6: Cart API returns prices in cents. When you fetch /cart.js, prices come back as integers representing the smallest currency unit, 2999 for a $29.99 item. You need to divide by 100, then format with the correct decimal separator, thousand separator, and symbol placement for the customer's locale. Getting any of these steps wrong produces a price that looks broken.
Problem 7: Dynamic price calculations in JavaScript. Subscription widgets, quantity discount tables, bundle builders, and discount code previewers, any JavaScript that calculates and displays a price needs to format it correctly. String concatenation like '$' + price.toFixed(2) produces valid output for USD, but fails for every other currency and locale combination.
The Shopify Money Format System
Every Shopify store has a money_format setting, a template string like ${{amount}} for US stores or €{{amount_with_comma_separator}} for German stores. The placeholder inside the double curly braces tells you which formatting style the store expects. There are eight format types, each defining the thousand separator, decimal separator, and whether to include decimal places:
| Format | Example Output | Locale |
|---|---|---|
amount |
1,134.65 | en-US |
amount_no_decimals |
1,135 | en-US |
amount_with_comma_separator |
1.134,65 | de-DE |
amount_no_decimals_with_comma_separator |
1.135 | de-DE |
amount_with_apostrophe_separator |
1'134.65 | de-CH |
amount_no_decimals_with_space_separator |
1 135 | fr-FR |
amount_with_space_separator |
1 134,65 | fr-FR |
amount_with_period_and_space_separator |
1 134.65 | Custom |
Each format defines three things: the thousand separator character, the decimal separator character, and whether to include decimal places. The currency symbol and its position are controlled separately by the template string wrapping the placeholder. Understanding this system is the key to formatting money correctly in JavaScript, and it's exactly what shopify-money-format-utils implements.
Introducing shopify-money-format-utils
Open source: shopify-money-format-utils on npm, zero dependencies, MIT licensed, works in browser and Node. Source on GitHub.
shopify-money-format-utils is a lightweight JavaScript library that formats and parses money values exactly like Shopify Liquid's | money filters. It supports all eight format types, handles symbol placement via a position option, and includes a parseMoney() function that correctly reverses formatted strings back to numbers. It was built specifically to close the gap between Liquid's server-side formatting and JavaScript's client-side rendering.
npm install shopify-money-format-utils
import { formatMoney, parseMoney } from 'shopify-money-format-utils';
// US format
formatMoney(1134.65, 'amount');
// → "1,134.65"
// German format with Euro symbol after
formatMoney(1134.65, 'amount_with_comma_separator', { symbol: '€', position: 'end' });
// → "1.134,65€"
// French format with space separator
formatMoney(1134.65, 'amount_with_space_separator', { symbol: '€', position: 'end' });
// → "1 134,65€"
// Swiss format
formatMoney(1134.65, 'amount_with_apostrophe_separator', { symbol: 'CHF ', position: 'start' });
// → "CHF 1'134.65"
// Parse formatted string back to number
parseMoney('1.134,65', 'amount_with_comma_separator');
// → 1134.65
// Parse with currency symbol, symbol is stripped automatically
parseMoney('€1.134,65', 'amount_with_comma_separator');
// → 1134.65
Theme Integration Pattern
The bridge between Liquid and JavaScript is a small snippet in your theme.liquid layout that exposes two pieces of information: the store's money format type and the current customer's currency symbol.
<script>
if (!window.$shopify) { window.$shopify = {}; }
window.$shopify.moneyFormat = String({{ shop.money_format | json }}).match(/\{\{\s*([^}]+?)\s*\}\}/)[1];
window.$shopify.currencySymbol = `{{ cart.currency.symbol }}`;
</script>
shop.money_format returns the store's format template, something like ${{amount}} or €{{amount_with_comma_separator}}. The regex extracts just the format type from inside the curly braces (e.g., amount, amount_with_comma_separator). cart.currency.symbol gives the currency symbol for the customer's current presentment currency.
With those values available globally, any JavaScript module can format prices correctly:
import { formatMoney } from 'shopify-money-format-utils';
function displayPrice(cents) {
const amount = cents / 100;
const format = window.$shopify.moneyFormat;
const symbol = window.$shopify.currencySymbol;
return formatMoney(amount, format, { symbol, position: 'start' });
}
This single function replaces all the ad-hoc string concatenation and toFixed() calls scattered across your theme's JavaScript. Every price rendered through displayPrice() will match the Liquid-rendered prices on the same page.
Practical Patterns for Common Scenarios
Subscription widget pricing. When calculating subscribe-and-save discounts in JavaScript, the discount math is straightforward, the formatting is where stores get it wrong. Apply the discount, then let the library handle the locale-specific output:
import { formatMoney } from 'shopify-money-format-utils';
const format = window.$shopify.moneyFormat;
const symbol = window.$shopify.currencySymbol;
const subscriptionPrice = originalPrice * 0.85;
const formatted = formatMoney(subscriptionPrice, format, { symbol });
priceElement.textContent = formatted;
Quantity break tables. Dynamic tables showing "Buy 5+ at $X each" need every cell formatted for the customer's locale. The discount tiers are business logic; the formatting should be delegated entirely:
import { formatMoney } from 'shopify-money-format-utils';
const tiers = [
{ qty: 1, discount: 0 },
{ qty: 5, discount: 0.10 },
{ qty: 10, discount: 0.20 },
];
tiers.forEach(tier => {
const price = basePrice * (1 - tier.discount);
const cell = formatMoney(price, format, { symbol });
row.insertCell().textContent = cell;
});
Cart total recalculation. After applying a client-side discount code preview, you need to show the updated total in the correct format. Fetch the cart, apply the discount, and format the result:
import { formatMoney } from 'shopify-money-format-utils';
function previewDiscount(cart, discountPercent) {
const subtotal = cart.total_price / 100;
const discounted = subtotal * (1 - discountPercent);
return formatMoney(discounted, format, { symbol });
}
fetch('/cart.js')
.then(res => res.json())
.then(cart => {
discountPreview.textContent = previewDiscount(cart, 0.10);
});
Liquid Best Practices for Multi-Currency
While this article focuses on the JavaScript side of multi-currency, getting Liquid right is equally important. These practices ensure your server-rendered prices are always correct:
- Always use
| moneyor| money_with_currencyfilters, never hardcode currency symbols in Liquid templates - Use
cart.currency.iso_codefor conditional logic when you need currency-specific behavior - Access
variant.pricedirectly, when Shopify Markets is active, it already reflects the customer's presentment currency - Use
shop.money_formatto understand the store's formatting preference and pass it to JavaScript via the theme integration pattern - For stores selling in currencies that share a symbol (USD and CAD both use $), prefer
| money_with_currencyto append the ISO code for clarity
Presentment currency in Liquid. When Shopify Markets is active, variant.price already reflects the customer's presentment currency, you don't need to convert manually. But in JavaScript, prices from the Cart API (/cart.js) are integers in the smallest currency unit and need both division and formatting. Always verify which currency context you're working with.
Testing Multi-Currency
Multi-currency bugs are invisible until you test from a non-default market. A systematic testing approach catches the formatting inconsistencies that erode customer trust:
- Use Shopify's Geolocation app or a VPN to simulate visits from different markets
- Test all price touchpoints: product page, collection page, cart drawer, cart page, checkout, and order confirmation emails
- Verify third-party apps render prices in the correct presentment currency
- Test currency switching if your store allows customers to manually select their currency
- Compare JavaScript-calculated prices against Liquid-rendered prices on the same page to catch formatting mismatches
- Test with high-value products (prices above 1,000) to verify thousand-separator formatting
- Test in zero-decimal currencies like JPY if you sell in Japan, decimal places should not appear
Edge Cases and Production Gotchas
Zero-decimal currencies. JPY and KRW don't use decimal places, ¥1,500 is 1,500 yen, not 15 yen. Use amount_no_decimals for these currencies. The library handles this correctly, but your division logic needs to account for it, if the API returns prices in the smallest unit, ¥1500 is already 1500, not 150000.
Currency symbols that are words. Swedish "kr", Romanian "lei", and similar multi-character symbols need space separation from the amount. When using position: 'start', include a trailing space in the symbol: { symbol: 'kr ', position: 'start' }.
RTL currencies. Currencies used in right-to-left languages may require different symbol positioning. Test with Arabic and Hebrew locales to ensure the symbol and amount appear in the correct visual order for the reader.
Presentment vs settlement currency. The price the customer sees (presentment) and the currency you receive (settlement) are different things. A German customer sees €28.99, but you might receive $31.47 USD. Don't confuse these in your analytics or order processing logic.
Admin API vs Storefront API prices. The Admin API returns prices in the shop's base currency. The Storefront API, when used with a buyer's context, returns prices in the presentment currency. Know which API you're calling and which currency you're getting back, mixing them up is one of the most common sources of price display bugs in headless builds.
Rounding across line items. When displaying a cart total, rounding each line item individually and then summing can produce a total that differs by a cent from rounding the sum. The safest approach is to sum the raw values first, then format the total once. This avoids the "penny problem" that confuses customers and flags fraud filters.
Key Takeaways
- Liquid's
| moneyfilter handles multi-currency formatting automatically, JavaScript doesn't, and that's where most pricing bugs live - Shopify's
money_formattemplate string contains the format type your JavaScript needs to match the store's locale - Never hardcode currency symbols, decimal separators, or thousand separators, they vary by locale and market
- shopify-money-format-utils provides the same formatting logic as Liquid's money filters for JavaScript, with zero dependencies
- Bridge Liquid and JavaScript by extracting the format type and currency symbol in your
theme.liquidlayout - Always let Shopify be the source of truth for converted prices, don't recalculate exchange rates in your own code
- Test from non-default markets using VPN or geolocation simulation to catch invisible formatting bugs
If your store needs consistent multi-currency formatting across custom JavaScript, theme integrations, or headless storefronts, I can help you architect and build it. Let's talk about what your commerce model actually needs.