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.
The abstraction that works for humans and not for robots
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.
Auditing the site five months later, I looked at where it was actually wired in. The answer: in the blog article layout, and nowhere else. On the home page, the offer pages, the tags or the case studies, the context stays empty and the switcher falls back to the current path.
But the real detail is elsewhere. Even on the pages where it is wired in, AlternatePathSetter feeds the context inside a useEffect:
// components/AlternatePathSetter.tsx
useEffect(() => {
setAlternatePaths(paths)
return () => setAlternatePaths({})
}, [paths, setAlternatePaths])
A useEffect runs after hydration, on the client. So the server-rendered HTML, the one Googlebot reads, always carries the fallback path and never the exact translated slug.
The abstraction does exactly its job for a human clicking. It does nothing at all for a crawler. That is the same blind spot that cost me five months of indexing, in a place I was not expecting it, and I had not seen it either.
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, correctly wired in, and only take effect after hydration, so never for a search engine. 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:
- Every language version needs at least one inbound
<a href>link. Anhreflangtag does not replace a link. - The sitemap lists all three languages. Saving crawl budget by removing pages means giving up the only discovery channel left when internal linking fails.
- 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
Software engineer and technical partner · AsuOs
Related articles

From AI prototype to sellable product: the industrialisation method
How to turn an AI, no-code or home-made prototype into a solid, sellable product: audit, what you keep, foundations, tests and CI/CD.

Taking Over a No-Code or Vibe-Coded Project: Where to Start
How to properly take a no-code or vibe-coded project into your own hands: the concrete steps, from the honest inventory to the audit, for a product that finally holds up.
Ready to transform your digital business?
Toni Dias supports you in your digital strategy with tailored solutions.