Back to engineering

Internationalization

Per-language URLs for SEO via a proxy rewrite

The Bookatu engineering team7 min read

Cookie-based localisation is invisible to Googlebot, so it only ever indexed our English pages. Here's how we made 14 languages crawlable with a flag-gated proxy rewrite and zero new routes.

Bookatu's marketing pages already spoke 14 languages. A visitor from Berlin saw German, a visitor from Lisbon saw Portuguese. We picked the language from a cookie the visitor set with a toggle, or fell back to their country from a geo header. That worked well for humans and did nothing for search.

The trouble is that Googlebot is not a normal visitor. It carries no cookie, and it crawls from the US, so our geo detection hands it English every single time. Every localised page we rendered collapsed into the same English URL in the index. We were translating content nobody could find. The fix is well understood in theory (give each language its own URL), but the interesting part was doing it without forking our route tree, without breaking a single existing English URL, and behind a flag we could turn off instantly if it misbehaved.

The gist

  • Cookie and geo localisation is invisible to crawlers, so only English ever got indexed.
  • Non-English languages get a /<lang> URL prefix; English stays unprefixed, so no existing URL changes.
  • A proxy rewrite maps /de/pricing back to the canonical /pricing page and passes the locale via a request header, not a new route.
  • The whole thing is gated on a build-time flag, so 'off' is byte-for-byte the old behaviour.

English unprefixed, the rest prefixed

The first decision was the URL shape. The common pattern is /[locale] for everyone, including /en. We rejected that because it would 301 every English URL we already have, throw away the ranking those URLs have built up, and force a redirect on our highest-traffic pages. English is the default and the fallback everywhere else in the system, so it keeps its bare URLs. The other 13 languages each get a /<lang> prefix.

ts
export function localizedPath(canonicalPath: string, locale: SupportedLocale): string {
  if (locale === "en") return canonicalPath || "/";
  const p = canonicalPath === "/" ? "" : canonicalPath;
  return `/${locale}${p}`;
}
// localizedPath("/pricing", "en") -> "/pricing"
// localizedPath("/pricing", "de") -> "/de/pricing"
// localizedPath("/",        "de") -> "/de"

The language list is the source of truth. Prefixes are derived from it by filtering out English, so adding a fifteenth language is a one-line change to the names map and the URL scheme follows for free.

Only some paths are eligible

Bookatu is multi-tenant. Tenant storefronts live at /[org], so /de could just as easily be an org whose slug happens to be 'de'. Admin lives at /admin, the API at /api. None of those should ever be read as a language prefix. So the split is deliberately narrow: a leading segment counts as a locale only when the next segment is a known marketing root.

ts
const LOCALIZED_ROOTS = new Set([
  "", "pricing", "discover", "blog", "help", "faq", "developers", "compare",
]);

export function splitLocalePath(pathname: string) {
  const parts = pathname.split("/");        // ["", "de", "pricing"]
  const first = parts[1] ?? "";
  if (isLocalePrefix(first) && LOCALIZED_ROOTS.has(parts[2] ?? "")) {
    const rest = "/" + parts.slice(2).join("/");
    return { locale: first, path: rest === "/" ? "/" : rest.replace(/\/+$/, "") };
  }
  return { locale: "en", path: pathname };  // untouched
}

So /de/pricing splits to { locale: 'de', path: '/pricing' }, and /de/some-org splits to { locale: 'en', path: '/de/some-org' } and passes through unchanged. The empty string is in the set so /de (the localised home page) resolves to /. An org slug that collides with a two-letter language code is never shadowed, because the segment after it won't be a marketing root.

The rewrite lives in the proxy

Next 16 renamed middleware.ts to proxy.ts, and it always runs on the Node.js runtime. Our proxy already handled custom domains, so the locale routing slots in as the very first block. When a request comes in for a prefixed marketing path, we rewrite the URL back to its canonical form and hand the locale to the page through a request header. A rewrite, not a redirect, so the prefixed URL stays in the address bar and in the index. Rewrites don't re-invoke the proxy, so there's no loop to guard against.

