When a site has two languages, the usual approach is to translate the text and leave the URLs in a single language: /es/services and /en/services. For SEO it is better if the URL is translated too: /servicios in Spanish and /en/services in English. Next.js doesn't do that on its own, and you don't need a library to get it. This is how we solved it on this site.
The idea
The folders in app/ have a fixed internal name, in English: app/[locale]/services, app/[locale]/contact. What the visitor sees is something else. A proxy.ts file translates the public URL to the internal folder before Next.js renders anything.
- Spanish is the root, with no prefix:
/servicios,/contacto. - English lives under
/en:/en/services,/en/contact. - The folder is the same in both cases.
Since Next.js 16 this file is called proxy.ts (it used to be middleware.ts; the old convention is marked as deprecated in the documentation).
Step 1: a single route map
All the information lives in one file, i18n/routes.ts:
export const SEGMENTS = {
services: { internal: 'services', es: 'servicios', en: 'services' },
contact: { internal: 'contact', es: 'contacto', en: 'contact' },
privacy: {
internal: 'privacy',
es: 'politica-de-privacidad',
en: 'privacy-policy',
},
// …
} as const;Each entry says what the internal folder is called and how it is written in each language. No component writes a URL by hand: they all use functions like segmentPath('en', 'contact'), which returns /en/contact. If one day you change contacto to escribenos, you change it in one place.
Step 2: build URLs with a function
const prefix = (locale: Locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`;
export const segmentPath = (locale: Locale, key: SegmentKey) =>
`${prefix(locale)}/${SEGMENTS[key][locale]}`;The default language has no prefix; the others do.
Step 3: the proxy translates
proxy.ts looks at the URL, detects the language and rewrites to the internal folder:
export function proxy(req: NextRequest) {
const { pathname, search } = req.nextUrl;
const { locale, explicit, rest } = splitLocale(pathname);
const prefix = locale === DEFAULT_LOCALE ? '' : `/${locale}`;
// /es/... redirects to the unprefixed URL: one URL per page
if (explicit && locale === DEFAULT_LOCALE) {
return NextResponse.redirect(new URL(join(...rest) + search, req.url), 308);
}
let internal = rest;
const match = rest[0] ? matchSegment(locale, rest[0]) : null;
if (match) {
// they asked for /services in Spanish: redirect to the canonical /servicios
if (match.canonical) {
return NextResponse.redirect(
new URL(prefix + join(match.canonical, ...rest.slice(1)) + search, req.url),
308,
);
}
internal = [match.internal, ...rest.slice(1)];
}
return NextResponse.rewrite(new URL(join(locale, ...internal) + search, req.url));
}It does three things:
- Rewrites
/serviciosto/es/services. The visitor still sees/servicios. - Redirects (308) to the right form when someone arrives with another language's segment, for example
/servicesin Spanish. That way each piece of content has a single URL. - Strips the
/esprefix of the default language with another redirect.
And the matcher leaves out what shouldn't go through here:
export const config = {
matcher: ['/((?!api/|admin(?:/|$)|_next/).*)'],
};Step 4: tell Google they are the same page
With different URLs per language, you need hreflang. Each page must know its equivalent in the other language. We pass the alternate URLs to a single template component, which generates the ES | EN switcher and the hreflang tags. An article without a translation simply isn't advertised in the other language.
What you gain and what it costs
You gain: readable URLs in each language, no dependencies, and all the behavior in one file you can read start to finish.
It costs: you maintain the map and the redirects yourself. If the site grows to many languages, or you need to format dates, plurals or currencies per language, an i18n library is still a good idea. For two languages and a content site, the map falls short before it becomes a problem.
Two warnings
- Test the redirects with
curl -I. A redirect loop between/es/xand/xshows up fast, but only if you test it. - Don't put routes that need no translation in the
matcher, such as/apior the admin panel. In our case, the Payload admin stays outside.
If your site is in Next.js and you want to see how the framework changed, read what changes in Next.js 16 for corporate sites.
Written by
Derian Pinto
Founder & Full Stack Developer
Founder of Codegea. Builds custom websites, stores and platforms, and integrates and supports Arc XP for media companies.