URLs in Eight Languages
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 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: therewritesoption can map/es/sobre-nosotrosto/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.
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:
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 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:
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:
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 (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/.
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:
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.
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:
'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 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:
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, 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
ROUTESbuilds 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:
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
publicgets aDisallowline, spelled out per locale. Not/*/cuenta, because in robots.txt a*also matches/, and that pattern would hide/es/blog/viajes-baratostoo. When a hidden slug shares letters with a public one (/pt/contanext 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/perfiland/de/konto/profilare bothaccount/profile, whose access isprivate, so neither is tracked. The rule "never track private or staff pages" is written once, againstaccess, not once per language. - hreflang tags come from the same builders. A page's metadata calls its
ROUTESbuilder once per locale to emit the alternate links 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
Maplookups 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
Related articles
A temporary inbox takes mail from anyone, for anyone, with no account. How Buzontemporal runs on Mailingest: no attachments, a light path, hard limits.