When teams think about internationalisation (i18n) for a trading platform, they usually think about translating strings. That is maybe 20% of the work. The other 80% is number formatting, date and time handling, layout direction, currency display, and locale-specific regulatory requirements.
A trading UI that has been "translated" but still formats numbers with commas as thousands separators will confuse every user in Germany, where commas are decimal separators. That confusion has real financial consequences.
Number formatting is the hard part
Every number on a trading interface is critical. Prices, quantities, P&L, percentages, and volumes all need to follow the user's locale conventions. The differences are significant:
| Locale | Number | Currency |
|---|---|---|
| en-US | 1,234,567.89 | $1,234,567.89 |
| de-DE | 1.234.567,89 | 1.234.567,89 $ |
| fr-FR | 1 234 567,89 | 1 234 567,89 $ |
| ja-JP | 1,234,567.89 | ¥1,234,568 |
JavaScript's Intl.NumberFormat handles most of this, but you need to integrate it with your instrument precision system:
function createPriceFormatter(locale: string, instrument: Instrument) { return new Intl.NumberFormat(locale, { minimumFractionDigits: instrument.pricePrecision, maximumFractionDigits: instrument.pricePrecision, }); } function createQuantityFormatter(locale: string, instrument: Instrument) { return new Intl.NumberFormat(locale, { minimumFractionDigits: instrument.quantityPrecision, maximumFractionDigits: instrument.quantityPrecision, }); }
Cache these formatters. Creating a new Intl.NumberFormat on every render is expensive, and a trading UI renders numbers thousands of times per second.
In practice, we started with a simpler version: a single formatCurrency(value, precision) utility backed by Intl.NumberFormat hardcoded to en-US. Every component - order book rows, portfolio balances, order form totals - calls the same function with the asset's precision derived from exchange metadata (tick size for prices, step size for quantities). The centralisation means adding locale support later is a change to one function rather than a grep through fifty components. Getting the precision plumbing right first was the key decision; locale formatting is a straightforward layer on top once every number already flows through a single path.
const formatterCache = new Map<string, Intl.NumberFormat>(); function getCachedFormatter(locale: string, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale}:${JSON.stringify(options)}`; if (!formatterCache.has(key)) { formatterCache.set(key, new Intl.NumberFormat(locale, options)); } return formatterCache.get(key)!; }
Input parsing
Formatting numbers for display is only half the problem. You also need to parse user input back into numbers. A German user typing "1.234,56" into a price field means 1234.56, not 1234456.
function parseLocaleNumber(input: string, locale: string): number { const parts = new Intl.NumberFormat(locale).formatToParts(1234.5); const groupSeparator = parts.find((p) => p.type === "group")?.value ?? ","; const decimalSeparator = parts.find((p) => p.type === "decimal")?.value ?? "."; const normalised = input .replace(new RegExp(`\\${groupSeparator}`, "g"), "") .replace(decimalSeparator, "."); return parseFloat(normalised); }
Get this wrong and a trader enters a quantity ten times larger than intended. This is not a cosmetic issue.
Date and time
Traders work across time zones and need to know exactly what time a candle closed, when an order was filled, and when a funding payment is due.
Display all times in the user's chosen timezone, and always include the timezone abbreviation:
function formatTradeTime(timestamp: number, timezone: string, locale: string): string { return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: timezone, timeZoneName: "short", }).format(new Date(timestamp)); } // "14:32:07 EST" or "14:32:07 MEZ" depending on locale
Let users choose their timezone explicitly rather than auto-detecting it. Many traders prefer UTC regardless of their physical location.
RTL layout support
If your platform targets Arabic or Hebrew-speaking markets, you need right-to-left (RTL) layout support. This goes beyond text direction:
- The entire layout mirrors - Sidebars, panels, and navigation flow right to left
- Charts do not mirror - Time series data always flows left to right regardless of text direction. This is a universal convention in financial data visualisation.
- Numbers remain LTR - Even in RTL contexts, numbers and prices are read left to right
- Icons may need mirroring - Directional icons (arrows, chevrons) should flip, but icons representing real objects should not
[dir="rtl"] { .panel-layout { direction: rtl; } .chart-container { direction: ltr; /* Charts always LTR */ } .price-display { direction: ltr; /* Numbers always LTR */ text-align: right; } }
Tailwind CSS supports RTL with the rtl: prefix, which makes conditional styling straightforward:
<div className="ml-4 rtl:ml-0 rtl:mr-4"> {content} </div>
Translation architecture
For the strings that do need translating, use a library like next-intl or react-i18next. Organise translation keys by feature, not by page:
{ "orderForm": { "buy": "Buy", "sell": "Sell", "limitPrice": "Limit Price", "quantity": "Quantity", "total": "Total", "submit": "Place Order", "insufficientBalance": "Insufficient balance" }, "orderBook": { "price": "Price", "quantity": "Qty", "total": "Total", "spread": "Spread" } }
Trading UIs have relatively few user-facing strings compared to content-heavy applications. The translation effort is modest - it is the number and date formatting that takes real engineering work.
Regulatory copy
Different jurisdictions require different disclaimers, risk warnings, and legal text. This is not optional - launching in a new market without the correct regulatory copy can result in fines or having your platform blocked.
Store regulatory content per jurisdiction and display it based on the user's registered location, not their browser locale:
interface RegulatoryConfig { riskWarning: string; termsUrl: string; privacyUrl: string; requiresRiskAcknowledgement: boolean; maxLeverageAllowed: number; restrictedProducts: string[]; }
Some jurisdictions restrict specific products entirely. A user in a jurisdiction where futures are prohibited should not see the futures tab at all, not just a disclaimer.
Currency display
Crypto exchanges deal with multiple currencies simultaneously. The display currency for portfolio values and P&L should be user-configurable:
function formatPortfolioValue( value: number, displayCurrency: string, locale: string ): string { return new Intl.NumberFormat(locale, { style: "currency", currency: displayCurrency, minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(value); }
Users in Japan expect to see values in JPY. Users in Europe expect EUR. Users in the US expect USD. Defaulting to USD and calling it done will alienate a significant portion of your user base.
Testing i18n
Test with real locale data, not just string substitution:
- Number formatting tests - Verify that every formatted number in the UI respects the active locale
- RTL visual tests - Screenshot comparison in RTL mode catches layout issues that are invisible in LTR
- Long string tests - German and Finnish translations are often 40% longer than English. Verify that your layout handles this without overflow or truncation
- Input parsing tests - Verify that number input works correctly for every supported locale
The investment in proper internationalisation pays off in market reach. A trading platform that only works correctly in English is leaving most of the world on the table.
