---
title: URLs in Eight Languages
date: '2026-10-10'
description: 'Secondbook speaks eight languages, and so do its URLs. One registry drives every link, redirect, guard and robots.txt line, with a single folder per page.'
author: Pablo Fernandez
tags:
  - Next.js
  - i18n
  - Routing
  - TypeScript
  - SEO
source: 'https://www.pablofr.com/blog/one-page-eight-urls'
---
Open `/es/sobre-nosotros` on Secondbook and you get the about page in Spanish. Open `/de/ueber-uns` and you get the same page in German. Now go looking for either of those in the code. There is no `sobre-nosotros` folder, and no `ueber-uns` one. There is `app/[lang]/(public)/about/`, one folder with an English name, and it answers to eight different URLs.

That is the whole trick, and it sounds like a one-liner. It isn't. The [Next.js App Router](https://nextjs.org/docs/app/getting-started/layouts-and-pages) maps folders to URLs one to one, and the moment a URL stops matching its folder, a lot of things that used to come for free stop working. Which page is `/es/cuenta/perfil`? Is it private? Should search engines crawl it? What happens to the link in an email you sent last month, after you rename the page? Where does a component get the right link to it, in the visitor's language?

So the question in this post: how do you translate every URL in an app without the translation leaking into every corner of the code? Secondbook has 103 pages in eight languages, and the answer it settled on is a single file that everything else is derived from.

## Why the obvious answers fail

There are a few ways to get localized URLs in Next.js, and each one breaks at a different size.

- **One folder per language:** `app/es/sobre-nosotros/`, `app/de/ueber-uns/`, each re-exporting the same page. At 103 pages and eight locales that's 824 folders, and every new page is eight new folders that have to stay in step.
- **Rewrites in `next.config`:** the [`rewrites`](https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites) option can map `/es/sobre-nosotros` to `/es/about`. It handles serving. It knows nothing about who may open the page, what happens to last year's slug, or how a link gets built in the first place.
- **A catch-all route:** one `[...slug]` page that looks the path up and renders the right component. It works, and you've given up the file-system router, its layouts and its loading states, to build a worse one by hand.
- **Translate links at the call site:** a dictionary of slugs that each component reads when it builds an `href`. Fine until someone writes `` `/${locale}/account` `` by hand, and someone always does.

The pattern underneath all four: a URL in Secondbook isn't one fact, it's several. It decides which folder renders, who may see it, what a crawler may index, where old forms of it redirect, and what analytics calls it. Spread those decisions over several mechanisms and they drift.

## One registry, everything derived

Secondbook keeps one table, `route-slugs.ts`, in the shared package. Every translated page is an entry keyed by its folder path, with its slug in each language and who may open it. Nothing else in the app knows a localized slug. Everything else reads them from here.

```
                  ┌─────────────────────────┐
                  │      route-slugs.ts     │
                  │  103 pages × 8 locales  │
                  └────────────┬────────────┘
                               │
          ┌─────────────┬──────┴──────┬─────────────┐
          ▼             ▼             ▼             ▼
    ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
    │   ROUTES  │ │   proxy   │ │   robots  │ │ analytics │
    │   links   │ │  301s and │ │  Disallow │ │   access  │
    │    out    │ │  rewrites │ │   lines   │ │   checks  │
    └───────────┘ └───────────┘ └───────────┘ └───────────┘
                  resolveRequest
```

*One table of pages; links, the proxy, robots.txt and analytics all read from it.*

Two directions come out of it. Going out, `ROUTES` turns "the about page, in Spanish" into `/es/sobre-nosotros`. Coming in, `resolveRequest` turns `/es/sobre-nosotros` back into "the about page, public, served from `/es/about`". Because both directions read the same table, they can't disagree.

## A page is an entry

Here is one entry, exactly as it sits in the file:

```typescript:route-slugs.ts
export type RouteAccess = 'public' | 'auth' | 'private' | 'staff' | 'transactional';

export interface RouteEntry {
  access: RouteAccess;
  /** Lowercase ASCII kebab segments, without the locale: `cuenta/perfil`. */
  slugs: Record<SupportedLocale, string>;
  /** Slugs this page had before, per locale; they 301 to today's. */
  former?: Partial<Record<SupportedLocale, readonly string[]>>;
}

export const ROUTE_SLUGS = {
  about: {
    access: 'public',
    slugs: {
      en: 'about',
      es: 'sobre-nosotros',
      pt: 'sobre-nos',
      fr: 'a-propos',
      it: 'chi-siamo',
      de: 'ueber-uns',
      nl: 'over-ons',
      pl: 'o-nas',
    },
  },
  // ...
} satisfies Record<string, RouteEntry>;
```

