Nakodo's plans are priced in pounds: £39 and £119 a month. Visitors in 23 currencies see and pay a converted figure instead, and the rule the whole module is built around is one sentence:

The figure on the page is the figure charged.

Not an approximation next to a pound price, not a "from" number that becomes something else at checkout. The number you read is sent to Stripe as the amount. That one decision removes a class of support ticket and adds about four interesting problems.

You can watch it happen from a terminal. Ask the pricing page for its prices as a browser, then as Googlebot:

$ curl -s -A "Mozilla/5.0 ... Chrome/120.0 Safari/537.36" https://nakodo.app/pricing | ... Prices in EUR, including any VAT or sales tax. €0 €49 €149 $ curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://nakodo.app/pricing | ... Prices in GBP, including any VAT or sales tax. £0 £39 £119 

Same URL, same deployment, two different sets of prices, and the difference is the user agent.

Why crawlers get pounds

const BOT = /bot\b|bot\/|crawler|spider|slurp|facebookexternalhit|whatsapp|headless|lighthouse|vercel/i; // Crawlers get pounds: they're placed wherever their servers are, and search // results should show the base price, not a datacentre's currency. export async function displayCurrency(): Promise<Currency> { if (BOT.test((await headers()).get("user-agent") ?? "")) return BASE_CURRENCY; return currencyForCountry(await requestCountry()); } 

A crawler's IP tells you where its datacentre is, not where its readers are. Without this, a snippet in a British search result might quote dollars because the fetch came out of Iowa, and the figure in a link preview would be whatever currency the preview service happens to sit in. The base price is the one thing that is true everywhere, so that is what machines get.

The same instinct runs through the structured data. The JSON-LD offers on that page are always GBP, whoever asks:

