logo
ia

Multilingual Next.js with next-intl: the full config and the traps

7 min read
Multilingual Next.js with next-intl: the full config and the traps

This article is part of the series "Rebuilding a Website with AI". If you haven't read the first episode, start there.

Updated on 10 August 2026: added the section on the crawl bug found five months after launch.


The problem

My site was 100% in French. No language switcher, no multilingual structure, nothing. Yet a significant part of my professional network is English-speaking, and my Brazilian roots had been pushing me for a long time to add Portuguese.

Internationalising a Next.js App Router site is the job everyone puts off. And for good reason, it touches absolutely everything. Routes, layouts, metadata, components, content. Every file is affected.


The foundation: one source of truth for routing

It all starts with one file. i18n/routing.ts declares the languages, the default locale, and above all the table of translated paths.

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['fr', 'en', 'pt'],
  defaultLocale: 'fr',
  localePrefix: 'always',
  pathnames: {
    '/': '/',
    '/sprint': '/sprint',
    '/saas-sur-mesure-suisse': {
      fr: '/saas-sur-mesure-suisse',
      en: '/custom-saas-switzerland',
      pt: '/saas-sob-medida-suica',
    },
    '/blog/[year]/[id]': '/blog/[year]/[id]',
    '/tags/[tag]': '/tags/[tag]',
  },
})

This is the part most tutorials skip. A path can be identical across all three languages (/sprint) or translated (/saas-sur-mesure-suisse becomes /custom-saas-switzerland in English). Translating URLs has real SEO value, an English page living on a French URL sends Google a contradictory signal.

localePrefix: 'always' is the other structural decision. Every URL carries its language, French included. No ambiguous root, no automatic detection deciding on the visitor's behalf. We'll come back to that, because automatic detection is exactly what caused me trouble later.

Then createNavigation generates the tools that respect this table:

// i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)

From there, a <Link href="/saas-sur-mesure-suisse"> written once produces the right URL in all three languages, automatically.


Migrating every route

This is the trickiest part. Every page had to move into an app/[locale]/ folder. app/page.tsx became app/[locale]/page.tsx, app/blog/page.tsx became app/[locale]/blog/page.tsx, and so on.

But it is not a simple file move. Each page had to receive the locale parameter, use useTranslations() or getTranslations() for its text, and generate SEO metadata in the right language.

More than 385 translation keys per language were produced, 1,155 in total, covering navigation, offer pages, the home page, the blog, SEO and the whole interface. Consistent across the three languages, not word for word but properly localised.


The real trap: translated articles have different slugs

A multilingual brochure site is fixed content. A multilingual blog is something else. A French article lives at /fr/blog/2026/prix-saas-sur-mesure-suisse, its English version at /en/blog/2026/custom-saas-cost-switzerland. The slugs differ, and they have to, a French slug on an English page ranks for nothing.

The convention I settled on: one file per language, and a sourceId field linking the translation back to its original.

# data/blog/2026/prix-saas-sur-mesure-suisse.mdx
id: 'prix-saas-sur-mesure-suisse'

# data/blog/2026/prix-saas-sur-mesure-suisse.en.mdx
id: 'custom-saas-cost-switzerland'
sourceId: 'prix-saas-sur-mesure-suisse'

The locale is derived from the filename suffix, through a Contentlayer computed field. From there, for any article, you can find its counterparts in the other languages, and therefore generate correct hreflang tags.

That leaves cross-language URLs. What should happen when Google, or a visitor, asks for /en/blog/2026/prix-saas-sur-mesure-suisse, a French slug under an English prefix? The rule I applied: the requested language wins. If an English translation exists, redirect there. Only if it does not, fall back to the language that owns the slug.

Written like that the rule looks obvious. It was not what my code did, and I come back to it below.


What the AI delivered and I never checked

Claude Code produced an AlternatePathProvider, a React context meant to give the language switcher the exact translated slug of the current page. Clean, properly typed, with its AlternatePathSetter to feed it from each page.

Five months later, auditing the site, I found that AlternatePathSetter was never called anywhere. The context was always empty. The switcher always fell back to its default, the current path, and it still worked thanks to the cross-language redirect described above.

Dead code that works by accident. That is exactly the kind of thing you miss in review when everything looks right and the site behaves properly. It broke nothing, but it taught me something. When an AI hands you a complete, elegant abstraction, check that it is wired in, not just that it is correct.


The bug that blocked indexing on 40 pages for five months

Here is the part I had not planned when writing this article, and by far the most expensive.

In August, Search Console reports 50 indexed pages and 84 not indexed. Digging in, two reasons stand out: 25 pages "Crawled, currently not indexed" and 7 "Discovered, currently not indexed". Among them, my English and Portuguese home pages. Google had come to look at them, then decided not to index them.

I ran a full crawl of the site, following every internal link from the sitemap. The result: the crawler never reached a single /en/ or /pt/ page. Not because they were broken, they all returned 200. Because nothing pointed to them.

The culprit, my language switcher:

// Invisible to Googlebot
<button aria-label={label} onClick={() => switchTo(code)}>
  {flag}
</button>

Three nice flags, an onClick, a router.replace. For a user, it works perfectly. For a crawler, those flags do not exist. Googlebot does not run click handlers to discover URLs.

So my 40 translated pages had no inbound link anywhere on the site. They were missing from the sitemap, removed back then to save crawl budget, and linked from nothing. Google only knew them through hreflang tags, which are a hint and not a link, and which pass no authority.

The fix is a few lines:

// A link the crawler can follow
<Link href={`/${code}${stripped}`} hrefLang={code} aria-label={label}>
  {flag}
</Link>

With one subtlety that cost me an iteration. next-intl's usePathname does not return the real path once you use localised pathnames, it returns the internal template. On an article it literally gives /blog/[year]/[id]. My first fix therefore generated href="/en/blog/[year]/[id]" links, in other words broken links, worse than the starting point. You need the raw usePathname from next/navigation, strip the current locale prefix, and rebuild the URL yourself.

And the cross-language redirect in all this? It sent the visitor to the language that owns the slug, not to the one they had asked for. In practice, from an English article, clicking the French flag brought you back to English. Nobody noticed while the switcher was only a button. Turned into a crawlable link, it became a contradictory signal sent to Google.


What I take away

next-intl handles technical i18n very well, and an AI sets it up in two hours where it used to take me a week. That is no longer where the difficulty lies.

The difficulty is where nobody looks. A component can be accessible, tested, pleasant to use, and completely invisible to a search engine. An abstraction can be clean and never plugged in. A redirect can work for five months before you realise it answers a different question than the one asked.

Three habits I now apply to any multilingual site:

  1. Every language version needs at least one inbound <a href> link. An hreflang tag does not replace a link.
  2. The sitemap lists all three languages. Saving crawl budget by removing pages means giving up the only discovery channel left when internal linking fails.
  3. Crawl your own site after every rebuild. Thirty lines of script are enough. If your crawler cannot reach a page, neither can Google.

This i18n migration is exactly the kind of foundation you lay from the start when building a custom SaaS platform. Not a feature to bolt on later, but an architecture decision that gets expensive when taken too late.


Next in the series: turning 13 podcast episodes into 50 blog articles with AI.

Toni Dias

Toni Dias

Software engineer and technical partner · AsuOs

Ready to transform your digital business?

Toni Dias supports you in your digital strategy with tailored solutions.