Correct bilingual hreflang in Next.js 16 with next-intl
How to get hreflang right in Next.js 16 with next-intl, without the sitemap saying something else.
Broken hreflang is silent: it doesn't break the page, it just costs you rankings. Google requires annotations to be reciprocal (if ES points to EN, EN must point back to ES) and ideally to carry an x-default. On a bilingual site built with the Next.js 16 App Router and next-intl v4, here's how I solve it at the root.
1. Localized URLs, not localized folders
The most common mistake is renaming folders to the target language. Instead, keep folder names in one language and map the URLs with pathnames:
// i18n/routing.ts
export const routing = defineRouting({
locales: ["es", "en"],
defaultLocale: "es",
localePrefix: { mode: "as-needed" }, // ES has no prefix, EN gets /en
pathnames: {
"/sobre-mi": { es: "/sobre-mi", en: "/about" },
"/blog/[slug]": { es: "/notas/[slug]", en: "/writeups/[slug]" },
},
});In Next.js 16 the middleware is named proxy.ts (renamed from 15). That's where next-intl routing lives:
// proxy.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
export default createMiddleware(routing);2. Internal links without prefix leaks
Inside app/[locale]/ I always use next-intl's wrapped Link, not next/link. That resolves the locale prefix and the translated pathname for you:
// i18n/navigation.ts
export const { Link } = createNavigation(routing);3. Per-page hreflang, paired by translationKey
Every piece of content carries a translationKey that is identical across its ES/EN pair. From it I build alternates.languages in generateMetadata, including x-default:
// app/[locale]/blog/[slug]/page.tsx
const languages: Record<string, string> = {};
if (post.locale === "es") languages.es = `/blog/${post.slug}`;
if (post.locale === "en") languages.en = `/en/blog/${post.slug}`;
if (translation) {
if (translation.locale === "es") languages.es = `/blog/${translation.slug}`;
if (translation.locale === "en") languages.en = `/en/blog/${translation.slug}`;
}
languages["x-default"] = languages.es ?? `/${locale}/blog/${post.slug}`;
return { alternates: { canonical: path, languages } };Next.js emits this as reciprocal <link rel="alternate" hreflang="es" …> tags. The point is that both directions exist: reciprocity is guaranteed by pairing on translationKey, not by a hand-written list.
4. The sitemap must tell the same story
hreflang in the <head> and in the sitemap have to match. In sitemap.ts I repeat the alternates.languages per entry:
alternates: {
languages: { es: urlEs, en: urlEn, "x-default": urlEs },
}Checklist
- Reciprocity guaranteed by data (
translationKey), not by hand. x-defaultpresent in every group.- Absolute, stable canonical URLs per locale.
<head>and sitemap consistent.
With this, a post with no translation sibling degrades gracefully (it self-references) instead of emitting broken hreflang — and the build stays green.