Intl-T

Routing

Choose your URLs. Independently of where the locale comes from.

The idea in one sentence

Most i18n libraries tie two decisions together: where the locale comes from and what your URLs look like. Intl-T keeps them separate.

  • strategy decides where the locale is resolved from.
  • pathPrefix decides what the URL looks like.

They are orthogonal. That is why Intl-T can give you /es/about URLs with no [locale] folder at all, or fully static prerendered pages with the locale invisible in the URL — combinations other libraries cannot express.

Pick your goal

I want…strategypathPrefix[locale] folder?Rendering
Classic SEO URLs, default locale clean (/about, /es/about)paramdefault (default)YesStatic-friendly
Every locale prefixed (/en/about, /es/about)paramalwaysYesStatic-friendly
Prefixed URLs without restructuring my apprequestalwaysNoDynamic
One URL for everyone, locale from cookie/headerrequesthidden (its default)NoDynamic
Static pages, locale never in the URLparamhiddenYesStatic-friendly
i18n/navigation.ts
import { createNavigation } from "intl-t/next";

export const { proxy, Link, useRouter, generateStaticParams } = createNavigation({
  allowedLocales: ["en", "es"] as const,
  defaultLocale: "en",
  // strategy: "param",      // default
  // pathPrefix: "default",  // default
});

strategy — where the locale comes from

param (default)

The locale is a route segment. Your pages live under app/[locale]/, Next can prerender them, and the URL is the single source of truth.

Use it when you want static generation and canonical per-language URLs.

request

The locale is resolved from request state — cookie, Accept-Language, or your own detection — and handed to your app through the x-locale header. There is no [locale] folder. Your routes stay exactly as they are.

Use it when you don't want to restructure your app, when the locale is per-user rather than per-URL, or when content negotiation matters more than URL canonicality.

request makes routes dynamic (server-rendered on demand). You trade static prerendering for zero route nesting and free content negotiation. That trade is the whole point — make it consciously.

pathPrefix — what the URL looks like

Assume allowedLocales: ["en", "es"], defaultLocale: "en", and a visitor whose browser prefers Spanish.

pathPrefix/about/en/about/es/about
default (default)first visit → 307 /es/about; returning → served as en307/aboutserved as es
always307/es/aboutserved as enserved as es
optionalserved as es, URL unchanged (internal rewrite)served as enserved as es
hiddenserved as es, URL unchanged307/about307/about

Reading the table:

  • default — the default locale never shows a prefix. /about is English, /es/about is Spanish. This is what most sites want.
  • always — every locale is prefixed. No unprefixed URL survives.
  • optional — both forms work and neither redirects. Careful: the same URL serves different languages to different users, which breaks shared CDN caches unless you Vary: Cookie.
  • hidden — the locale never appears. One URL per page, for everyone.

pathBase — what happens on an unprefixed path

When a request arrives with no locale in the URL, this decides which locale wins.

pathBaseNo cookie yet (first visit)Cookie present (returning visitor)
detect-default (default when the prefix is visible)negotiate Accept-Languagedefault locale — the URL wins over the cookie
detect-latest (default when pathPrefix: "hidden")negotiate Accept-Languagethe cookie — remembers the last locale used
always-defaultdefault localedefault locale
always-detectnegotiatenegotiate — the cookie is ignored entirely

The two defaults are deliberate: when the locale is visible in the URL, the URL is the truth (detect-default). When it is hidden, the user's last choice is the truth (detect-latest).

A locale cookie holding a value outside allowedLocales (a locale you removed, a stale cookie, tampering) is ignored and re-negotiated, and the response resets it. Routing cannot be poisoned by a bad cookie.

The combination worth knowing about

createNavigation({
  allowedLocales: ["en", "es"] as const,
  defaultLocale: "en",
  strategy: "request",
  pathPrefix: "always",
});

This gives you /es/about URLs with no [locale] directory. The proxy strips the prefix, rewrites internally to /about, and passes the locale to your app through the header. Your file tree stays flat; your URLs look fully localized.

It is the clearest example of the orthogonality: prefixed URLs are a presentation choice, not a routing choice.

What the proxy gives your app

On every request the proxy sets:

x-locale headerthe resolved locale — readable server-side in every router
x-path headerthe path with the locale stripped
locale cookiepersisted, and only re-sent when it actually changes (so responses stay CDN-cacheable)
Reading the locale server-side
// App Router
import { getRequestLocale } from "intl-t/next";
const locale = await getRequestLocale();

// Pages Router (getServerSideProps)
const locale = ctx.req.headers["x-locale"];

Switching locale

With a visible prefix, link to the prefixed URL — Link from createNavigation handles it:

import { Link } from "@/i18n/navigation";

<Link href="/about" locale="es">
  Español
</Link>;

With pathPrefix: "hidden", navigate to the prefixed path anyway: the proxy persists the cookie and redirects you back to the clean URL. /es/about307/about, now in Spanish.

Custom middleware

Compose your own logic on top of the i18n proxy. Your callback receives the i18n response — return it (or your own) to keep it.

proxy.ts
import { withProxy } from "@/i18n/navigation";
import { NextResponse } from "next/server";

export const proxy = withProxy(async (req, _ev, res) => {
  if (!req.cookies.get("session")) return NextResponse.redirect(new URL("/login", req.url));
  return res; // keeps the i18n redirect/rewrite, headers and cookie
});

export const config = {
  matcher: "/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
};

Return the res you were given (or copy its headers and cookies). Returning a fresh NextResponse.next() silently discards the locale cookie and the x-locale header.

Domain-based locales

Map hostnames to locales instead of paths:

createNavigation({
  allowedLocales: ["en", "es"] as const,
  defaultLocale: "en",
  domains: [
    { domain: "example.es", defaultLocale: "es", locales: ["es"] },
    { domain: "example.com", defaultLocale: "en", locales: ["en"] },
  ],
});

When domains is set, detection is derived from the hostname automatically. You can still override it with detectLocale.

Custom detection

detectLocale replaces Accept-Language negotiation with your own logic.

createNavigation({
  allowedLocales: ["en", "es"] as const,
  defaultLocale: "en",
  detectLocale: req => (req.geo?.country === "ES" ? ["es"] : ["en"]),
  // or a fixed list:  detectLocale: ["es"]
  // or disable:       detectLocale: false
});

On this page