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.
strategydecides where the locale is resolved from.pathPrefixdecides 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… | strategy | pathPrefix | [locale] folder? | Rendering |
|---|---|---|---|---|
Classic SEO URLs, default locale clean (/about, /es/about) | param | default (default) | Yes | Static-friendly |
Every locale prefixed (/en/about, /es/about) | param | always | Yes | Static-friendly |
| Prefixed URLs without restructuring my app | request | always | No | Dynamic |
| One URL for everyone, locale from cookie/header | request | hidden (its default) | No | Dynamic |
| Static pages, locale never in the URL | param | hidden | Yes | Static-friendly |
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 en | 307 → /about | served as es |
always | 307 → /es/about | served as en | served as es |
optional | served as es, URL unchanged (internal rewrite) | served as en | served as es |
hidden | served as es, URL unchanged | 307 → /about | 307 → /about |
Reading the table:
default— the default locale never shows a prefix./aboutis English,/es/aboutis 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 youVary: 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.
pathBase | No cookie yet (first visit) | Cookie present (returning visitor) |
|---|---|---|
detect-default (default when the prefix is visible) | negotiate Accept-Language | default locale — the URL wins over the cookie |
detect-latest (default when pathPrefix: "hidden") | negotiate Accept-Language | the cookie — remembers the last locale used |
always-default | default locale | default locale |
always-detect | negotiate | negotiate — 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 header | the resolved locale — readable server-side in every router |
x-path header | the path with the locale stripped |
locale cookie | persisted, and only re-sent when it actually changes (so responses stay CDN-cacheable) |
// 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/about → 307 → /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.
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
});