I wrote earlier about Googlebot indexing the wrong currency. That was one bug. This is the full checklist of what per-request personalisation does to your search presence, and the four fixes we ended up needing.
We localise the price on our landing page by reading the visitor's country from a CDN header. It is a nice touch for humans and an excellent way to quietly damage your search presence, because Googlebot is a visitor too, and Googlebot lives in a datacentre.
Here is the failure mode, in order:
- A crawler fetches your pricing page from a US datacentre.
- Your geo logic does exactly what you told it to and serves
$14.99. - Google indexes your business, which charges in pounds, as an American product with an American price.
- Your
Productstructured data, generated separately, still saysGBP. - Google notices the rendered price and the declared price disagree.
Step 5 is the expensive one. A price in structured data that does not match the price on the page is a documented reason for rich results to be suppressed, and in the worse cases it is a manual action rather than a silent demotion. You have not gamed anything, you have just built two code paths that answer the same question differently.
You can see the fixed version on munchable.app/#pricing. A VPN to Germany or Japan changes what you see. curl with a bot user agent does not.
Fix one: crawlers get the currency you actually charge in
One branch, at the top of the 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'));
}
BASE_CURRENCY is GBP, the currency the subscription is genuinely priced in and the one our structured data declares. So a crawler gets one stable answer no matter which datacentre it is calling from.
It is worth saying clearly what this is and is not. This is not cloaking. Cloaking is showing a crawler different content to the content a user gets in order to rank for things the page does not say. We are showing the crawler the canonical version of the same fact: the real price, in the real billing currency, which is also what a UK visitor sees. Every feature, claim and link is identical. If anything, the crawler gets the more honest page, because a human in Frankfurt is the one seeing an approximation.
The detector itself is dull on purpose:
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);
}
Two details in there earn their place. vercel catches our own platform's internal fetches, which would otherwise be geolocated to a build region. slackbot, twitterbot, facebookexternalhit, linkedinbot and whatsapp are the unfurlers: when someone pastes your pricing link into a group chat, the preview card is generated from wherever that company's scraper runs. Without those entries, a link shared in a London office can unfurl with a Brazilian real price in the preview, which looks like a bug to everyone who sees it.
Crucially, this lives in its own module because two callers need the identical answer: the currency resolver, and the edge proxy, which has no reason to do geo work for a datacentre crawler either. Two regexes that are supposed to agree will eventually disagree.
Fix two: resolve once, feed both the markup and the JSON-LD
The structured data mismatch is not really a crawler problem, it is a duplication problem. If the page computes the currency and the JSON-LD builder computes the currency, they are two sources of truth for one fact, and they will drift the first time somebody edits one.
So resolve it once, at the top of the page, and pass the same value into both:
export default async function Home() {
// Resolved once and fed to both the JSON-LD and the rendered section, so the
// structured data can never quote a price the page does not show.
const currency = await getDisplayCurrency();
const questions = faqs(currency);
return (
<main>
<JsonLd data={jsonLdGraph(softwareApplicationLd(), faqLd(questions))} />
<Pricing />
</main>
);
}
Note that the FAQ copy takes the currency too. Our FAQ literally contains the sentence "£10 a month for unlimited scanning", and that sentence is also emitted as FAQPage structured data. If the headline price localises and the FAQ answer does not, you have shipped a page that contradicts itself in two places at once, one of them machine readable.
Fix three: decide what the cache is allowed to do
Personalised content and static caching are directly opposed, and the failure is silent: whichever visitor warms the cache decides the currency for everyone behind that edge node.
export const dynamic = 'force-dynamic';
Pay this cost knowingly. Our homepage does no database work, so rendering per request is close to free, and a page that renders instantly with the wrong number is worse than one that renders correctly. If your pricing page is expensive to render, the alternative is to cache the shell and vary the price fragment, but do not let a page that reads request state keep a shared cache entry.
Fix four: only the real deployment may be indexed
Slightly off topic and far too common to leave out. Every preview deploy and every branch gets its own public hostname, and every one of them is a complete copy of your site competing for your own queries.
export const IS_INDEXABLE =
!process.env.VERCEL_ENV || process.env.VERCEL_ENV === 'production';
Wire that into robots.txt and your metadata, and your staging environment stops splitting your ranking signals with you.
The general principle
Any per-request personalisation, currency, language, region or A/B bucket, needs an explicit answer to a question most implementations never ask: what does a crawler get? If you have not decided, the answer is "whatever your IP database thinks about a server rack in Virginia", and that answer is now in the index under your domain.
Working example at munchable.app/#pricing. If you want to see what our crawler-facing pages look like when they are the actual product rather than an afterthought, we have a few hundred of them at munchable.app/answers, and every verdict on them is generated by the production rules engine rather than written by hand.
Top comments (0)