The key, `about`, is the folder under `app/[lang]/`, with route groups like `(public)` left out. `slugs` is a `Record` over every supported locale, so a page with a missing translation doesn't compile. `access` is one of five kinds: anyone, the sign-in pages, a session, a staff token, or a checkout flow that anyone may open but nobody should find through a search engine.

The `satisfies` at the bottom is doing quiet work. Annotating the object as `Record<string, RouteEntry>` would widen its keys to `string`, and then `localizedHref('serch', locale)` compiles even though no page called `serch` exists. The typo would only show up as a 404, after someone clicked the link. With [`satisfies`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator) the object is checked against the shape but keeps its literal keys, so `keyof typeof ROUTE_SLUGS` is a union of the 103 real pages and a typo is a compile error.

## Reading a path back

Going from a URL back to a page is the interesting direction, because a URL can arrive in more shapes than the one you'd link to. Someone types the English folder path. Someone switches the language and keeps the Spanish slug. An email from before a rename still carries the old one. The resolver treats all of these as forms of the same page and ranks them. For a request under `/es/`, a form can be:

| Path asked for | What it is | Answer |
|---|---|---|
| `/es/sobre-nosotros` | this locale's slug | served from `/es/about` |
| `/es/reservar/exito` | this locale's former slug | 301 to `/es/reservar/estado` |
| `/es/about` | the folder path | 301 to `/es/sobre-nosotros` |
| `/es/a-propos` | another locale's slug | 301 to `/es/sobre-nosotros` |
| `/es/reserver/succes` | another locale's former slug | 301 to `/es/reservar/estado` |

Only the first row is served. Every other row is a single redirect straight to today's slug, never to another form, so there are no chains and no loops. The index behind this is built once per process, the first time a request needs it: per locale, a `Map` from every form to its page. If two pages claim the same form, the better rank wins, and a tie between different pages is a test failure.

Matching takes the longest form that is a prefix of the path, in whole segments:

```typescript:route-resolver.ts
function matchForm(locale: SupportedLocale, segments: string[]): FormMatch | null {
  const { forms, maxDepth } = index();
  const keys = segments.slice(0, maxDepth).map(segmentKey);
  for (let n = keys.length; n > 0; n--) {
    const form = forms[locale].get(keys.slice(0, n).join('/'));
    if (form) return { ...form, rest: segments.slice(n) };
  }
  return null;
}
```

So `reservar/estado` is the booking status page, not the booking page (`reservar`) with `/estado` stuck on the end. Whatever follows the matched form is the page's dynamic part (a hotel's slug, an order id) and rides along byte for byte. `segmentKey` decodes and lowercases each segment before the lookup, which means `/es/Sobre-Nosotros` and `/es/sobre-nosotros/` aren't 404s, they're 301s to the clean spelling.

## Serving the folder, guarding the folder

The resolver is pure: no I/O, just a path in and an answer out. The [Next.js proxy](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) (what used to be called middleware) is the only thing that acts on it. A redirect goes out at once. A page is served by an internal rewrite to its folder, so the browser keeps showing `/es/sobre-nosotros` while Next renders `app/[lang]/(public)/about/`.

```
  GET /es/cuenta/perfil
          │
          ▼
  ┌──────────────────────────────────┐
  │ resolveRequest()                 │
  │ serve /es/account/profile        │
  │ access: private                  │
  └────────────────┬─────────────────┘
                   ▼
  ┌──────────────────────────────────┐
  │ block gate, then auth guard      │
  │ no session? 307 to sign in       │
  └────────────────┬─────────────────┘
                   ▼
  ┌──────────────────────────────────┐
  │ rewrite to /es/account/profile   │
  │ URL bar: /es/cuenta/perfil       │
  └──────────────────────────────────┘
```

*The guards check the folder path, before the rewrite is returned.*

