Notifio costs £20 once. It also costs €24.99 once, and its own figure in each of a couple of dozen other currencies, because a visitor from a country with its own price sees and pays that price rather than a conversion of ours at checkout time. That machinery works. The figure on the page, the figure the desktop app shows, and the figure Stripe charges are the same figure, and getting them to agree is most of what our checkout routes do.
Then I fetched all 38 live pages from a European IP and read only the <head> of each one.
The measurement
Pull <title> and <meta name="description"> out of every page and grep for anything that looks like money.
7 of 38 pages carry a price in their metadata, 11 figures between them, in three currencies:
| Page | Figures in the head |
|---|---|
| /pricing | £20, £20 |
| / | £20 |
| /compare/rentbird | €29, £20 |
| /compare/rentslam | €29.95, £20 |
| /compare/stekkies | €29.95, £20 |
| /compare/visualping | $70, £20 |
| /compare/rentola | £20 |
Every price of ours is in pounds. Every price of somebody else's is in whatever currency they publish, which is why there are euros and dollars in there: those come from the comparison pages, where a competitor's subscription is quoted exactly as they advertise it.
Now the other half. Only 3 of 38 pages resolve a price per visitor in the body: /, /pricing and /download. Those three are also, exactly, the three pages that answer no-store on every request, which I measured separately in three of our 38 pages tell every cache not to store them. A page that prints your price cannot be shared with anybody else, so the two sets being identical is the system working.
Intersect the two lists and you get the problem. Two pages are in both.
What that looks like in one response
Here is the pricing page as my browser received it. Title:
Pricing – £20 Lifetime License | Notifio Body, same response, 72 pixels tall:
€24.99 And the homepage, where the price is in the meta description rather than the title:
<meta name="description" content="...alerts you the moment a new listing appears. £20 one-time, plus an optional auto-reply upgrade. Mac and Windows."> while the FAQ a few kilobytes below it says:
Monitoring and alerts are a one-time €24.99 payment for a lifetime licence, with all future updates included.
Neither number is wrong. £20 is the list price and the figure the base currency is set in; €24.99 is what this visitor will actually be charged. They are two true answers to the same question in one document, and the only reason I had never seen it is that I build and browse from the UK, where the two answers are the same string.
The cause is one line of Next.js:
// server/src/app/pricing/page.tsx export const metadata: Metadata = { title: "Pricing – £20 Lifetime License", // ... }; A static metadata export is evaluated once and has no request to read. The body of that same page resolves the currency from request headers on every hit. So the head is a build-time artefact sitting on top of a request-time page, and a figure typed into it can be overtaken by the page it describes without anything failing anywhere. These three pages are already dynamic, so generateMetadata could read the same headers and produce a localized title. The question is whether it should.
Why localizing the head is the wrong fix
My first instinct was to make the head follow the body on those three pages. That instinct is wrong, for a reason I have run into before on this site: Googlebot lives in a datacentre.
A search engine indexes one version of a page. If the title is a function of the requester's location, the title that gets indexed is a function of where the crawler happened to be, which is not a property of our product at all. I would be replacing a figure that is wrong for some readers with a figure that is unpredictable for everybody, including in the place it is most often read: the link preview a customer gets when somebody shares our pricing page in a chat.
The structured data makes the same point from the other direction. 33 of the 38 pages carry a schema.org Offer, and every one of them says "price": "20.00", "priceCurrency": "GBP", which I counted in 33 pages on our site carry an Offer pointing at /pricing. A price in structured data has to be one number with one currency. There is no version of that field that means "it depends". The list price in the list currency is the only honest value it can hold, and it stays.
So the fix goes the other way: the three pages that resolve a price at request time stop naming a figure in their own head. "Pricing – Lifetime Licence, One Payment" says everything the old title said except the part that can be contradicted by the page below it. The four comparison pages keep their £20, because those pages are static, print £20 in their bodies too, and are internally consistent for every visitor.
As a bonus, this buys back characters in the one place I am always short of them. Every page on this site has to come in under 60 characters of title and 160 of description, enforced by a test, and ten of those sixty are spent on the | Notifio suffix the root layout appends.
The test I did not have
The suite that guards this site is almost entirely about money and search engines, which is the right pair of subjects for a one-product marketing site. It checks that every title fits, that every description fits, that the currency table agrees with Stripe about which currencies have decimals. It had no opinion at all about a currency symbol in a metadata string, because nothing in the type system distinguishes "a string that is shown to everybody" from "a string that sits next to a number resolved per request".
Ten lines fixes that:
const PER_VISITOR_PRICE_ROUTES = ["/", "/pricing", "/download"]; for (const route of PER_VISITOR_PRICE_ROUTES) { const { title, description } = metadataFor(route); assert.doesNotMatch(`${title} ${description}`, /[£€$]\s?\d/, `${route} resolves its price per visitor, so its metadata must not name one`); } What I like about that assertion is that it encodes the decision rather than the symptom. The next person to add a price to the homepage title is not making a typo, they are making a reasonable SEO edit that happens to be invalid for this one route family, and the failure message tells them why in one sentence.
What I do not like is that the list of three routes is typed out by hand, which means a fourth page that starts showing a local price will not be covered until somebody remembers. The honest version of this test derives that list from the same place the pages do, and that is the next piece of work.
You can check all of this yourself: open /pricing and compare your browser tab with the number on the card, then look at /compare/rentslam, where the tab and the page agree. The app is on /download.

