Home
VA.
Back to Blog

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:

FilePT URLEN 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.

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.

Tags

  • Next.js
  • i18n
  • next-intl