Notifio is a one-time £20 purchase. The site shows visitors an approximate price in their own currency, because somebody in Berlin reading "£20" has to do a conversion in their head before they can decide whether that is cheap.
Two requests to the same URL, a minute apart, from this machine:
$ curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://notifio.app/pricing | grep -o '£[0-9.]*\|€[0-9.]*' | sort -u
£20
£20.
$ curl -s -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/140.0" \
https://notifio.app/pricing | grep -o '£[0-9.]*\|€[0-9.]*' | sort -u
£1
£20
£20.
€24.99
Same page, same deployment. The crawler is quoted pounds and the human is quoted euros, and the £1 in the second response is the rate note that explains the conversion. Here is the whole mechanism, which is smaller than it sounds.
The resolver
export async function getDisplayCurrency(): Promise<DisplayCurrency> {
const headerList = await headers();
if (isBotUserAgent(headerList.get("user-agent"))) {
return BASE_CURRENCY;
}
// Set by Vercel's edge network on every request. Absent in local development,
// where we correctly fall back to GBP.
return currencyForCountry(headerList.get("x-vercel-ip-country"));
}
Two header reads, one branch. BASE_CURRENCY is "gbp", the currency every Checkout Session is actually created in.
Why crawlers are a special case
A crawler is geolocated wherever its datacentre is. Googlebot fetching our pricing page from a US region looks exactly like a visitor in Oregon, so without the branch above, Google would index dollar prices for a business that charges in pounds.
That is worse than it looks, because the indexed figure would then disagree with three other things on the same page that are not geolocated at all: the FAQ copy, which talks about the £20 licence in words, the SoftwareApplication structured data, whose offers.price is derived from the GBP constant we charge (I wrote that one up in the price in our structured data comes from the constant Stripe charges), and the Stripe page the buyer eventually lands on. A rich result quoting a price the checkout does not charge is the kind of mistake that gets you an angry email before it gets you a sale.
So crawlers get the one currency that can never be wrong: the one the card is debited in.
The detector is deliberately crude
export const BOT_PATTERN =
/vercel|bot|crawler|spider|googlebot|bingbot|slackbot|twitterbot|facebookexternalhit|linkedinbot|whatsapp|headless/i;
export function isBotUserAgent(userAgent: string | null | undefined): boolean {
if (!userAgent) return false;
return BOT_PATTERN.test(userAgent);
}
Twelve alternatives, one case-insensitive flag, and bot already subsumes googlebot and bingbot on its own. The redundancy stays because the list doubles as documentation of who we had in mind.
What matters is which way the false positives point. A real person whose user agent happens to contain one of those substrings gets quoted GBP, with no local figure and no conversion caveat, because there is no conversion to caveat. They see the exact amount their card will be charged. Slightly less convenient, never wrong. A crawler that slips through gets quoted in the currency of its datacentre, which is the outcome we are trying to avoid, so the list leans towards matching too much.
slackbot, twitterbot, facebookexternalhit, linkedinbot and whatsapp are not search crawlers at all. They are link unfurlers, and an unfurl card quoting the wrong currency is a screenshot that circulates for years.
The headless entry catches a lot of automation, including our own: the OG card images on this site are screenshots taken by a headless Chromium, and it sees the site the way a crawler does rather than the way a visitor in whichever region the screenshot job ran in would.
The empty case returns false rather than true, so a missing user agent is treated as a person and gets the country lookup. A request with no user agent is more likely a privacy-conscious browser than a crawler, and a crawler with no user agent still ends up with GBP whenever its IP is unmapped.
The header is untrusted input, and the lookup says so
export function currencyForCountry(country: string | null | undefined): DisplayCurrency {
if (!country) return BASE_CURRENCY;
const key = country.trim().toUpperCase();
// Own-property check, because the input is a header value we do not control
// and `"constructor"` must not resolve to something inherited.
return Object.prototype.hasOwnProperty.call(COUNTRY_CURRENCY, key)
? COUNTRY_CURRENCY[key]
: BASE_CURRENCY;
}
COUNTRY_CURRENCY["CONSTRUCTOR"] is undefined, so the uppercase step defuses most of this on its own, but the own-property check is one line and the alternative is a value from a header indexing an object by prototype chain. Anything unrecognised falls through to GBP, which means an unmapped country sees the exact amount it will be debited. That is the honest direction for the gap to point.
Deciding on the server costs three pages their static rendering
Reading a request header makes the page render per request. The three pages that show a price say so explicitly:
export const dynamic = "force-dynamic";
That is the home page, the pricing page and the download page. Everything else on the site, the fifteen per-portal alert pages, the comparison pages, the guides and the audience pages, is force-static and does not read the request at all, because none of them render a price.
The alternative was resolving in the browser: ship GBP in the HTML, read a country from an API after hydration, swap the number. That paints one price and then changes it, a visible flicker on exactly the figure the visitor came to read, and it depends on JavaScript arriving at all. A page that renders instantly and shows the wrong number is worse than one that renders correctly, so we pay for three dynamic pages.
And no cookie, because the privacy policy says so
The header is read fresh on every request. Nothing is mirrored into a cookie, and nothing needs to be, because nothing here is cached.
That is not only an engineering preference. Our privacy policy says, of the country code Vercel derives from the visitor's IP: "We do not store that country code and we do not save it in a cookie." A currency cookie would be a non-essential cookie, which means a banner, which means a consent decision on a page whose entire job is to show one number clearly.
Carrying the value down is a context with nothing in it
const DisplayCurrencyContext = createContext<DisplayCurrency>(BASE_CURRENCY);
export function DisplayCurrencyProvider({ currency, children }) {
return (
<DisplayCurrencyContext.Provider value={currency}>{children}</DisplayCurrencyContext.Provider>
);
}
export function useDisplayCurrency(): DisplayCurrency {
return useContext(DisplayCurrencyContext);
}
No state, no effect, no fetch, no storage. Each of those would be a way for the price to change after load, which is the thing we gave up static rendering to prevent. The value arrives as a prop from the server component, so it is baked into the HTML and the first client render is already correct.
The default is the interesting line. createContext<DisplayCurrency>(BASE_CURRENCY) means a price component rendered outside any provider shows GBP rather than throwing, rendering blank, or showing a zero. A surface where somebody forgot the provider still shows a real, charged, honest price. The bug becomes "this page is not localised" instead of "this page has no price on it".
One CSS trap worth writing down
The price component renders a bare span with whatever class you pass it, and no wrapper:
export function Price({ pence, className }: PriceProps) {
const currency = useDisplayCurrency();
return (
<span className={className} title={conversionNotice(currency) || undefined}>
{formatForDisplay(pence, currency)}
</span>
);
}
If a pricing block has a rule like .plan-price span { font-size: 14px }, written months earlier to shrink a "/ month" suffix, that rule now also shrinks the headline amount, because the amount has quietly become a span. Nothing errors. The price just gets small. Scope rules of that shape to a class on the suffix rather than to every span in the block.
The conversion caveat rides along in title, so the disclosure follows the number into places with no room for a footnote. A tooltip is not a disclosure on a touch device, so any surface carrying a real purchase decision renders the footnote component too. The pricing page is the one to look at if you want to see all of it at once: the converted figure, the rate note underneath, and the £20 that the card is actually charged.
The charge side of this, where Stripe becomes merchant of record in 27 countries and deliberately not in ours, is a separate story: merchant of record for 27 countries.
Top comments (0)