Multilingual Next.js with next-intl: hreflang Without the Mistakes
Key takeaways
- hreflang requires reciprocity: every locale must reference every other locale and itself.
- Never generate alternates for locales where the page does not actually exist.
- x-default tells Google which version to serve when no language matches.
- Lighthouse flags self-referencing canonical + hreflang as an error. It is a false positive.
- Localised slugs are better for SEO but add routing complexity — decide before you build.
Building a multilingual Next.js site is straightforward until search engines get involved. The routing works, the translations render, and then Google serves the German version to English users, or treats your locales as duplicate content.
The problem is almost never the framework. It is the metadata around it.
This article covers the parts that actually break: hreflang reciprocity, conditional alternates, x-default, and one Lighthouse false positive that will make you doubt a correct implementation. Examples use Next.js App Router with next-intl, but the SEO logic applies regardless of library.
Contents
Routing setup
Start with the decision that is expensive to change later: URL structure.
Subdirectories (/de/, /uk/) are correct for most projects. All locales share one domain's authority, hosting stays singular, and configuration is simple. Subdomains and country-code domains have their uses, but not for a typical multi-language site.
With next-intl, the middleware handles locale detection and routing:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};
And the routing config:
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'de', 'uk', 'ru'],
defaultLocale: 'en',
localePrefix: 'as-needed'
});
localePrefix: 'as-needed' serves the default locale at the root (/about) and prefixes the others (/de/about). This is the common choice. The alternative, 'always', prefixes every locale including the default — cleaner conceptually, but it means your root URL redirects, which some people dislike.
Whichever you choose, be consistent. Mixed behaviour is where canonical bugs come from.
The metadata layer
This is where multilingual SEO lives. Every page needs generateMetadata that produces locale-correct title, description, canonical and alternates.
A helper keeps this consistent across pages:
// lib/seo.ts
const SITE = 'https://example.com';
const LOCALES = ['en', 'de', 'uk', 'ru'] as const;
type Locale = typeof LOCALES[number];
function localeUrl(locale: Locale, path: string) {
const cleanPath = path === '/' ? '' : path;
return locale === 'en'
? `${SITE}${cleanPath}`
: `${SITE}/${locale}${cleanPath}`;
}
export function buildMetadata({
locale,
path,
title,
description,
availableLocales = LOCALES,
}: {
locale: Locale;
path: string;
title: string;
description: string;
availableLocales?: readonly Locale[];
}) {
const languages: Record<string, string> = {};
for (const l of availableLocales) {
languages[l] = localeUrl(l, path);
}
languages['x-default'] = localeUrl(
availableLocales.includes('en') ? 'en' : availableLocales[0],
path
);
return {
title,
description,
alternates: {
canonical: localeUrl(locale, path),
languages,
},
openGraph: {
title,
description,
url: localeUrl(locale, path),
locale,
},
};
}
The availableLocales parameter is the important part. Read on for why.
The four hreflang mistakes
Missing reciprocity
If your German page points to the English one but the English page does not point back, Google discards the entire annotation set. Not just the broken link — all of it.
Every locale must reference every other locale, and itself. The self-reference is not optional; it is part of the specification.
The helper above handles this by generating the full map from a single source of truth. Hand-writing alternates per page is where reciprocity breaks.
Relative URLs
hreflang requires absolute URLs including protocol and domain. /de/about is silently ignored. https://example.com/de/about works.
Next.js metadataBase helps with Open Graph but does not save you here — build the absolute URL explicitly.
Alternates for pages that do not exist
This is the subtle one, and it bites hardest on content sites.
Suppose you have a blog where an article exists in German and English but not Ukrainian. If your metadata helper blindly generates all four locales, you are telling Google that /uk/blog/some-article exists. It does not. Google crawls it, gets a 404, and downgrades trust in your annotations.
The fix is to pass the actual set:
// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
const { locale, slug } = await params;
const post = await getPost(slug, locale);
const availableLocales = await getAvailableLocales(slug);
return buildMetadata({
locale,
path: `/blog/${slug}`,
title: post.title,
description: post.description,
availableLocales, // ← only what actually exists
});
}
Where getAvailableLocales reads the filesystem, your CMS, or wherever your content lives:
import { readdir } from 'node:fs/promises';
async function getAvailableLocales(slug: string) {
const files = await readdir(`content/blog/${slug}`);
return files
.filter(f => f.endsWith('.md'))
.map(f => f.replace('.md', ''))
.filter(l => LOCALES.includes(l as Locale));
}
Missing x-default
x-default tells Google which version to serve when none of your locales matches the user's language. Without it, Google picks on its own, and its pick may not be yours.
Point it at your most broadly useful version — usually English, or your default locale.
The sitemap
Your sitemap should mirror the same logic. One entry per locale, each listing all available alternates:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const entries: MetadataRoute.Sitemap = [];
const posts = await getAllPosts();
for (const post of posts) {
const available = post.locales;
for (const locale of available) {
entries.push({
url: localeUrl(locale, `/blog/${post.slug}`),
lastModified: post.updatedAt,
alternates: {
languages: Object.fromEntries(
available.map(l => [l, localeUrl(l, `/blog/${post.slug}`)])
),
},
});
}
}
return entries;
}
Same rule as before: only locales that exist. A sitemap that lists 404s is worse than a sitemap that lists fewer URLs.
The Lighthouse false positive
Here is the one that will make you second-guess a correct implementation.
Run Lighthouse on a page with self-referencing canonical and hreflang, and you may get:
Document does not have a valid
rel=canonical— Points to anotherhreflanglocation
Look at the URL in the error message. It is the same page. Lighthouse's canonical audit treats any match between the canonical URL and an hreflang entry as a conflict, including the self-reference that the specification requires.
I verified this across four locales on a production site: canonical self-references correctly on every one, and Lighthouse reports the same error on all four, including the default locale. The SEO score caps at 92 as a result.
Do not "fix" this. Removing the self-referencing hreflang to satisfy Lighthouse breaks reciprocity, and Google will discard your annotations. A tool warning is not worth real ranking damage.
Worth noting: on production the audit sometimes passes where localhost fails, likely due to how the canonical is resolved. Either way, verify with curl rather than trusting the score:
curl -s https://example.com/de | grep -E 'rel="(canonical|alternate)"'
What you want to see: canonical pointing at /de, and an hreflang="de" alternate pointing at the same URL. That is correct.
Localised slugs
A question that comes up: should /de/about be /de/ueber-uns?
For SEO, yes — a localised slug is a small ranking signal and reads better in results. Google does not require it, and the gain is modest.
The cost is routing complexity. next-intl supports pathname localisation:
export const routing = defineRouting({
locales: ['en', 'de', 'uk', 'ru'],
defaultLocale: 'en',
localePrefix: 'as-needed',
pathnames: {
'/': '/',
'/about': {
en: '/about',
de: '/ueber-uns',
uk: '/pro-nas',
ru: '/o-nas',
},
},
});
This works well for a fixed set of pages. For dynamic content — a blog with a hundred articles — you need a slug mapping per locale in your content layer, and every link and alternate generation has to go through it.
My advice: shared slugs for content collections, localised slugs for the handful of static marketing pages if you want them. Decide before you build, because retrofitting means changing every URL and setting up redirects.
Verification checklist
Before you consider the setup done:
# Reciprocity: each locale references all others and itself
for loc in "" "/de" "/uk" "/ru"; do
echo "=== $loc ==="
curl -s "https://example.com$loc" | grep -o 'hreflang="[^"]*"'
done
Then:
- Every locale's canonical points at itself
- Every locale lists all available alternates, including itself
- x-default present and pointing somewhere sensible
- No alternates for locales where the page returns 404
- Sitemap alternates match the page alternates
-
<html lang>set correctly per locale - Language switcher preserves the current path
That last one is a UX issue rather than SEO, but it is broken remarkably often. If switching language on /de/blog/article lands the user on /en, they leave.
Screaming Frog (free up to 500 URLs) has a dedicated hreflang report that catches missing reciprocity across a whole site faster than manual checking.
Summary
Multilingual Next.js is not hard. Multilingual metadata is where projects break, and the failures are invisible: the site looks fine, and only Search Console reveals that Google is serving the wrong language or ignoring your annotations entirely.
Three rules cover most of it. Generate alternates from one source of truth so reciprocity cannot break. Never annotate a locale that does not exist. Include x-default.
And when Lighthouse complains about your self-referencing canonical, check the actual output before changing anything. Sometimes the tool is wrong.