DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Ship localised pricing in an afternoon, using a header your CDN already sends

A landlord in Dublin lands on your pricing page and reads £30 / month. Before they can decide whether that is affordable, they have to open a calculator. Then they click through to Stripe and see a third number. You have asked someone to do currency arithmetic at the exact moment they were deciding whether to pay you.

We fixed this on pub-trivia.app without a geolocation API, without a client-side fetch, and without a flicker. Total cost: four small files. Here is the whole build, in the order I would do it again.

Go and poke at it first. Open pub-trivia.app/pricing. If you are in the UK you will see plain pounds. Now switch a VPN to Germany, Japan, Sweden or the US and reload. The amount, the symbol, the decimal separator and the small print underneath all change. Nothing about the checkout changes, which is the part most people get wrong, and I will come back to it.

Step 1: read the header you are already being sent

If you deploy on Vercel, every request arrives with the visitor's country stamped on it by the edge network:

x-vercel-ip-country: IE
Enter fullscreen mode Exit fullscreen mode

Cloudflare sends cf-ipcountry. Fastly, CloudFront and Akamai all have an equivalent. You do not need MaxMind, an IP database, or a third-party script that blocks your first paint. The answer is sitting in the request and it costs you nothing.

export async function getDisplayCurrency(): Promise<DisplayCurrency> {
  const headerList = await headers()
  return currencyForCountry(headerList.get('x-vercel-ip-country'))
}
Enter fullscreen mode Exit fullscreen mode

Locally the header is absent, currencyForCountry(null) returns your base currency, and dev just works. That is not an accident, it is the design: every unknown input has to land on the currency you actually charge in.

Step 2: two tables, and nothing else

Resist the urge to build a currency service. Everything downstream is derived from two plain objects. One says what a currency is:

export const CURRENCIES = {
  gbp: { code: 'GBP', locale: 'en-GB', rate: 1 },
  eur: { code: 'EUR', locale: 'en-IE', rate: 1.1696 },
  sek: { code: 'SEK', locale: 'sv-SE', rate: 12.8667 },
  jpy: { code: 'JPY', locale: 'ja-JP', rate: 215.89 },
  // ...
} as const satisfies Record<string, CurrencyDefinition>
Enter fullscreen mode Exit fullscreen mode

The other says which countries use it:

export const COUNTRY_CURRENCY: Record<string, DisplayCurrency> = {
  GB: 'gbp', JE: 'gbp', IM: 'gbp',
  IE: 'eur', DE: 'eur', FR: 'eur', ES: 'eur', // ...
  SE: 'sek', NO: 'nok', CH: 'chf',
  US: 'usd', CA: 'cad', AU: 'aud', JP: 'jpy',
}
Enter fullscreen mode Exit fullscreen mode

as const satisfies is doing real work in the first one. satisfies type-checks every entry against CurrencyDefinition, and as const keeps the literal keys, so DisplayCurrency is keyof typeof CURRENCIES and a typo anywhere downstream is a compile error rather than an undefined in a price.

Adding a currency is one entry in each table. The prices, the disclosures, the tests and the footnotes all follow, because every one of them reads the table.

Step 3: the lookup that is not an index

This one looks pedantic until you think about where the input comes from:

export function currencyForCountry(country: string | null | undefined): DisplayCurrency {
  if (!country) return BASE_CURRENCY
  const key = country.trim().toUpperCase()
  return Object.prototype.hasOwnProperty.call(COUNTRY_CURRENCY, key)
    ? COUNTRY_CURRENCY[key]
    : BASE_CURRENCY
}
Enter fullscreen mode Exit fullscreen mode

The header is attacker-influenced, even if only via a proxy or a spoofed IP. COUNTRY_CURRENCY[key] with key = 'constructor' hands you a function rather than a currency, and whatever you do with that next is not going to be a price. hasOwnProperty costs one call and closes the whole category.

Step 4: resolve on the server, not in the browser

This is the decision that separates a localised price from a janky one.

If you resolve the currency client-side, the page paints £30, then swaps to €37.99 once your JavaScript has run. That flicker lands on the single number the visitor came to the page to read. It does not look like localisation, it looks like the price changing while they watch.

So the currency is resolved on the server, passed into a context provider as a prop, and read by client components:

const DisplayCurrencyContext = createContext<DisplayCurrency>(BASE_CURRENCY)

export function DisplayCurrencyProvider({ currency, children }) {
  return <DisplayCurrencyContext.Provider value={currency}>{children}</DisplayCurrencyContext.Provider>
}
Enter fullscreen mode Exit fullscreen mode

No fetch, no effect, no localStorage. Any of the three reintroduces the swap. The value is baked into the server-rendered HTML, so the first frame is correct and the page does not depend on a script arriving at all.

The default matters too. A surface that forgets to wrap itself gets the base currency, which is the amount you really charge, rather than a blank or a zero.

Step 5: accept the cache cost on purpose

Reading the request makes the calling page render per request. You give up static generation on your pricing page. That is the trade, and it is worth naming rather than discovering:

A page that renders fast and shows the wrong number is worse than one that renders correctly.

The obvious optimisation is to have middleware mirror the country into a cookie so the page can stay cached. We deliberately did not. Every surface that shows a price already renders dynamically, so the cookie buys nothing, and a currency-preference cookie is not strictly necessary under UK PECR, which would undercut our cookie policy's claim that we set only essential cookies and therefore show no consent banner. Reading the header directly keeps that claim true.

Step 6: say what the number is

This is the part that makes the feature honest rather than clever.

We charge in GBP. Every subscription is created from an integer number of pence against a GBP Stripe Price. Nothing in the display layer touches money movement, the webhook, or entitlements. What we render is a label, and the label says so:

Approximate EUR (£1 is about €1.22, incl. card fees). Charged in GBP. Stripe shows the exact total at checkout. VAT added at checkout based on your country.

Because it is an estimate, the direction of the error is a design decision. Whoever performs the conversion takes a cut of it: Stripe's presented rate embeds a 2 to 4 percent spread, and a card issuer's own FX markup sits in the same band. A naive mid-market conversion therefore under-quotes what the customer is actually billed, which is the same unpleasant surprise you set out to remove, just smaller.

So we multiply by a buffer at the top of that band and round up:

const CONVERSION_FEE_BUFFER = 1.04
Enter fullscreen mode Exit fullscreen mode

The guarantee that the whole module rests on: the number we display is never lower than the amount that will be asked for. Quoting slightly high and charging slightly less is the only safe direction for this error to point.

The bit I would tell you to budget for

The header, the tables and the context took an afternoon. The rounding took longer than all of it, because "round it nicely" means something different in a currency with no decimal places, and getting that wrong is an error of a hundred times rather than four percent. That is its own post.

Go and try it: pub-trivia.app/pricing, ideally with a VPN so you can watch it move. There is a free tier and no card needed if you want to see what the rest of the app does with it.

Top comments (0)