DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three of our pages render per request and thirty-five do not, and the only thing deciding that is whether a price is on screen

Notifio is a £20 one-time purchase, charged in GBP in every country, and the website shows visitors an approximate figure in their own currency. I wrote about the conversion itself in Our displayed price is allowed to be wrong in one direction only. This post is about where the decision is made, which turned out to be the more expensive question.

The site has thirty-eight pages in its sitemap. Three of them render per request. The other thirty-five are static. And the thing separating the two groups is not interactivity, or freshness, or personalisation. It is whether there is a price on the page.

The option I did not take

Resolve the currency in the browser. It is the easy version: ship a static page, read a locale or call something on mount, swap the number.

What that produces is a page that paints £20, then paints €24.99 a moment later. The one number the visitor came to read is the one number that visibly changes under them. And it changes only if JavaScript arrives, so the page has two correct-looking states and one of them is a lie depending on network conditions you do not control.

A price that flickers is worse than a price in the wrong currency, because the second is merely inconvenient while the first looks like the site is deciding what to charge you.

So it is resolved on the server

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"));
}
Enter fullscreen mode Exit fullscreen mode

The country arrives as a header from the edge. No geolocation API, no IP database in the app, no client round trip. The value then travels to the components as a prop on a provider:

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

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

and the provider is as inert as a provider can be:

const DisplayCurrencyContext = createContext<DisplayCurrency>(BASE_CURRENCY);

export function DisplayCurrencyProvider({ currency, children }: Props) {
  return (
    <DisplayCurrencyContext.Provider value={currency}>{children}</DisplayCurrencyContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

No effect, no fetch, no storage. Each of those is a mechanism by which the price could change after load, which is the thing being eliminated. The components that read it are client components for exactly one reason, which is that they need useContext, and they render the same output on the server as they do on the client. There is no hydration correction to see because there is nothing to correct.

The default is the charge currency:

/** The visitor's display currency. Always defined; falls back to GBP. */
export function useDisplayCurrency(): DisplayCurrency {
  return useContext(DisplayCurrencyContext);
}
Enter fullscreen mode Exit fullscreen mode

A surface that forgets to wrap itself in the provider shows £20, which is the amount actually debited. That is the right failure: a missing provider produces a correct price in the wrong currency, never a blank, never a zero, and never a figure lower than the charge.

The bill

Reading a request header makes the calling page render per request. In Next.js that is explicit:

/**
 * Rendered per request so the price is localised in the first frame.
 *
 * `getDisplayCurrency` reads the visitor's country from the request, which
 * forfeits this page's static rendering. That is the intended trade: showing a
 * visitor a price in a currency they have to convert in their head, or worse
 * flickering from one to the other after hydration, costs more than a
 * per-request render of a page whose whole job is to state a price.
 */
export const dynamic = "force-dynamic";
Enter fullscreen mode Exit fullscreen mode

Three pages carry that line. /, /pricing, and /download. The third one is the clearest illustration of how narrow the rule is, because the download page is not a pricing page:

/**
 * Rendered per request so the licence price in the footer link is localised.
 * Same trade as `/` and `/pricing`: correctness of the advertised figure over a
 * static render. See `lib/pricing/server-currency`.
 */
export const dynamic = "force-dynamic";
Enter fullscreen mode Exit fullscreen mode

One price, in one link, near the bottom. That is enough to give up the static render of the whole page, because the alternative is a page where one number out of forty is not what the visitor would be charged.

Everything with no price on it is nailed down the other way:

export const dynamic = "force-static";
Enter fullscreen mode Exit fullscreen mode

That line is on the alerts hub, the per-site pages, the guides, the comparison pages and the audience pages. It is not the default being restated for decoration. Those pages are built from TypeScript literals, they change when I deploy, and marking them explicitly means a future edit that casually reads a header or a cookie fails the build instead of quietly turning thirty-five static pages into thirty-five server renders. The expensive mistake in this setup is not a page that should be static and is not; it is a page that becomes dynamic without anybody noticing.

You can see the split in the response headers. notifio.app/pricing comes back with cache-control: private, no-cache, no-store, max-age=0, must-revalidate and x-vercel-cache: MISS, because the response is specific to the request that asked for it and there is nothing to reuse. notifio.app/alerts comes back public with x-vercel-cache: HIT and an age in the tens of thousands of seconds, because it was rendered at build time and has been served from the edge ever since.

No cookie

The header is read fresh on every request and nothing is written down:

 * The header is read directly on every request. Nothing is mirrored into a
 * cookie, because nothing here is cached and so nothing needs to be: a currency
 * cookie would be a non-essential cookie, which is more than the privacy policy
 * claims we set and more than this feature needs.
Enter fullscreen mode Exit fullscreen mode

The usual reason to mirror a resolved value into a cookie is to avoid redoing the work behind a cache. There is no cache here. The page is already rendering per request, the header is already present on that request, and the lookup is a property access on an object literal. A cookie would add a consent obligation in exchange for saving nothing measurable.

That constraint came from the privacy page rather than from engineering. The site says it sets no non-essential cookies. A currency cookie is non-essential by any reading, so either the sentence changes or the cookie does not exist. The cookie does not exist.

Crawlers get pounds

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);
}
Enter fullscreen mode Exit fullscreen mode

