Skip to main content
ASoc
Tutorial

Next.js Currency Formatting: Four Renderings, One That Lied

Four ways to print money in one codebase, and the fallback that stamped a dollar sign on any currency Intl could not parse. The audit, the fix, and the locale rule.

The ASoc Team10 min read

Format currency in Next.js with Intl.NumberFormat, store money as integer cents, and pin the locale explicitly instead of letting the runtime pick one. The last rule is the Next.js-specific one: a Server Component formats on the server, where the runtime's locale is the container's, not the visitor's — so an unpinned format is a silent bug and a hydration mismatch waiting to happen.

This storefront renders prices in four places. All four use a different method, and one of them was lying about the currency until this post. Here is the audit.

Four renderings of the same kind of number

WhereCodeOutputProblem
PricingTierCard.tsx:40`$${tier.price}`$39Symbol hard-coded; no grouping above 999
dashboard/page.tsx:77Intl.NumberFormat("en-US", …)$34.99Correct
dashboard/page.tsx:82 (before)`$${(cents / 100).toFixed(2)}`$34.99Prints $ for any currency
email/refundRequest.ts:40`${(cents / 100).toFixed(2)} ${currency}`34.99 USDCorrect, different house style

Rows 2 and 4 render the same order two different ways, which is a consistency problem. Row 3 was a correctness one.

The defect: a fallback that hard-codes a symbol

Intl.NumberFormat throws a RangeError when it is handed a currency code it does not recognise — and the code here comes off a webhook payload, not out of a constant:

// src/lib/lemonsqueezy/webhook.ts — the value's actual provenance
currency:
  typeof attributes.currency === "string" ? attributes.currency : null,

Anything string-shaped survives that check and lands in Postgres as orders.currency text. So the dashboard's formatter needs a fallback, and it had one:

// src/app/dashboard/page.tsx — before
function formatAmount(cents: number | null, currency: string | null) {
  if (cents === null) return null;
  try {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: currency ?? "USD",
    }).format(cents / 100);
  } catch {
    return `$${(cents / 100).toFixed(2)}`; // ← the bug
  }
}

Read the two branches together. The try exists to honour the order's real currency. The catch runs precisely when that currency was not understood — and then prints a dollar sign. A €34.99 order whose currency code arrived malformed renders in a buyer's purchase history as $34.99. The fallback is confidently wrong exactly where the primary path admitted it did not know.

The fix keeps the code visible instead of inventing a symbol, and matches what the refund email already sends:

  } catch {
    return `${(cents / 100).toFixed(2)} ${currency ?? "USD"}`;
  }

34.99 EUR is uglier than €34.99 and considerably better than $34.99. When you cannot format a currency, name it.

Store cents, format at the edge

The one thing this codebase got right from the start is that money is an integer everywhere until the moment it is displayed. LemonSqueezy sends attributes.total in cents, the schema stores total_cents int, and the division by 100 happens in the formatter and nowhere else.

-- supabase/migrations/0001_commerce_init.sql
total_cents int,
currency    text,

The reason is the usual float one: 0.1 + 0.2 is 0.30000000000000004 in JavaScript, and a numeric column read through a JSON API arrives as a string or a lossy double depending on the driver. An integer count of the smallest unit has neither problem. The cost is that every read path must remember to divide — which is an argument for having exactly one formatter, not four.

Two footnotes people hit in practice. Not every currency has two decimal places: JPY has zero, and dividing yen by 100 produces a number a hundred times too small. Intl.NumberFormat already knows the right digit count per currency, so let it decide the display and keep your storage unit explicit in the column name — total_cents is doing real documentation work. And Intl will happily format a Number; if you are dealing with sums large enough to leave the safe-integer range, format a BigInt or a decimal string instead.

The Next.js-specific part: which locale, and on which side

Intl.NumberFormat("en-US", …) in the snippet above pins the locale. That is deliberate, and it is the piece a generic JavaScript article will not tell you.

Where the function runs decides what undefined would mean:

Where the format runsWhat an unpinned locale resolves toConsequence
Server ComponentThe server container's locale (usually en-US or C)Every visitor sees the server's formatting
Client Component, after hydrationThe visitor's browser localeCorrect per user
Both — prerendered then hydratedServer's on the HTML, visitor's on the re-renderHydration mismatch: React warns and swaps the text

The dashboard is a Server Component, so an unpinned locale would silently mean "the container's locale", which is not a product decision anyone made — it is whatever the hosting region defaults to and can change under you. Pinning it makes the choice reviewable in the diff.

