Back to engineering

Internationalization

Localizing into 13 languages without /[locale] routes

The Bookatu engineering team7 min read

How Bookatu ships its UI in 14 languages with no /[locale] URL segment: a pure deep-merge catalog, a visitor-locale resolver over header, cookie and geo, and SEO bolted on later behind a flag.

Most Next.js i18n guides start by carving a `[locale]` segment into the route tree. You end up with `/en/pricing`, `/de/pricing`, a `generateStaticParams`, and every link in the app suddenly needs to know the current locale to build a correct href. That works when the whole product is one big localized site. Bookatu isn't shaped that way.

We have two very different kinds of surface. There's our own marketing, signup and app chrome, where the language should follow the visitor. And there's each tenant's public storefront, which is the tenant's business, in the tenant's chosen language and money format, no matter who's looking at it. A single URL-driven locale would conflate the two: a German visitor browsing a New Zealand salon's booking page shouldn't flip that salon into German. So we kept locale out of the URL and made it a data concern instead.

The gist

  • Two locale planes: the org's locale formats a storefront, the visitor's locale translates our own surfaces. Neither lives in a route segment.
  • The resolver is a pure, dependency-free function: exact locale, then language prefix, then English, with per-key fallback inside each group.
  • A partial translation is always safe. A missing key, or a whole missing section, silently shows English.
  • Per-language URLs for SEO came later, as a flag-gated proxy rewrite, not a rewrite of the route tree.

One catalog, deep-merged onto English

The English catalog is the source of truth. It's a plain object grouped by surface (`storefront`, `booking`, `auth`, `signup`, and so on), and its type comes straight from the value:

ts
export const en = {
  storefront: { bookNow: "Book now", backToHome: "Back to home" },
  auth: { signIn: "Sign in", signingIn: "Signing in…" },
  // …
};

// NOT `as const`: values are typed as `string`, so a translated
// catalog can hold its own text while keeping the exact key shape.
export type Messages = typeof en;

export type DeepPartial<T> =
  { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
export type LocaleCatalog = DeepPartial<Messages>;

That comment about `as const` matters more than it looks. With `as const`, `bookNow` would have the literal type `"Book now"`, and a German catalog assigning `"Jetzt buchen"` would fail to type-check. Leaving it off makes every value a `string`, so a translation keeps the exact key structure while carrying different text. The `DeepPartial` on top lets a translator override only what they've done so far.

Resolution itself is tiny and pure, which is the point. It runs unchanged in server and client components, with no provider and no async:

ts
export function getMessages(locale?: string | null): Messages {
  if (!locale) return en;
  const lc = locale.toLowerCase();
  const override = LOCALES[lc] ?? LOCALES[lc.split("-")[0]];
  return override ? merge(en, override) : en;
}

So `getMessages("fr-CA")` looks for an exact `fr-ca` catalog, falls back to the `fr` language catalog, and finally to English. The `merge` is a shallow merge at the group level: for each translated group it spreads the override over the English group of the same name (`{ ...en[group], ...override[group] }`). Because the catalog is one level of nesting deep, that's enough. If French translated `auth` but not `booking`, the whole `booking` group comes through from English. If it translated `auth.signIn` but not `auth.signingIn`, that single missing key falls back too.

A missing key, or an entire missing section, silently renders English. A half-finished translation never breaks a page, and adding a new English string never breaks an existing catalog's build.

The visitor plane: resolving a language without a URL

If the locale isn't in the URL, something has to decide it per request. For our own surfaces that's `getVisitorLocale`, a server-only function that reads request state in a strict priority order:

  • An internal locale request header, when present. The proxy sets this on a per-language URL, and it's the strongest, most explicit signal.
  • A pinned locale cookie, set when the visitor uses the language toggle or has visited a localized URL before. An explicit choice beats detection.
  • Geo: Vercel's `x-vercel-ip-country` header, mapped to a default business language for that country.
  • The `Accept-Language` header's first entry.
  • English.
ts
export async function getVisitorLocale(): Promise<SupportedLocale> {
  const h = await headers();

  const fromPath = h.get(localeHeaderName); // set by the proxy on /de/... URLs
  if (fromPath) return normalizeLocale(fromPath);

  const pinned = (await cookies()).get(localeCookieName)?.value;
  if (pinned) return normalizeLocale(pinned);

  const geo = localeForCountry(h.get("x-vercel-ip-country"));
  if (geo !== "en") return geo;

  const accept = h.get("accept-language");
  if (accept) {
    const first = normalizeLocale(accept.split(",")[0]);
    if (first !== "en") return first;
  }
  return "en";
}

The country mapping is deliberately conservative. We only map countries Stripe supports, and multilingual countries pick the most common business language (Switzerland and Austria go to German, Belgium and Luxembourg to French) on the understanding that the visitor can always switch. Anything unmapped, including the US, UK, Ireland, Australia and New Zealand, stays English. `normalizeLocale` strips any region or casing (`fr-CA`, `FR_fr`) down to a supported two-letter code, or English if we don't have it.

A server component resolves this once and hands `{ locale, messages }` to a thin client `LocaleProvider`. Client components then read strings with `useMessages()`. The provider defaults to English so a component used outside a provider still renders, and on a localized route it nudges `document.documentElement.lang` to match, so screen readers and JS-rendering crawlers see the real language.

Why cookie-and-geo localization is invisible to Google

The cookie-plus-geo approach is great for humans and useless for Googlebot. The crawler arrives with no cookie, crawls predominantly from the US, and sends a generic `Accept-Language`. Every one of our detection signals resolves it to English. So however many languages we render for real visitors, Google only ever indexes the English version. There's nothing for it to discover for the other thirteen.

The fix is the one thing we'd avoided: distinct URLs per language. But we added them as a narrow, reversible layer rather than re-architecting routing. English stays unprefixed, so every existing URL is byte-for-byte unchanged. The thirteen other languages get a `/<lang>` prefix, and only on marketing roots we explicitly allow-list (the home page, `pricing`, `discover`, `blog`, `help`, `faq`, plus `developers` and `compare`). Tenant storefronts, `/admin`, `/api` and auth are never prefixed, so an org whose slug happens to be two letters is never shadowed.

A flag-gated proxy rewrite, not a route segment

The whole feature lives in the proxy (Next 16's renamed `middleware.ts`) and is gated by a build-time flag. When the flag is off, the block is skipped entirely and routing is unchanged. When it's on, a localized request is rewritten back to its canonical English path, with the locale handed downstream as a header:

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);  // feeds getVisitorLocale
    const url = req.nextUrl.clone();
    url.pathname = path;                            // /de/pricing -> /pricing
    const res = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
    res.cookies.set(localeCookieName, locale, { path: "/", maxAge: 31536000, sameSite: "lax" });
    return res;
  }
}