The order matters more than it looks. The proxy doesn't run again on an internal rewrite, so if it returned the rewrite as soon as it knew the folder, every check further down would be skipped. A visitor hitting the Spanish slug of a private page would walk past the session check that the English path would have hit. So the rewrite is computed early and returned last:

```typescript:proxy.ts
let rewriteUrl: URL | null = null;
if (resolution.pathname !== pathname) {
  rewriteUrl = request.nextUrl.clone();
  rewriteUrl.pathname = resolution.pathname;
}
const canonicalPathname = resolution.pathname;
```

Everything between this and the end of the function (the block gate, the session check, the staff check) looks at `canonicalPathname`, which is always the folder. Access is decided by the folder's first segment: everything under `account/` is private and everything under `staff/` needs a staff token, whatever language the URL is in. A test checks that every page under one first segment shares the same access, so a public page can't sneak in under `account/`.

## The language switcher is one line

A nice consequence of ranking other locales' slugs: switching language doesn't need to know anything about slugs. The switcher swaps the locale prefix and nothing else.

```
  /es/sobre-nosotros
          │  switch to French: swap the prefix
          ▼
  /fr/sobre-nosotros
          │  a Spanish slug under /fr/: 301
          ▼
  /fr/a-propos
```

*The switcher only changes the prefix; the resolver translates the rest.*

The same path covers a signed-in user whose account is set to German who opens a Spanish link from a friend: the proxy swaps the prefix to `/de/`, and the resolver turns the Spanish slug into the German one. One redirect each, and no component had to look up a translation.

## Renaming a page is moving a string

Slugs change. The booking status page used to be "success" in every language, which was wrong the day a payment came back as processing or expired, so it became "status". The problem with renaming a URL is everything outside your code that still has the old one: Stripe's return URLs, confirmation emails already sitting in inboxes, a search result.

In Secondbook a rename is editing `slugs` and moving the old value into `former`:

```typescript:route-slugs.ts
'book/status': {
  access: 'transactional',
  slugs: {
    en: 'book/status',
    es: 'reservar/estado',
    // ...
  },
  former: {
    en: ['book/success'],
    es: ['reservar/exito'],
    // ...
  },
},
```

That's the whole change. The old slug 301s to the new one in one hop, the query string comes along (a Stripe return URL carries the order id there), and every link in the app already points at the new slug because they're all built from this table. For a `POST`, say a server action from a tab that was open before the rename, the redirect is a [308](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/308) instead, because a 301 would turn it into a `GET` and drop the body.

Moves that aren't a rename, like one blog post replacing another, go in a separate list of rules in `redirects.ts` with `:param` and `*` segments. Their targets go through the same resolver, so a rule that lands on a former slug still answers in one hop.

## Writing a link

The outgoing side is `ROUTES`, an object of typed builders, one per page:

```typescript:routes.ts
export const ROUTES = {
  home: (locale: LocaleInput) => withLocale(locale, ''),
  about: localized('about'),
  // ...
  destination: (locale: LocaleInput, slug: string, params?: QueryParams) =>
    localizedHref('destinations', locale, `/${slug}${query(params)}`),
  // ...
};
```

A component writes `ROUTES.destination(locale, 'sevilla')` and gets `/es/destinos/sevilla` or `/de/reiseziele/sevilla`. Dynamic segments and query parameters are typed arguments, not strings glued onto the end. The emails package uses the same builders, so a link in a German refund email is built exactly like a link on a German page.

Types can't stop someone from typing `` `/${locale}/account` `` by hand, so a custom [ESLint rule](https://eslint.org/docs/latest/extend/custom-rules), `no-hardcoded-localized-url`, does. It flags a hand-built `/${locale}/...` path, a literal `'/es/...'`, a base URL followed by anything that isn't a `ROUTES` call, and a production URL written out in full. It even flags `localizedHref` itself, which is what `ROUTES` is built from: call sites go through `ROUTES` or they don't pass lint. That's what makes the "renaming is one file" promise true. Without the rule, it would be true until the first shortcut.

## What the tests hold

The registry is a big hand-edited object, so its rules live in a test that runs in CI. A few of them carry most of the weight:

