Next.js i18n met next-intl: path prefix, hreflang en canonical zonder bugs
Een productie-handleiding voor multilingual Next.js sites met next-intl - path prefix routing, hreflang tags, canonical URLs en de redirect statuscodes die je SEO maken of breken.
Salonnare's marketing site draait in vijf talen - Nederlands, Engels, Duits, Frans en Spaans. Elke locale heeft zijn eigen URL prefix (/nl/..., /en/..., /de/...), zijn eigen content, zijn eigen sitemap-entries en zijn eigen hreflang signaal. Dat klinkt simpel, en met next-intl is het ook redelijk te bouwen, maar er zit een handvol valkuilen onder die je SEO-prestaties bepalen. Onlangs heb ik er twee weggewerkt - dit is wat ik onderweg leerde.
De basis opzet
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['nl', 'en', 'de', 'fr', 'es'],
defaultLocale: 'nl',
localePrefix: 'always', // /nl/over zelfs voor default
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
localePrefix: 'always' is bewust gekozen. De alternatief 'as-needed' betekent dat de default locale géén prefix heeft (/over voor NL, /en/about voor EN). Dat geeft kortere URLs maar ook ambiguïteit voor Google: is /over een Nederlands document of de canonical voor een meertalig document? Met 'always' weet Google: deze URL is exact één locale, geen interpretatie nodig.
Valkuil 1: 307 redirects ondermijnen je canonical signalen
Onlangs merkte ik dat onze root-redirect (/ → /nl) een HTTP 307 (temporary) status gaf in plaats van 308 (permanent). Voor de gebruiker maakt dat niets uit - beide leiden hem netjes naar de Nederlandse homepage. Voor Google maakt het wel uit:
- 307 Temporary Redirect → "deze pagina kan terugkeren op
/, dus indexeer beide" - 308 Permanent Redirect → "deze pagina is verplaatst naar
/nl, behandel/nlals de canonical"
In Search Console zag ik dat Google / regelmatig opnieuw probeerde te indexeren als aparte pagina, wat aanleiding gaf tot canonical waarschuwingen. Sinds de switch naar 308 zie je dit gedrag verdwijnen.
// next.config.ts
async redirects() {
return [
{ source: '/', destination: '/nl', permanent: true }, // 308
];
}
// Voor middleware-driven redirects:
return NextResponse.redirect(new URL('/nl', req.url), 308);
permanent: true in de Next.js redirect helper geeft 308. permanent: false geeft 307. De default is false, wat in praktijk dus de verkeerde keuze is voor locale redirects.
Valkuil 2: hreflang vergeten op dynamische routes
Statische pagina's zoals /about zijn makkelijk: in generateMetadata voeg je alternates.languages toe en je bent klaar. Dynamische routes (zoals /blog/[slug]) zijn tricky, want niet elke slug bestaat in elke taal.
// src/lib/seo.ts
export function buildAlternates(path: string, availableLocales: string[]) {
const languages: Record<string, string> = {};
for (const locale of availableLocales) {
languages[locale] = `https://salonnare.com/${locale}${path}`;
}
// x-default verwijst naar de fallback (NL voor ons)
languages['x-default'] = `https://salonnare.com/nl${path}`;
return { languages };
}
Voor blog posts check je in je MDX loader welke locales beschikbaar zijn voor die slug, en geef je alleen die door. Een blog post die alleen in NL bestaat heeft geen <link rel="alternate" hreflang="en" href="..." /> - anders krijgt Google een 404 op die alternate URL en daalt je trust score.
Valkuil 3: Canonical naar de geschone URL, niet de verzochte
Een gebruiker komt binnen via /en/blog/?ref=newsletter&utm_campaign=april. Je canonical moet /en/blog zijn, zonder de tracking parameters. Anders ziet Google elke campagne-URL als een aparte pagina en gaat je linkjuice in versplinterde varianten verloren.
// In je layout of page metadata
return buildMetadata({
locale,
path: '/blog',
// canonical wordt hier gebouwd zonder query string
});
Onze buildMetadata helper bouwt de canonical altijd vanuit (locale, path), nooit vanuit req.url. Dat is een bewuste keuze.
Valkuil 4: Sitemap zonder hreflang annotations
Je sitemap is meer dan een URL lijst. Voor multilingual sites hoort elk URL te declareren welke alternates er zijn:
<url>
<loc>https://salonnare.com/nl/over</loc>
<xhtml:link rel="alternate" hreflang="nl" href="https://salonnare.com/nl/over" />
<xhtml:link rel="alternate" hreflang="en" href="https://salonnare.com/en/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://salonnare.com/nl/over" />
</url>
Next.js' sitemap.ts API ondersteunt dit via het alternates veld. Vergeet x-default niet - die vertelt Google welke versie te tonen aan een gebruiker zonder duidelijke locale signalen.
// src/app/sitemap.ts
return [{
url: 'https://salonnare.com/nl/over',
lastModified: new Date(),
alternates: {
languages: {
nl: 'https://salonnare.com/nl/over',
en: 'https://salonnare.com/en/about',
'x-default': 'https://salonnare.com/nl/over',
},
},
}];
Valkuil 5: Locale slug ongelijk aan path slug
In het Engels noem je je pagina misschien /en/about, in het Spaans /es/sobre-nosotros. Dan is de path-slug taalafhankelijk, en moet je een mapping bijhouden. next-intl ondersteunt dit via pathnames config:
// src/i18n/routing.ts
export const routing = defineRouting({
locales: ['nl', 'en', 'de', 'fr', 'es'],
defaultLocale: 'nl',
pathnames: {
'/about': {
nl: '/over',
en: '/about',
de: '/ueber-uns',
fr: '/a-propos',
es: '/sobre-nosotros',
},
},
});
Mooi voor SEO (lokale keywords in URL), maar verdubbelt de complexiteit van je sitemap en hreflang generatie. Voor Salonnare hebben we per pagina afgewogen of de SEO-winst de complexiteit waard is. Voor de meeste pagina's: nee. Voor /booking en /pricing: ja.
Valkuil 6: Switching language verliest de huidige route
Een Engelstalige bezoeker op /en/blog/post-slug klikt op de NL vlag. Slechte implementatie: hij landt op /nl (homepage). Goede implementatie: hij landt op /nl/blog/post-slug (zelfde post, NL versie als die bestaat) of /nl/blog (parent, als de specifieke post geen NL versie heeft).
// LanguageSwitcher.tsx
import { useRouter, usePathname } from 'next/navigation';
import { useLocale } from 'next-intl';
function switchLocale(newLocale: string) {
const pathname = usePathname();
const newPath = pathname.replace(/^\/[a-z]{2}/, `/${newLocale}`);
router.push(newPath);
}
Voor dynamische routes: check eerst of de target slug bestaat in de nieuwe locale. Anders fallback naar de parent route.
Wat ik adviseer
- Gebruik
localePrefix: 'always'. Korte URLs zijn niet het optimaliseren waard. - Zet alle locale redirects op 308.
permanent: trueinnext.config.ts. - Bouw één centrale
buildMetadatahelper die canonical en hreflang consistent maakt voor alle routes. Alle pages roepen die aan, niemand schrijft het lokaal. - Test je sitemap output regelmatig in de Search Console URL inspect tool. Daar zie je direct of Google je hreflang correct interpreteert.
- Wacht met locale-specifieke pathnames tot je SEO data laat zien dat het loont. Voor de meeste sites is
/en/aboutnetjes genoeg.
i18n in Next.js is een grijszone tussen "werkt" en "werkt voor SEO". next-intl doet de eerste 80% out-of-the-box; de laatste 20% is canonical hygiene en redirect statuscodes. Als je dat op orde krijgt, zit je internationale verkeer goed in elkaar.
