Next.js i18n with next-intl: path prefix, hreflang and canonical without bugs
A production guide for multilingual Next.js sites with next-intl - path prefix routing, hreflang tags, canonical URLs and the redirect status codes that make or break your SEO.
Salonnare's marketing site runs in five languages - Dutch, English, German, French and Spanish. Each locale has its own URL prefix (/nl/..., /en/..., /de/...), its own content, its own sitemap entries and its own hreflang signal. Sounds simple, and with next-intl it's reasonably buildable, but a handful of pitfalls underneath will determine your SEO performance. Recently I cleared two of them - here's what I learned along the way.
The basic setup
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['nl', 'en', 'de', 'fr', 'es'],
defaultLocale: 'nl',
localePrefix: 'always', // /nl/about even for default
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
localePrefix: 'always' is a deliberate choice. The alternative 'as-needed' means the default locale has no prefix (/about for NL, /en/about for EN). That gives shorter URLs but also ambiguity for Google: is /about a Dutch document or the canonical for a multilingual document? With 'always' Google knows: this URL is exactly one locale, no interpretation needed.
Pitfall 1: 307 redirects undermine your canonical signals
Recently I noticed our root redirect (/ → /nl) was returning HTTP 307 (temporary) instead of 308 (permanent). To users it makes no difference - both lead them neatly to the Dutch homepage. To Google it matters:
- 307 Temporary Redirect → "this page may return on
/, so index both" - 308 Permanent Redirect → "this page has moved to
/nl, treat/nlas the canonical"
In Search Console I saw Google regularly attempting to index / again as a separate page, triggering canonical warnings. Since switching to 308 that behaviour disappears.
// next.config.ts
async redirects() {
return [
{ source: '/', destination: '/nl', permanent: true }, // 308
];
}
// For middleware-driven redirects:
return NextResponse.redirect(new URL('/nl', req.url), 308);
permanent: true in the Next.js redirect helper yields 308. permanent: false yields 307. Default is false, which in practice is the wrong choice for locale redirects.
Pitfall 2: forgetting hreflang on dynamic routes
Static pages like /about are easy: in generateMetadata you add alternates.languages and you're done. Dynamic routes (like /blog/[slug]) are tricky, because not every slug exists in every language.
// 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 points at the fallback (NL for us)
languages['x-default'] = `https://salonnare.com/nl${path}`;
return { languages };
}
For blog posts you check in your MDX loader which locales are available for that slug, and pass only those. A blog post that exists only in NL has no <link rel="alternate" hreflang="en" href="..." /> - otherwise Google gets a 404 on that alternate URL and your trust score drops.
Pitfall 3: canonical to the cleaned URL, not the requested one
A user enters via /en/blog/?ref=newsletter&utm_campaign=april. Your canonical must be /en/blog, without the tracking parameters. Otherwise Google sees every campaign URL as a separate page and your link equity fragments across variants.
// In your layout or page metadata
return buildMetadata({
locale,
path: '/blog',
// canonical is built here without query string
});
Our buildMetadata helper always builds the canonical from (locale, path), never from req.url. That's a deliberate choice.
Pitfall 4: sitemap without hreflang annotations
Your sitemap is more than a URL list. For multilingual sites every URL should declare which alternates exist:
<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 supports this via the alternates field. Don't forget x-default - that tells Google which version to show users without clear locale signals.
// 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',
},
},
}];
Pitfall 5: locale slug different from path slug
In English you might call your page /en/about, in Spanish /es/sobre-nosotros. Then the path slug is language-dependent, and you need to maintain a mapping. next-intl supports this 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',
},
},
});
Nice for SEO (local keywords in URL), but doubles the complexity of your sitemap and hreflang generation. For Salonnare we evaluated per page whether the SEO win is worth the complexity. For most pages: no. For /booking and /pricing: yes.
Pitfall 6: switching language loses the current route
An English visitor on /en/blog/post-slug clicks the NL flag. Bad implementation: she lands on /nl (homepage). Good implementation: she lands on /nl/blog/post-slug (same post, NL version if it exists) or /nl/blog (parent, if the specific post has no NL version).
// LanguageSwitcher.tsx
import { useRouter, usePathname } from 'next/navigation';
function switchLocale(newLocale: string) {
const pathname = usePathname();
const newPath = pathname.replace(/^\/[a-z]{2}/, `/${newLocale}`);
router.push(newPath);
}
For dynamic routes: first check whether the target slug exists in the new locale. Otherwise fall back to the parent route.
What I recommend
- Use
localePrefix: 'always'. Short URLs aren't worth optimising for. - Set all locale redirects to 308.
permanent: trueinnext.config.ts. - Build one central
buildMetadatahelper that makes canonical and hreflang consistent across routes. All pages call it, no one writes it locally. - Test your sitemap output regularly with the Search Console URL inspect tool. There you immediately see whether Google reads your hreflang correctly.
- Hold off on locale-specific pathnames until SEO data shows it pays off. For most sites
/en/aboutis fine.
i18n in Next.js is a grey zone between "works" and "works for SEO". next-intl does the first 80% out of the box; the last 20% is canonical hygiene and redirect status codes. Get those right and your international traffic sits in good shape.