Crawlers are geolocated to whichever datacentre they ran from. Googlebot fetching from a US region would be served dollars, and then the indexed price for a business that charges in pounds is a dollar figure that nobody is ever charged.

Worse, it would disagree with the rest of the same page. The metadata, the FAQ copy and the structured data all state £20 as a literal:

 * Only the price *figures* are localised. The FAQ copy, the crawler-facing
 * summary block and the FAQPage structured data all keep their literal £20,
 * because those strings are indexed and the structured data has to match the
 * text beside it, and £20 is the amount actually charged in every country.
Enter fullscreen mode Exit fullscreen mode

A page whose offers.price says 20.00 GBP while its visible headline says $28.99 is a page making two claims about the same product. Serving the base currency to crawlers makes the indexed version of the page internally consistent, and the indexed version is the one that gets quoted in a search result months later.

This is checkable from a terminal. Request /pricing with a Googlebot user agent and the figure in the HTML is £20. Request it with an ordinary browser user agent from a euro country and you get €24.99 plus the footnote explaining the conversion. Same URL, same deploy.

Two entries in that pattern are there for less obvious reasons. headless catches headless Chrome, which is what most preview and screenshot tooling identifies as, and vercel catches the platform's own fetches, including the ones that generate OG images. An OG card with a converted price baked into it would be a price frozen at whatever currency the render happened to run in, shown to everybody.

Where it is still wrong

Three cases, all of which I have decided to live with.

A visitor on a VPN sees the currency of their exit node. There is nothing in the request that distinguishes them from a real visitor in that country, and no amount of server-side cleverness changes that.

A real visitor whose ISP geolocates to a datacentre may trip the same path as a crawler if their user agent happens to contain a matching substring. That pattern is a regex over a string anyone can set. The consequence is that they see £20, the real price, so the blast radius is one slightly less convenient page.

And a visitor in a country not in the table sees GBP. The table covers twenty-four currencies; everywhere else falls through:

export function currencyForCountry(country: string | null | undefined): DisplayCurrency {
  if (!country) return BASE_CURRENCY;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Every one of those three failures lands on the same value, which is the amount actually charged. That is the property worth having. When a geolocation guess goes wrong, the fallback is not a default currency chosen for convenience, it is the one number on the page that is true regardless of where the request came from.

If you want to compare the two rendering modes on the live site, notifio.app/pricing is one of the three per-request pages and notifio.app/alerts is one of the thirty-five static ones. The static set is where most of the site's content lives, which I wrote about in Fifteen required fields and no optional ones, because the type decides whether a site gets a page.

Top comments (0)