- **Every page has a slug in every locale**, and every slug is lowercase ASCII kebab-case. No accents in a URL, no `Über`.
- **No two pages share a slug in a locale**, and a former slug is no page's live slug in any locale.
- **Every key is a real folder** under `app/[lang]/`, walking route groups and `[param]` folders the way Next does. A typo in a key fails here, not as a 404.
- **Round trip:** for every page and every locale, the link `ROUTES` builds is served, and served from the right folder.
- **One hop:** every other form of every page, in every locale, 301s straight to today's slug, and that slug is then served, not redirected again.

The round trip is the one I'd keep if I could keep only one:

```typescript:route-slugs.test.ts
for (const [canonical, entry] of ENTRIES) {
  for (const locale of SUPPORTED_LOCALES) {
    const href = localizedHref(canonical, locale);
    expect(href).toBe(`/${locale}/${entry.slugs[locale]}`);
    expect(canonicalForLocalizedPath(locale, entry.slugs[locale])).toEqual({ canonical, suffix: "" });
    expect(canonicalForLocalizedPath(locale, `${entry.slugs[locale]}/x/Y`)).toEqual({ canonical, suffix: "/x/Y" });
    expect(resolveRequest(`${href}/x`)).toEqual({ action: "serve", pathname: `/${locale}/${canonical}/x`, access: entry.access });
  }
}
```

It turns "the outgoing and incoming sides agree" from something you hope into something checked, 824 times per run.

## Everything else that falls out

Once the table exists, other parts of the app stop keeping their own copies of the URL structure.

- **robots.txt** is generated: every page that isn't `public` gets a `Disallow` line, spelled out per locale. Not `/*/cuenta`, because in [robots.txt](https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec) a `*` also matches `/`, and that pattern would hide `/es/blog/viajes-baratos` too. When a hidden slug shares letters with a public one (`/pt/conta` next to `/pt/contacto`), it gets an exact `$` line and a `/` line instead of a bare prefix.
- **Analytics** maps each URL back to its page before deciding whether to record it. `/es/cuenta/perfil` and `/de/konto/profil` are both `account/profile`, whose access is `private`, so neither is tracked. The rule "never track private or staff pages" is written once, against `access`, not once per language.
- **hreflang** tags come from the same builders. A page's metadata calls its `ROUTES` builder once per locale to emit the [alternate links](https://developers.google.com/search/docs/specialty/international/localized-versions) search engines use to match a page to its translations.

## Where it isn't worth it

- **The file is long:** 103 pages times eight slugs is about 1,600 lines of one object. It's boring to read, which is mostly the point, but adding a page means writing eight good translations of a URL, not one.
- **Only page slugs are translated:** a hotel's slug is the same in every language (`/es/alojamientos/hotel-x`, `/de/unterkuenfte/hotel-x`). Translating dynamic segments would need the database in the resolver, and the resolver is pure on purpose.
- **Access per first segment is a constraint:** a public page can't live under `account/`. That's been a feature so far, but it shapes the folder tree.
- **Every request pays for the lookup:** a few `Map` lookups per path. Cheap, but it runs on every page load, so it has to stay pure and in memory.
- **There are exceptions:** seasonal campaign pages live at the locale's root (`/es/ofertas-navidad`) and are matched by prefix, not by entry, which means one test exists only to keep page slugs from starting like a campaign. A registry stays simple until the first thing that doesn't fit in it.
- **For a two-language site it's too much:** a handful of rewrites and some discipline would do. This pays off when the count of pages times locales is big enough that nobody can keep it in their head.

## The trade

Localized URLs look like a translation problem, and the translation is the easy part. The hard part is that a URL is a decision several systems make, and each of them wants its own copy of the answer. Secondbook gives them one: a table of pages that the links, the proxy, the guards, robots.txt and analytics all read from, held together by a type that knows the 103 real pages, a lint rule that won't let a link be built any other way, and a test that walks every form of every page in every language. The cost is one long, dull file. In exchange, renaming a URL in eight languages is a change to two lines, and a broken link has to get past the compiler first.

## Sources

- [Next.js: proxy.ts](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)
- [Next.js: rewrites](https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites)
- [Next.js: internationalization](https://nextjs.org/docs/app/guides/internationalization)
- [TypeScript 4.9: the satisfies operator](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator)
- [Google: how robots.txt rules are interpreted](https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec)
- [Google: localized versions of your pages](https://developers.google.com/search/docs/specialty/international/localized-versions)
- [MDN: 308 Permanent Redirect](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/308)