offers: PLAN_ORDER.map((id) => ({ "@type": "Offer", name: PLANS[id].name, price: PLANS[id].price, priceCurrency: CURRENCIES[BASE_CURRENCY].code, // ... })), 
$ curl -s https://nakodo.app/pricing | grep -o '"price":[0-9]*,"priceCurrency":"[A-Z]*"' | sort -u "price":0,"priceCurrency":"GBP" "price":39,"priceCurrency":"GBP" "price":119,"priceCurrency":"GBP" 

Because crawlers also see pound prices in the visible copy, the markup and the page agree for every reader that cares about the markup, which is the property you actually need there.

Two more notes on that function. The country comes from x-vercel-ip-country, validated as two letters, and anything unrecognised falls back to pounds rather than guessing. And there is deliberately no cookie fallback, because the sign-in cookie is the only cookie this site sets and a currency cookie would be the first one that needed a banner.

Fixed rates, because they are the charge

// Rates are from the ECB reference rates of 2026-10-02, crossed through the // euro. They're fixed, not live, because they set the charge. Refresh them when // one moves more than about 3%. Only currencies with a steady pound rate that // Stripe can charge in are listed. // The Turkish lira is left out because it moves too fast for a fixed rate. export const CURRENCIES = { gbp: { code: "GBP", locale: "en-GB", rate: 1 }, usd: { code: "USD", locale: "en-US", rate: 1.3201 }, eur: { code: "EUR", locale: "en-IE", rate: 1.176 }, // ...20 more } as const satisfies Record<string, CurrencyDefinition>; 

A live rate feed would be wrong for this job. If the rate is the price, a live rate means a subscription whose figure moves between the pricing page and checkout, a renewal that costs a different number each month, and a hard dependency on a rates API being up in order to sell anything. A constant in the repository is a price list, which is what it is.

The cost is a maintenance chore, so the file says when to do it, and which currency is excluded for being too volatile to pin.

Each currency carries a locale alongside the code, because the locale is what makes the number look local: nb-NO writes a krone price the Norwegian way. One of those locales has a comment next to it:

ils: { code: "ILS", locale: "en-IL", rate: 4.0464 }, // he-IL adds right-to-left marks 

Intl with he-IL emits bidirectional control characters around the number. Correct for Hebrew text, visible as mojibake when the rest of the line is English.

Every converted price ends in 9

const MARGIN = 1.04; export function convertPrice(gbp: number, currency: Currency): number { if (!Number.isFinite(gbp) || gbp < 0) throw new Error(`convertPrice: expected a price in pounds, got ${gbp}`); const scale = 10 ** minorUnitDigits(currency); if (currency === BASE_CURRENCY || gbp === 0) return Math.round(gbp * scale); return endInNine(gbp * CURRENCIES[currency].rate * MARGIN) * scale; } // The smallest whole amount at or above `amount` that ends in 9. The epsilon // stops float error pushing an exact whole amount up a unit. function endInNine(amount: number): number { const whole = Math.ceil(amount - 1e-9); return whole + ((19 - (whole % 10)) % 10); } 

Three things are happening in those few lines.

The 4% margin. Stripe charges for converting a payout, and fixed rates drift between refreshes. Adding 4% before rounding means a converted sale never nets less than a pound sale. It is not a markup for the sake of one, it is the cost of the conversion sitting on the conversion.

Rounding up to a 9. £39 at 1.176 plus margin is 47.70, which becomes €49. £119 becomes €149. Prices that end in 9 in every currency, never €47.70, and always rounded up so the margin cannot be rounded away.

The epsilon. Say an amount lands on exactly 49 in theory and 49.000000000000014 in floating point. Math.ceil makes that 50, 50 does not end in a 9, so the next amount that does is 59. A rounding error of 1.4e-14 becomes a price ten units higher. Subtracting 1e-9 before the ceiling keeps an exact whole number as itself. It is one line of defensive arithmetic whose absence shows up as a price being wrong in one currency, occasionally, which is the hardest kind of bug to get reported.

The minor-unit trap, which only bites in three currencies

How many decimals does a currency have? Not a question to answer by hand:

// Decimal places the currency is written with. From Intl rather than assumed, // because yen, forints and Icelandic krónur are written without any. export function minorUnitDigits(currency: Currency): number { const { code, locale } = CURRENCIES[currency]; return new Intl.NumberFormat(locale, { style: "currency", currency: code }).resolvedOptions().maximumFractionDigits ?? 2; } 

Then Stripe disagrees with Intl, on purpose, for exactly the currencies you would not test:

// Stripe counts yen in whole units, but forints and krónur in hundredths even // though neither is written with decimals. const STRIPE_ZERO_DECIMAL: ReadonlySet<Currency> = new Set<Currency>(["jpy"]); const stripeDigits = (currency: Currency) => (STRIPE_ZERO_DECIMAL.has(currency) ? 0 : 2); // A pound price in `currency`, as a Stripe `unit_amount`. export function stripeUnitAmount(gbp: number, currency: Currency): number { return Math.round(convertPrice(gbp, currency) * 10 ** (stripeDigits(currency) - minorUnitDigits(currency))); } 

Stripe's currency docs are explicit about why those two are not in the zero-decimal list:

Icelandic Króna (ISK) ISK transitioned to a zero-decimal currency, but backward compatibility requires you to represent it as a two-decimal value, where the decimal amount is always 00. For example, to charge 5 ISK, provide an amount value of 500.

Hungarian Forint (HUF) Stripe treats HUF as a zero-decimal currency for payouts, even though you can charge two-decimal amounts.

So the Pro plan shown as "6.539 kr." in Iceland is 653900 to Stripe, "17 619 Ft" in Hungary is 1761900, and the yen price of 8,449 is sent as 8449. Getting this backwards does not throw. It charges a hundred times too much, or a hundredth, which is why the conversion between "what the page shows" and "what the API wants" is one function with one exponent and no call site allowed to multiply by 100 on its own.

The display side has its own small rule, for the same reason: whole amounts drop the decimals, so a plan is "€49" and not "€49.00", while a non-whole amount keeps them.

The thing I would steal from this

One function owns the conversion, and both the page and the payment call it. formatPrice for the human, stripeUnitAmount for the API, and both start at convertPrice. There is no second place where a rate, a margin or a rounding rule is applied, which means the invariant at the top of this post is not a convention anybody has to remember. It is just what the code can do.

If you want to see the output rather than the arithmetic, the plans are on the pricing page, and how Nakodo works explains what each plan is actually buying.