So `/de/pricing` is served by the existing `/pricing` page. No `[locale]` directory, no duplicated routes, no `generateStaticParams`. The page tree never learns about locales; it just reads the resolved messages as before. The proxy also pins the cookie, so once a visitor lands on a German URL, their in-language navigation to unprefixed internal links stays German via the cookie branch of the resolver.

The careful bit is `splitLocalePath`. It only treats a leading segment as a locale when the following segment is one of the allow-listed marketing roots. `/de/pricing` parses as German pricing; `/de/some-org` is left untouched, because `some-org` isn't a marketing root, so a tenant storefront can never be mis-parsed as a localized page.

To make the languages actually indexable, the sitemap emits `hreflang` alternates for exactly those localized paths, each variant self-canonical, plus an `x-default` pointing at English. The toggle closes the loop on the client: when per-language URLs are on and you're on a localizable page, picking a language navigates to the prefixed URL, so the URL is shareable and crawlable; everywhere else it just pins the cookie and refreshes.

What this bought us, and what we'd watch

The payoff is that localization stayed a data problem. Adding a language is dropping in one small per-language catalog file and registering it; nothing at the call sites changes, and untranslated keys are safe by construction. The visitor plane and the org plane never fight, because the same `getMessages` serves both from different inputs. And the SEO layer that finally put locale into the URL did so without touching the route tree, behind a flag we can switch off in one line.

The honest trade-offs: the group-level merge assumes a one-level-deep catalog, so if we ever nest a third level we'd need a real recursive merge or we'd silently drop English siblings. Geo-defaulting a language is a guess, and the cookie is the only thing that makes it stick, so a visitor who clears cookies gets re-detected each time. And keeping the translated catalogs honest against a moving English baseline is a process problem more than a code one; the type system guarantees they compile, not that they're complete or well translated. For now, English-as-floor is the safety net we want while the catalogs fill in.

internationalizationnextjsi18narchitectureseo

Building on Bookatu?

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

Developer docs