The mismatch row is the one that bites hardest, because the number is also correct in both renders — it just differs. It is the same class of bug as reading localStorage during render: a value the server cannot know, used at a moment the server has to produce output.

If you genuinely want per-visitor formatting on a static page, format on the client after mount and render a pinned, server-safe value first:

"use client";

export function Price({ cents, currency }: { cents: number; currency: string }) {
  const [locale, setLocale] = useState("en-US"); // server-safe first paint
  useEffect(() => setLocale(navigator.language), []);
  return (
    <span suppressHydrationWarning>
      {new Intl.NumberFormat(locale, { style: "currency", currency }).format(
        cents / 100,
      )}
    </span>
  );
}

That costs a client boundary for a piece of text, which is why this storefront does not do it: prices are quoted in one currency, checkout is handled by a merchant of record that localises the tax and the receipt, and 412 of this site's URLs are prerendered HTML that a formatter hook would push work back into.

Constructing the formatter once

Intl.NumberFormat is not free — the constructor resolves locale data. Building it inside a .map() over a hundred rows constructs it a hundred times. Hoist it:

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

export const formatUsd = (cents: number) => usd.format(cents / 100);

The instance is reusable and thread-safe for formatting. When the currency varies per row, memoise per currency code in a Map rather than per call.

Troubleshooting

SymptomCauseFix
RangeError: Invalid currency codeThe code is not a 3-letter ISO 4217 value — often lowercase, or nullNormalise with .toUpperCase(), validate against a known set, and make the fallback name the code instead of guessing a symbol
Prices differ between the HTML and the hydrated pageAn unpinned locale resolved differently on server and clientPin the locale, or format after mount with suppressHydrationWarning
Yen amounts are 100× too smallThe value was divided by 100, but JPY has zero decimal placesStore the smallest unit and let Intl pick the digit count; don't assume "cents" is universal
Totals drift by a cent over many rowsFloats were summed before roundingSum integer cents, divide once at the end
$1234.5 renders without grouping or a second decimalA template literal, not IntlUse Intl.NumberFormat; $${n} is only ever right for small round integers
The currency symbol is right but the position is wrong for the localeFormatting was assembled by hand as symbol + amountIntl places the symbol per locale — 1 200,50 € in German, $1,200.50 in US English
A price list re-renders slowlyA new Intl.NumberFormat per rowConstruct once outside the loop and reuse it

FAQ

How do I format currency in Next.js? new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount) — with the locale written out rather than left undefined, because in a Server Component the default is the server's locale, not the visitor's. No library is needed; Intl is in the runtime on both sides.

Should I store prices as cents or decimals? Integer cents, in an int column. Floats lose precision on arithmetic, and decimal types arrive from a JSON API as strings or doubles depending on the driver. This codebase stores total_cents int beside a currency text and divides in exactly one place.

Do I need a library like dinero.js or currency.js? Not for display — Intl covers it. Reach for a money library when you are doing arithmetic on money: allocation, splitting a total across line items without losing a cent, multi-currency sums. Formatting is not that.

Why does my price show as $ when the order was in euros? Almost always a fallback path that hard-codes a symbol, which is the exact defect this post fixed. Anywhere you cannot format a currency, print its ISO code rather than a symbol you assumed.

Templates in this post

ASoc Groove is a vinyl record storefront — new releases, limited pressings and hi-fi gear — where every product card carries a price and a strikethrough anchor, the highest-density formatting surface a shop has. ASoc Holly is a festive-decor storefront with a cart, so the same number has to agree across a card, a line item and a total. ASoc Keycap is a mechanical-keyboard shop whose switches and keycaps sell in multiples, which is where per-unit and extended prices start needing one shared formatter rather than four.

Browse the full sets: Next.js shop templates, Tailwind shop templates. For the hydration failure mode this post's locale rule avoids, see the localStorage hydration mismatch; for what the storefront half of an ecommerce build actually costs, ecommerce site cost.

Keep reading

Tutorial12 min read

Data Tables in Next.js: Virtualizing 10,000 Rows Without Losing the Server

Virtualization forces the table into a Client Component and hands back the sorting, filtering and accessibility the server did for free. The three options, and the hybrid worth using.

Read more
Tutorial8 min read

Next.js Deployment: The Env-Var Mistake That Breaks Every Page

Static export can't run middleware, a restart isn't a rebuild, and a missing env var in a proxy file breaks the whole site on first request. Our own go-live checklist.

Read more