DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One header and two tables: the whole mechanism behind localised prices

I have written before about the three attempts it took us to ship geo-localised pricing. This is the same system described the other way round: not the story, the mechanism, end to end.

A visitor in Berlin reads £10 / month on your pricing page, decides they are interested, clicks the button, and lands on a Stripe Checkout page quoting euros. They now have to do currency arithmetic at the exact moment they are deciding whether to give you money. That is the worst possible moment to make somebody open a calculator.

We fixed this on Munchable by showing the price in the visitor's own currency before they ever reach checkout. The whole mechanism is one request header, two lookup tables and a React context. No geolocation API, no client-side fetch, no flicker.

Go and poke at it: open munchable.app/#pricing. If you have a VPN, switch your exit node to Germany, Japan or the US and reload. The number and the currency symbol change, and so does the small print underneath it. If you are in the UK you will see the plain £10, because that is the amount we actually charge.

The signal: your CDN already knows

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

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

Cloudflare gives you cf-ipcountry. Fastly, AWS CloudFront and most other CDNs have an equivalent. You do not need a geolocation service, an IP database, or a third party script. The answer is already in the request, and it costs nothing to read.

Here is the entire resolver:

export async function getDisplayCurrency(): Promise<DisplayCurrency> {
  const headerList = await headers();

  if (isBotUserAgent(headerList.get('user-agent'))) {
    return BASE_CURRENCY;
  }

  return currencyForCountry(headerList.get('x-vercel-ip-country'));
}
Enter fullscreen mode Exit fullscreen mode

The bot branch matters and gets its own post. The short version: crawlers are geolocated to whichever datacentre they run in, so without that line Googlebot indexes dollar prices for a business that charges in pounds.

Two tables, and nothing else

Everything downstream is derived from two 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 },
  usd: { code: 'USD', locale: 'en-US', rate: 1.3559 },
  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', GG: 'gbp', IM: 'gbp',
  DE: 'eur', FR: 'eur', IE: 'eur', /* ...the euro area... */
  US: 'usd', PR: 'usd', EC: 'usd', SV: 'usd', PA: 'usd',
  CH: 'chf', SE: 'sek', NO: 'nok', JP: 'jpy',
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Adding a currency is one entry in each. Anything not in the table falls back to the base currency, which is both the safe answer and the correct one for the audience that makes up most of our traffic.

Note the entries that are not countries you would think of first. Jersey, Guernsey and the Isle of Man use sterling. Andorra, Monaco, San Marino, Vatican City, Montenegro and Kosovo use the euro without being in the EU. Ecuador, El Salvador, Panama and Puerto Rico use the US dollar. A country-to-currency map is not the same shape as a country-to-tax-region map, and if you reuse one for the other you will be wrong in a handful of small, embarrassing places.

Resolve it on the server, or you will get a flicker

This is the part people get wrong, and it is worth being blunt about it.

If you decide the currency in the browser, the page paints one number and then replaces it. Every variation of that idea has the same defect. React state gives you a visible swap on hydration. A blocking pre-paint script gives you a slower first paint and stops working the moment somebody has JavaScript disabled or a script blocker on. CSS variants mean shipping every currency in the markup and hiding all but one, which bloats the HTML and leaks the rest of your price list to anyone who opens devtools.

Resolving on the server removes the entire category of problem. There is one price in the markup, it is correct in the first frame, and it does not depend on JavaScript running at all:

export default async function Home() {
  const currency = await getDisplayCurrency();

  return (
    <DisplayCurrencyProvider currency={currency}>
      <Pricing />
    </DisplayCurrencyProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The provider is deliberately inert. No effect, no fetch, no localStorage, no cookie. It carries a value that the server already decided, so a client component reading it renders the correct price on its very first render and there is nothing to correct on hydration.

export function Price({ pence, className }: { pence: number; className?: string }) {
  const currency = useDisplayCurrency();
  return <span className={className}>{formatForDisplay(pence, currency)}</span>;
}
Enter fullscreen mode Exit fullscreen mode

That component is marked 'use client' only so it can read context. It renders identically on the server, which is the entire point.

The cost, stated honestly

Reading a request header opts the page out of static caching. You are trading a cached response for a correct one.

export const dynamic = 'force-dynamic';
Enter fullscreen mode Exit fullscreen mode

Take that trade with your eyes open. It is cheap for us because our homepage does no database work, so per-request rendering costs almost nothing. It would not be cheap for a page that runs six queries to render. If that is your situation, the usual escape hatch is to cache the page and stamp the country into a cookie at the edge so the cached shell can read it.

We deliberately did not do that, for a reason that has nothing to do with performance. Our privacy policy says Munchable sets strictly necessary cookies only, and that this is why there is no cookie banner. A currency preference cookie is not strictly necessary when the request header already answers the question. Writing one would have turned a true statement in our privacy policy into a false one, to save a few milliseconds on a page that was already fast.

The number on the page is not the number we charge

Worth being precise, because this is where the idea gets dangerous if you are sloppy.

We charge in GBP. The subscription is created from an integer number of pence and Stripe's Adaptive Pricing does the real conversion at checkout. Nothing in the display layer touches money movement, the webhook or the entitlement. What we are rendering is a label, and the label says so:

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

Because it is an estimate, the direction of the error is a design decision rather than an accident. Stripe's presented exchange rate embeds a conversion fee of roughly 2 to 4 percent, so a naive mid-market conversion under-quotes what the customer actually sees at checkout. That is the same unpleasant surprise we set out to remove, just smaller. So we apply a buffer at the top of that band and round up, which guarantees the displayed figure is never lower than what Stripe will ask for. Quoting slightly high and charging slightly less is the only safe direction for this error to point.

The rounding is its own pile of sharp edges, including a currency where getting it wrong is a factor of one hundred rather than a few percent. That is the next post.

Try it

munchable.app/#pricing, ideally with a VPN so you can watch it change. If you want to see what the rest of the app does with all this, there is a free tier and no card required to sign up.

Top comments (0)