ts
if (localeUrlsEnabled) {
  const { locale, path } = splitLocalePath(req.nextUrl.pathname);
  if (locale !== "en" && path !== req.nextUrl.pathname) {
    const requestHeaders = new Headers(req.headers);
    requestHeaders.set(localeHeaderName, locale);
    const url = req.nextUrl.clone();
    url.pathname = path;
    const res = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
    res.cookies.set(localeCookieName, locale, { path: "/", maxAge: 60 * 60 * 24 * 365, sameSite: "lax" });
    return res;
  }
}

Two things happen besides the rewrite. We set an internal locale request header so the page knows which language to render, and we pin the locale cookie. The cookie matters for what comes next. Once you've landed on a German page, the in-page nav links point at unprefixed canonical paths (the nav doesn't rewrite every href), so without the cookie your next click would silently drop you back to English. Pinning it keeps in-language navigation in language.

On the server side, the header wins over everything. The resolver checks the URL-derived header first, then the cookie, then geo, then Accept-Language, then English. The header is the strongest signal precisely because it's the one a crawler can act on without state.

ts
const fromPath = h.get(localeHeaderName);
if (fromPath) return normalizeLocale(fromPath);   // URL beats cookie beats geo
const pinned = (await cookies()).get(localeCookieName)?.value;
if (pinned) return normalizeLocale(pinned);
// ...geo, then Accept-Language, then "en"

Telling search engines which page is which

Distinct URLs are only half of it. Without hreflang, Google sees a dozen near-identical pages and may pick the wrong one for the wrong region, or treat them as duplicates. So each localised page emits a self-referencing canonical plus the full set of language alternates, including x-default pointing at English.

ts
export function localeAlternates(base: string, canonicalPath: string, currentLocale = "en") {
  const languages = { "x-default": `${base}${localizedPath(canonicalPath, "en")}` };
  for (const code of Object.keys(LOCALE_NAMES))
    languages[code] = `${base}${localizedPath(canonicalPath, code)}`;
  return { canonical: `${base}${localizedPath(canonicalPath, currentLocale)}`, languages };
}

A page feeds its own resolved locale in as currentLocale, so /de/pricing canonicals to itself and not to the English page. The pricing page wires this straight into Next's metadata as alternates: { canonical, languages }. The sitemap does the same, but with one honest restriction: it declares hreflang alternates only for paths whose bodies are actually translated today (home, FAQ, pricing). Advertising a /de/blog that still renders English would be lying to the crawler, and Google drops hreflang clusters where the return tags don't line up.

Flag-gated, so 'off' is exactly the old behaviour

Everything above hangs off one build-time flag, wired so the proxy and the client toggle agree at build time. When it's off, the proxy block is skipped entirely (the routing is byte-for-byte what it was), the sitemap emits no alternates, and the language toggle falls back to its old job of just setting the cookie and refreshing. When it's on, the toggle navigates to the prefixed URL instead, so the address bar reflects the language and the page is shareable.

The flag isn't a feature toggle so much as a blast-radius control. The proxy runs on every matched request, so 'instantly revertible' was a hard requirement, not a nicety.

We leaned on this gate because the proxy sits in the hot path. The matcher already excludes /api, Next internals, and anything with a dot in it, and the locale block only acts on a non-English prefix followed by a known root. But a routing bug here would hit every page, so being able to flip back to a pure pass-through with one env var was worth more than any elegance.

What's next

The plumbing is done and live behind the flag: URLs, rewrites, canonicals, hreflang, sitemap, and a toggle that navigates. The honest gap is content. Right now only the home page, pricing, and FAQ have fully translated bodies, which is why the sitemap only advertises alternates for those three. The next round of work is unglamorous: translate the remaining marketing bodies and add each path to the localised set as it lands, so the hreflang clusters grow to match what we actually serve. The architecture was the interesting part. Filling it in is just typing.

internationalizationseonextjsproxyhreflang

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs