Codegea

Development

Custom software developmentSystems, platforms and integrations built around your operationCorporate websitesProfessional, fast and built to inspire trustOnline storesCatalog, payments and shipping ready to sell 24/7WooCommerceFast WooCommerce stores with each country’s payments and invoicingLanding pagesA page focused on one goal: turning visitors into customersMobile appsCustom iOS and Android apps that put your business in your customers’ pockets

Integration and CMS

CMS integrationsWordPress, Strapi, Directus and Arc XP connected to your whole ecosystemArc XPSupport, implementation and integrations for the media platformMigrationsMigrate your site, platform or database without losing anything

Growth and infrastructure

AI automationTechnology does the repetitive work and your team focuses on what mattersOn-page SEOBe found and recommended by Google: technical and content optimizationServers & hostingFast, secure servers monitored 24/7
RetainerYour dev team, by the monthFrom 20h a month. Fixed fee.
An AI quoting tool to quote services in minutesProfessional quotes in minutes, with projects, sellers and payments tracked in one place.See the case

By type

EcommerceStores and catalogsCorporate sitesBrand and lead genPlatforms & appsCustom software

Explore

All projectsCases and resultsClientsWho trusts usRetainerMonthly dev team

Blog

All articlesGuides and opinionNewsletterOne email a monthContactWe reply in 24h

Latest articles

How codegea.com is built: Next.js, Payload, Turso and R2CMS · 2026-10-20Cloudflare Workers: what they are and when to use themNode.js · 2026-10-19Live Preview in Payload with Next.jsCMS · 2026-10-19
NewsletterJoin our newsletterPractical web, SEO and AI notes.
ServicesCustom software developmentCorporate websitesOnline storesWooCommerceLanding pagesMobile appsCMS integrationsArc XPMigrationsAI automationOn-page SEOServers & hostingYour dev team, by the month
ProjectsProfessional quotes in minutes, with projects, sellers and payments tracked in one place.EcommerceCorporate sitesPlatforms & appsAll projectsClientsRetainer
Retainer
BlogAll articlesNewsletterContactHow codegea.com is built: Next.js, Payload, Turso and R2Cloudflare Workers: what they are and when to use themLive Preview in Payload with Next.jsJoin our newsletter
ESEN
Let's talk

Web

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.

Derian Pinto · Founder & Full Stack Developer2026-10-183 min

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:

  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:

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.

Share this article?LinkedInWhatsAppX

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.

Join our newsletter

An email with news and practical web, SEO and AI notes. No spam, unsubscribe anytime.

Shall we build your project?

Need help? Chat with us