Format a price with new Intl.NumberFormat(locale, { style: "currency", currency }) and call format() with the amount exactly as your API sent it. Since ES2023 a string is read as an exact decimal, so "19.99" from a Shopify MoneyV2 never passes through a float. Pick the locale from the storefront, not from the currency, and keep the formatter instance around.
Most shops need nothing more than that. What follows is the handful of options that change what a customer sees, and the places where the output is not what a test expects. Outputs shown are from Node 24.12 (ICU 77), as of October 2026.
Why pass the amount as a string?
Because the proposal that landed in ES2023, Intl.NumberFormat v3, changed what format() does with one: “strings are interpreted as decimals instead of Numbers”. Before that, format("19.99") meant format(Number("19.99")). Now the string is parsed as an exact decimal and rounded by the formatter’s own rules, with no IEEE 754 double in between.
Commerce APIs already send money this way. The Storefront API’s MoneyV2 object (checked against 2026-10) is amount: Decimal! plus currencyCode: CurrencyCode!. Hand amount straight to format() and the decimal never becomes a double at all.
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
usd.format("1999.50"); // "$1,999.50"
usd.format("9007199254740993.00"); // "$9,007,199,254,740,993.00"
usd.format(9007199254740993); // "$9,007,199,254,740,992.00"
The last line is the double losing a unit above 2^53. Nobody sells anything at that price, but it shows why Number(amount) is a habit worth dropping: once the value is a float, everything downstream inherits the float.
Support is the same across the whole v3 set: Chrome 106, Firefox 116, Safari 15.4 and Node 19, per MDN’s compatibility data. Node 18 and older coerce the string to a Number, silently.
Which locale do you format with?
The locale and the currency are two separate inputs. The currency says which unit the number is in. The locale says how that unit is written for this reader.
const gb = (opts = {}) =>
new Intl.NumberFormat("en-GB", { style: "currency", currency: "USD", ...opts });
gb().format("19.99"); // "US$19.99"
gb({ currencyDisplay: "narrowSymbol" }).format("19.99"); // "$19.99"
gb({ currencyDisplay: "code" }).format("19.99"); // "USD 19.99"
en-GB writes a dollar as US$; outside the US a bare $ is ambiguous and the locale data reflects that. narrowSymbol drops the prefix. code is what belongs on an invoice or in a CSV. Flip the locale to de-DE and the same GBP price comes out as 19,99 followed by a space and the symbol: comma for the decimal, symbol after the number.
On the server there is no navigator.language. Take the locale from the storefront context you already have, the language and country the buyer chose, which is the same pair the @inContext directive takes (see our post on Storefront API @inContext and local prices). Hydrogen does the same thing: useMoney builds its localizedString with Intl.NumberFormat and the locale from ShopifyProvider, and the <Money> component renders that same string with withoutTrailingZeros as a prop.
How do you drop the .00?
trailingZeroDisplay: "stripIfInteger" removes the fraction only when it is all zeros, so a grid shows $20 and $20.50 side by side rather than $20 and $20.5.
const tidy = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
trailingZeroDisplay: "stripIfInteger",
});
tidy.format("20.00"); // "$20"
tidy.format("20.50"); // "$20.50"
Setting minimumFractionDigits: 0 instead gives you $20.5, which is the thing the option exists to avoid. Hydrogen’s withoutTrailingZeros is the same idea exposed as a prop.
Do not hard-code two fraction digits anywhere. The default for a currency is the minor unit count from ISO 4217 (“2 if the list doesn’t provide that information”, per MDN), which is why JPY formats "1999" as whole yen and BHD gets three places with no options set at all. Set maximumFractionDigits only when you mean it.
What does rounding do by default?
halfExpand: ties go away from zero, the schoolbook rule. The formatter rounds the decimal, not the double, so it gets right the case toFixed is famous for getting wrong:
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
usd.format("1.005"); // "$1.01"
(1.005).toFixed(2); // "1.00"
If a displayed price must never round up (a “from” price, say), roundingMode: "trunc" cuts instead: format("1.009") gives "$1.00". The other seven modes (ceil, floor, expand, halfCeil, halfFloor, halfTrunc, halfEven) are in the same ES2023 set, as is roundingIncrement for prices that end in 5 or 0.
Rounding for display is not rounding for money. Compute totals in your integer or decimal type and round once there. The formatter’s job is the last step before the string.
Where does the output surprise you?
Four cases to check.
format(undefined) returns "$NaN", and so does format("abc"). format(null) returns "$0.00", because null coerces to zero. A missing amount from a bad query therefore renders as a free product, not as an error. Guard the input before the formatter sees it.
formatRange() joins two values with an en dash (U+2013), not a hyphen, and when both ends round to the same string it returns a single value with a tilde: formatRange("10", "10") is "~$10.00". A price-range component that expects a hyphen, or that never considered a minimum equal to the maximum, breaks on both.
The spaces are not spaces. fr-FR with EUR renders 1999.5 as 1 999,50 and the symbol, where the group separator is U+202F (narrow no-break space) and the gap before the symbol is U+00A0. A test that compares against a literal typed with the space bar fails. Normalise if you must, but do it knowingly.
The data is the runtime’s. Node builds with full-icu by default and its official binaries ship that way, per the Node internationalisation docs; a small-icu build formats English only. Browsers and edge runtimes carry their own ICU, so the exact symbol or spacing can differ by version, the same drift we hit with Intl.Segmenter in the post on emoji and string length. Snapshot tests of formatted money belong behind a pinned Node version.
Should you build the formatter once?
Yes. The constructor resolves the locale and the full option set up front (it is what resolvedOptions() reports) and format() reuses that work. Create one formatter per locale-and-currency pair and keep it. A small cache does the job in a product grid or an SSR loader:
type MoneyV2 = { amount: string; currencyCode: string };
const formatters = new Map<string, Intl.NumberFormat>();
export function formatMoney(money: MoneyV2, locale: string): string {
const key = `${locale}:${money.currencyCode}`;
let formatter = formatters.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currencyCode,
trailingZeroDisplay: "stripIfInteger",
});
formatters.set(key, formatter);
}
return formatter.format(money.amount);
}
The amount goes in as the string it arrived as. On Node 18 this still runs, it just takes the Number route; move the Node version before you rely on the decimal path for anything with more than fifteen significant digits.
Reach for a library when you need arithmetic on money values, or a formatting rule ICU does not express. For displaying a price, the platform already has the locale data, the rounding modes and the decimal parser, and a dependency adds a second opinion on all three.
Whoooop builds Hydrogen storefronts and Storefront API integrations where multi-currency display is the normal case. If prices on your product pages render differently from the checkout, or your tests keep failing on a space nobody typed, our Shopify development work covers exactly this.