Translated URLs with next-intl, without duplicating routes
How to serve /estudos in Portuguese and /blog in English from a single route file in the App Router.
3 min read
The first version of any bilingual site usually falls into one of two holes: either
the URLs stay in English for everyone (/pt/blog), or you duplicate the route tree
to get real /estudos and /blog paths. The first is lazy, the second doubles the
maintenance.
next-intl solves this with pathnames, and it's worth understanding exactly what
it does.
The concept: internal vs. external pathname
The core idea is separating two names for the same thing:
- Internal pathname — the folder path under
app/. Always English, never shown to the user. - External pathname — what lands in the address bar, and changes per locale.
You declare the mapping once:
// src/i18n/routing.ts
export const routing = defineRouting({
locales: ["pt", "en"],
defaultLocale: "pt",
localePrefix: "as-needed",
pathnames: {
"/": "/",
"/blog": { pt: "/estudos", en: "/blog" },
"/blog/[slug]": { pt: "/estudos/[slug]", en: "/blog/[slug]" },
},
});With localePrefix: "as-needed", the default locale drops its prefix. The result:
| File | PT URL | EN URL |
|---|---|---|
app/[locale]/blog/page.tsx | /estudos | /en/blog |
app/[locale]/blog/[slug]/page.tsx | /estudos/my-post | /en/blog/my-post |
One file, two URLs. Nothing is duplicated.
Links have to come from next-intl
This is the detail that breaks most implementations. next/link's Link knows
nothing about the mapping — it would emit /blog literally. You need the wrappers:
// src/i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);From then on you always write the internal pathname and let the library handle the translation:
<Link href="/blog">Blog</Link>
<Link href={{ pathname: "/blog/[slug]", params: { slug } }}>Read</Link>The bonus is typing: a typo like /blogg becomes a compile error instead of a 404
you discover in production.
The proxy and files with dots
In Next.js 16, middleware.ts became proxy.ts. The recommended matcher skips any
path containing a dot — great for favicon.ico, terrible for /rss.xml, which is a
real route that needs to go through translation.
export const config = {
matcher: [
"/((?!api|trpc|og|_next|_vercel|.*\\..*).*)",
// rss.xml has a dot in it: opt it back in explicitly
"/rss.xml",
"/(pt|en)/rss.xml",
],
};The inverse happens too: routes that should not be localized — like an endpoint
generating OG images — need to leave the matcher, otherwise /og becomes /pt/og
and returns a 404.
Partial translation without an error page
One case the library doesn't solve on its own: the post exists in Portuguese but not yet in English. Switching languages would drop the reader straight onto a 404.
The fix fits inside the route itself:
const entry = getEntry(locale, "blog", slug);
if (!entry) {
const other = routing.locales.find((candidate) => candidate !== locale);
if (other && hasEntry(other, "blog", slug)) {
// It exists, just not in this language: send them to the index instead.
redirect({ href: "/blog", locale });
}
notFound();
}Three lines, and the reader lands on a useful list instead of a dead end.
What I'd do differently
Slugs are still shared across locales — /estudos/localized-routes and
/en/blog/localized-routes. For Portuguese SEO the slug should be translated too,
but that requires a lookup table between the two. Since language switching depends
on the slug matching, I kept them shared for now. It's the next thing I'll touch.