Multi-Currency
Base-currency storage with display-time conversion and a shopper currency switcher.
The storefront stores every price in a single base currency and converts to the shopper's chosen currency at display time. Nothing is ever re-priced in the database: the shopper picks a display currency, the choice is saved in a cookie, and price components multiply the base amount by that currency's FX rate when they render. Admin dashboards and emails always show the untouched base-currency figure.
The whole system is driven by two config keys in src/config/site.ts and two tiny libraries: src/lib/money.ts (formatting + conversion) and src/lib/currency/ (the switcher context and its server-side reader).
These are static demo rates shipped for local development. Wire a live FX feed before you take real orders — see Wiring a live FX feed.
How it fits together
Prices are authored and stored in the base currency (USD out of the box).
The shopper picks a display currency in CurrencySwitcher, which writes the currency cookie and calls router.refresh().
On the client, price components read the active currency from useCurrency() and call formatMoneyIn(baseAmount, currency).
On the server, components read the same cookie via getActiveCurrency() and pass the result into formatMoneyIn / formatMoney, so server-rendered prices match after the refresh.
Admin pages and email templates call bare formatMoney(amount) with no rate, so operational records always stay in the base currency.
Configuration
Two keys in siteConfig control currency. Change them once to re-currency the whole app.
siteConfig.currency — the base
currency: { code: "USD", locale: "en-US" } satisfies CurrencyConfig,This CurrencyConfig is the single source of truth for how prices render when no override is passed. formatMoney falls back to siteConfig.currency.code and siteConfig.currency.locale.
| Field | Type | Meaning |
|---|---|---|
code | string | ISO 4217 code used for all price formatting, e.g. "USD". |
locale | string | BCP 47 locale that drives number grouping and the currency symbol. |
siteConfig.currencies — the switcher list
The currencies a shopper can switch into. The first entry must be the base currency (rate: 1) and match siteConfig.currency.
currencies: [
{ code: "USD", locale: "en-US", rate: 1, label: "USD ($)" },
{ code: "EUR", locale: "de-DE", rate: 0.92, label: "EUR (€)" },
{ code: "GBP", locale: "en-GB", rate: 0.79, label: "GBP (£)" },
{ code: "JPY", locale: "ja-JP", rate: 155, label: "JPY (¥)" },
] satisfies CurrencyOption[],Each entry is a CurrencyOption:
| Field | Type | Meaning |
|---|---|---|
code | string | ISO 4217 code. |
locale | string | BCP 47 locale for grouping and symbol placement. |
rate | number | Multiplier from the base currency. Base currency = 1. |
label | string | Short human label shown in the switcher, e.g. "USD ($)". |
Zero-decimal currencies like JPY are handled automatically — the code reads Intl.NumberFormat(...).resolvedOptions().maximumFractionDigits rather than hand-maintaining a zero-decimal allow-list, and rounds converted JPY amounts to whole numbers.
The first array entry is treated as the base everywhere (baseCurrency = siteConfig.currencies[0]). If it drifts from siteConfig.currency, server- and client-rendered prices can disagree. Keep them in sync.
Formatting and conversion (src/lib/money.ts)
All price rendering — storefront, admin, and emails — goes through this module instead of scattered $, .toFixed(2), and Intl.NumberFormat calls.
formatMoney(amount, opts?)
Formats a base-currency amount as a localized currency string. Returns an em-dash (—) for null, undefined, or any non-finite number (NaN, Infinity).
export function formatMoney(
amount: number | null | undefined,
opts: FormatMoneyOptions = {}
): stringFormatMoneyOptions:
| Option | Type | Default | Meaning |
|---|---|---|---|
currency | string | siteConfig.currency.code | ISO 4217 target currency code. |
locale | string | siteConfig.currency.locale | BCP 47 locale for grouping/symbol. |
rate | number | 1 (untouched) | Multiplier from base to target; undefined or 1 leaves the amount as-is. |
formatMoney(29.99); // "$29.99"
formatMoney(null); // "—"
formatMoney(1234.56, { currency: "EUR", locale: "de-DE" }); // "1.234,56 €"
formatMoney(10, { currency: "EUR", locale: "de-DE", rate: 0.92 }); // "9,20 €"Calling it with no options (as the admin and email code does) yields byte-identical base-currency output.
formatMoneyIn(amount, currency)
The convenience wrapper the storefront uses. Takes a base-currency amount and a CurrencyOption, converts, and formats in one call. Also returns — for absent amounts.
export function formatMoneyIn(
amount: number | null | undefined,
currency: CurrencyOption
): stringformatMoneyIn(10, { code: "EUR", locale: "de-DE", rate: 0.92, label: "" }); // "9,20 €"
formatMoneyIn(10, { code: "JPY", locale: "ja-JP", rate: 155, label: "" }); // "¥1,550"
formatMoneyIn(null, someCurrency); // "—"Internally it composes convertFromBase (rate multiply + zero-decimal rounding) with formatMoney.
convertFromBase(amount, currency)
Converts a base-currency amount into currency by multiplying by currency.rate. Zero-decimal currencies (detected via Intl) are rounded to whole numbers; others keep fractional precision and are rounded to cents by Intl.NumberFormat at format time.
convertFromBase(10, { code: "EUR", locale: "de-DE", rate: 0.92, label: "" }); // 9.2
convertFromBase(10, { code: "JPY", locale: "ja-JP", rate: 155, label: "" }); // 1550The currency switcher
Client context — CurrencyProvider (src/lib/currency/CurrencyProvider.tsx)
Holds the active display currency in React context. Prices convert against useCurrency() at format time.
- Cookie name:
CURRENCY_COOKIE = "currency". Written on the client by the provider, read on the server bygetActiveCurrency(). - Cookie attributes:
path=/(every route sees it),max-age~1 year (60 * 60 * 24 * 365),SameSite=Lax. - On change:
setCurrency(code)validates the code, updates state, writes the cookie, then callsrouter.refresh()so any server-rendered prices re-render in the new currency. - SSR-safe: no browser globals are read at module scope; the initial cookie read happens in an effect (never during render), avoiding hydration mismatches.
Exported helpers:
| Export | Purpose |
|---|---|
CurrencyProvider | Context provider. Accepts an optional initialCurrency (defaults to baseCurrency). |
useCurrency() | Returns { currency, setCurrency }. Safe to call without a provider — falls back to baseCurrency and a no-op setter. |
resolveCurrency(code) | Narrows an arbitrary string to a configured CurrencyOption, or returns baseCurrency for anything unknown. |
baseCurrency | siteConfig.currencies[0]. |
CURRENCY_COOKIE | The cookie name constant, "currency". |
Server reader — getActiveCurrency() (src/lib/currency/server.ts)
Resolves the shopper's active currency on the server by reading the currency cookie, validated against siteConfig.currencies, falling back to the base currency for a missing or unknown value.
export function getActiveCurrency(): CurrencyOption {
const code = cookies().get(CURRENCY_COOKIE)?.value;
return (
siteConfig.currencies.find((currency) => currency.code === code) ??
baseCurrency
);
}This lives in its own server-only module because it imports next/headers. Importing that from a client bundle would break the build, so it is deliberately kept out of CurrencyProvider.
The picker component — CurrencySwitcher (src/components/currency/CurrencySwitcher.tsx)
A shadcn/ui Select (default export) that lists siteConfig.currencies by label, shows the active one, and calls setCurrency on change.
CurrencySwitcher is already wired into the header: Header.tsx renders it via PreferencesMenu on desktop and HamburgerMenu on mobile, alongside the locale and theme switchers. To place it elsewhere, import it and drop it into any client tree under CurrencyProvider.
Wiring it up
The [locale] layout already resolves the cookie on the server and seeds the provider so the first client render matches:
import { CurrencyProvider } from "@/lib/currency/CurrencyProvider";
import { getActiveCurrency } from "@/lib/currency/server";
const currency = getActiveCurrency();
// ...
<CurrencyProvider initialCurrency={currency}>
{/* app */}
</CurrencyProvider>Read the active currency from context and convert at render:
import { formatMoneyIn } from "@/lib/money";
import { useCurrency } from "@/lib/currency/CurrencyProvider";
const { currency } = useCurrency();
// ...
{formatMoneyIn(product.price, currency)}Resolve the currency from the cookie, then format:
import { getActiveCurrency } from "@/lib/currency/server";
import { formatMoneyIn } from "@/lib/money";
const currency = getActiveCurrency();
// ...
{formatMoneyIn(displayPrice, currency)}Admin and emails stay in base currency
Conversion is a storefront display concern only. Operational surfaces intentionally call bare formatMoney(amount) with no rate, so records never drift with FX:
- Admin — products, orders, quotes, discounts, and the analytics widgets (
RevenueStats,SalesTrend,TopProducts) all render base-currency figures. - Emails —
QuoteEmailTemplateandStatusUpdateEmailTemplateformat totals and discounts withformatMoney, so a quote a merchant sends shows the same numbers they see in the dashboard.
This keeps a quote, its confirmation email, and the admin record all speaking one currency, regardless of what the shopper was browsing in.
Wiring a live FX feed for production
The shipped rate values are static demo numbers. For real orders, replace them with rates from a live FX source (a scheduled job that rewrites siteConfig.currencies, or a data layer that supplies fresh CurrencyOptions). Because every price flows through formatMoneyIn / convertFromBase, updating the rate fields is the only change required — no call sites move.
Displaying a converted price is not the same as charging in that currency. If you enable Stripe payments, settle in the base currency (or configure Stripe's own currency handling) rather than relying on these display rates for the actual charge.
Roadmap: per-currency price lists
Today the system does FX conversion from one stored base price. It does not yet support per-currency price lists — i.e. authoring an explicit override price per currency (e.g. €99 that is not simply $99 × rate). Per the ROADMAP, multi-currency display is shipped and per-currency override price lists remain open.