Run these two commands against our pricing page and you get different numbers back:
curl -s https://pub-trivia.app/pricing \
-H "User-Agent: Mozilla/5.0 (Macintosh)" | grep -o '[£€$][0-9.]*' | sort -u
curl -s https://pub-trivia.app/pricing \
-H "User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
| grep -o '[£€$][0-9.]*' | sort -u
From a machine in Europe the first prints €36.99 and €121.99. The second prints £30 and £100, from the same URL, in the same second. That is deliberate, and the reason is not the prices. It is the JSON-LD sitting a few hundred bytes further down the same document.
What the page shows a person
Prices are stored once, as integer GBP pence, in a module that imports nothing:
/** Pro: one host account, £30/month. */
export const PRO_PRICE_PENCE = 3000
/** Ultimate: up to five host accounts, £100/month. */
export const ULTIMATE_PRICE_PENCE = 10000
A landlord in Dublin reading "£30" has to do the sum before they know whether the plan is affordable, so we show an approximate local figure next to it. The conversion is display only. Every subscription is still billed in GBP against a GBP Stripe Price, and nothing in the localisation path touches money movement.
Because it is approximate, it is rounded in one direction on purpose. Whoever performs the real conversion takes a cut of it: Stripe's presented rate embeds a spread where Adaptive Pricing is in play, and the card issuer's own markup sits in the same band where it is not. A clean mid-market conversion therefore under-quotes what the customer is actually billed, which is the same unpleasant surprise we were trying to remove, just smaller. So there is a fee buffer and a round up, and the guarantee is one sentence: the number we display is never lower than the amount that will be asked for.
(A different app of mine went the other way entirely and replaced conversion with a fixed figure per currency. That is the better answer when the local figure is the price. Here it is a label beside the price, and the two problems want opposite solutions.)
Why the currency is resolved on the server
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'))
}
Resolving this in the browser means the page paints "£30" and then swaps it for "€36.99" once JavaScript has run. That flicker lands on the single number the visitor came to the page to read, and it does not look like localisation, it looks like the price changing while they watch. Reading the header during render removes the whole class of problem: there is one price in the markup, it is correct in the first frame, and it does not depend on JavaScript arriving at all.
The cost is real and worth stating. Touching headers() makes the page render per request, so it gives up its static and ISR caching. That is the intended trade. A page that renders instantly and shows the wrong number is worse than one that renders correctly.
There is also no cookie, which is the obvious way to make this cache-friendly. Every surface that shows a price renders per request anyway, so a country cookie would buy nothing, and a currency-preference cookie is not strictly necessary under UK PECR. Our cookie policy claims we set only essential cookies and therefore show no consent banner. Reading the header directly is what keeps that claim true.
And now the bots
A crawler is geolocated wherever its datacentre is. Googlebot fetching from Iowa looks exactly like a customer in Iowa, so without an exception it would index dollar prices for a business that charges in pounds.
That alone would be bad. What makes it a correctness bug rather than a presentation one is that the same page emits this:
{
"@type": "SoftwareApplication",
"offers": [
{ "@type": "Offer", "name": "Pro", "price": "30", "priceCurrency": "GBP" },
{ "@type": "Offer", "name": "Ultimate", "price": "100", "priceCurrency": "GBP" }
]
}
The offers are quoted in GBP because that is the currency the plans are defined in, and because the price a crawler is told has to be a fixed one. Inventing a per-visitor number here would mean publishing a different price on every crawl, which is not a price.
Structured data has one rule that matters more than the schema: it must describe what is visibly on the page. A crawler served €36.99 in the body and "price": "30", "priceCurrency": "GBP" in the markup is reading a document that contradicts itself. That is a markup violation with a real consequence, because a rich result can be suppressed for exactly that mismatch.
So crawlers get the base currency, which is also the amount actually debited. Both halves of the page then agree, and they agree on the true number.
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)
}
Why that lives in its own module
Fourteen alternatives in a regex is not worth a file. It has one call site. Inlining it would have been fine.
It is a module because of what the answer is. "Should a crawler be quoted GBP" is a product decision about what we publish to search engines, not an implementation detail of the pricing page, and the next surface that needs it, a link preview renderer or an OG card, needs the same answer rather than its own. Putting it behind a named function with a comment explaining the decision means the decision is in one place and can be read.
Note vercel and headless in there alongside the search engines. Platform fetches and headless browsers are not search engines, but they are also not customers in a country, and the thing they have in common with Googlebot is that their geolocation means nothing.
Try it
- Run the two
curlcommands at the top. Same URL, same moment, two currencies. - Then
curl -s https://pub-trivia.app/pricing | grep -o 'priceCurrency":"[A-Z]*"' | sort | uniq -c. Always GBP, from any IP, under any user agent. - Or just load pub-trivia.app/pricing with a VPN and watch the currency follow your exit node, with no flicker on the way in, because the swap happened before the HTML was written.
Top comments (0)