Acme Store Docs
Configuration

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

src/config/site.ts
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.

FieldTypeMeaning
codestringISO 4217 code used for all price formatting, e.g. "USD".
localestringBCP 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.

src/config/site.ts
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:

FieldTypeMeaning
codestringISO 4217 code.
localestringBCP 47 locale for grouping and symbol placement.
ratenumberMultiplier from the base currency. Base currency = 1.
labelstringShort 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).

src/lib/money.ts
export function formatMoney(
  amount: number | null | undefined,
  opts: FormatMoneyOptions = {}
): string

FormatMoneyOptions:

OptionTypeDefaultMeaning
currencystringsiteConfig.currency.codeISO 4217 target currency code.
localestringsiteConfig.currency.localeBCP 47 locale for grouping/symbol.
ratenumber1 (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.

src/lib/money.ts
export function formatMoneyIn(
  amount: number | null | undefined,
  currency: CurrencyOption
): string
formatMoneyIn(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: "" }); // 1550

The 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 by getActiveCurrency().
  • 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 calls router.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:

ExportPurpose
CurrencyProviderContext 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.
baseCurrencysiteConfig.currencies[0].
CURRENCY_COOKIEThe 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.

src/lib/currency/server.ts
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:

src/app/[locale]/layout.tsx
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:

src/components/product/ProductCard.tsx
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:

src/app/[locale]/products/[id]/page.tsx
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.
  • EmailsQuoteEmailTemplate and StatusUpdateEmailTemplate format totals and discounts with formatMoney, 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.

On this page