# Translated routes in Next.js 16 without libraries

> How to have /servicios in Spanish and /services in English in Next.js 16 with a route map and a proxy.ts, without adding internationalization libraries.

URL: https://codegea.com/en/translated-routes-in-next-js-16-without-libraries
Web · 2026-10-18 · Derian Pinto

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`:

```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

```ts
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:

```ts
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:

1. **Rewrites** `/servicios` to `/es/services`. The visitor still sees `/servicios`.
2. **Redirects** (308) to the right form when someone arrives with another language's segment, for example `/services` in Spanish. That way each piece of content has a single URL.
3. **Strips the ****`/es`**** prefix** of the default language with another redirect.

And the `matcher` leaves out what shouldn't go through here:

```ts
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/x` and `/x` shows up fast, but only if you test it.
- **Don't put routes that need no translation in the ****`matcher`**, such as `/api` or 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](/en/nextjs-16-what-changes-for-corporate-websites).
