Getting hreflang and Multilingual SEO Right in Next.js
When you ship a multilingual site, you need to tell Google two things clearly: which URL is the primary address of each page (canonical), and where its counterparts in other languages live (hreflang). Get these wrong and Google treats your pages as duplicates, serves the wrong language in results, or skips indexing altogether.
This post covers how to wire both signals correctly in a Next.js App Router site using next-intl, and the three mistakes that show up most often in practice.
Why is declaring hreflang in the root layout wrong?
The most common mistake is putting alternates in the root layout's generateMetadata:
// ❌ Don't do this
export async function generateMetadata() {
return {
alternates: {
languages: {
tr: "https://example.com/tr",
en: "https://example.com/en",
},
},
};
}This declaration is inherited by every page under that layout. The result: /en/about and /tr/blog/some-post both advertise the homepage as their language alternate. You are telling Google "the English version of this page is the homepage," which is false.
The fix is for each page to produce its own alternates in its own generateMetadata.
Why should localized paths come from a single source?
next-intl lets you translate route paths — /hakkimda in Turkish, /about in English:
// i18n/routing.ts
export const routing = defineRouting({
locales: ["tr", "en"],
defaultLocale: "tr",
pathnames: {
"/hakkimda": { tr: "/hakkimda", en: "/about" },
"/blog/[slug]": { tr: "/blog/[slug]", en: "/blog/[slug]" },
},
});Here is the trap: writing that mapping a second time when you generate the sitemap or canonical URLs — for example keeping an array like ["hakkimda", "iletisim"] inside sitemap.ts. The two sources drift apart over time, and your sitemap starts advertising URLs that don't exist. Each one lands on a redirect and burns crawl budget.
The fix is to treat next-intl's getPathname as the single source of truth:
// i18n/navigation.ts
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);Now every place that produces a URL is fed by the same definition:
// lib/seo.ts
export function absoluteUrl(href: Href, locale: string) {
return `${SITE_URL}${getPathname({ href, locale })}`;
}
export function localeAlternates(href: Href, locale: string) {
return {
canonical: absoluteUrl(href, locale),
languages: Object.fromEntries(
routing.locales.map((l) => [l, absoluteUrl(href, l)])
),
};
}Using it on a page is one line:
// app/[locale]/hakkimda/page.tsx
export async function generateMetadata({ params }) {
const { locale } = await params;
return {
title: "About",
alternates: localeAlternates("/hakkimda", locale),
};
}The output is page-specific and correct:
<link rel="canonical" href="https://example.com/en/about" />
<link rel="alternate" hreflang="tr" href="https://example.com/tr/hakkimda" />
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />Why should the sitemap include localized paths?
The same absoluteUrl helper belongs in your sitemap. The App Router's sitemap.ts supports an alternates.languages field and emits it in the xhtml:link format Google recommends (this is in the Next.js sitemap docs). A generic version looks like this:
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [];
for (const locale of routing.locales) {
for (const href of staticHrefs) {
entries.push({
url: absoluteUrl(href, locale),
lastModified: new Date(),
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, absoluteUrl(href, l)])
),
},
});
}
}
return entries;
}After deploying, run a quick check — every URL in the sitemap should return 200, never a redirect:
curl -s https://example.com/sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed 's/<[^>]*>//g' \
| while read u; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$u")
[ "$code" != "200" ] && echo "$code $u"
doneEmpty output means the sitemap is clean.
Two notes from this site's own sitemap, which is not a sitemap.ts but a route handler (app/sitemap.xml/route.ts) that writes the same xhtml:link XML by hand. I chose the handler deliberately over the metadata convention; it feeds from the same absoluteUrl helper, so the single-source rule still holds. If you go this route, remember a handler that only reads local files is not refreshed by a route-level revalidate; this site marks it force-dynamic.
The second note is lastmod. The first version wrote new Date() for every static page, which means every URL claimed to have changed at request time. Google only uses lastmod when it is consistently and verifiably accurate, so a value that is always "now" makes the whole signal untrustworthy. The sitemap now sets lastmod only where content really changes (the list pages take the date of their newest item, posts take their own date) and omits it elsewhere. Omitting is better than faking.
How do you pair hreflang when TR and EN slugs differ?
For static pages, the path mapping comes from your routing config. Blog posts are different — their slugs are usually unrelated across languages:
/tr/blog/cok-dilli-seo-kurulumu/en/blog/multilingual-seo-setup
There's no way to match those automatically, because they share nothing. The fix is a matching key in frontmatter:
---
title: "Multilingual SEO Setup"
locale: "en"
translationKey: "multilingual-seo-setup"
---Both language files carry the same translationKey. The lookup helper is small:
export function getTranslationSlug(post, targetLocale: string) {
const key = post.frontmatter.translationKey;
if (!key) return null;
return (
getPosts(targetLocale).find(
(p) => p.frontmatter.translationKey === key
)?.slug ?? null
);
}When the key is missing it returns null and no hreflang is emitted for that language. On this site a post's languages map is only added to the metadata when it has more than one entry, so an untranslated post gets a canonical and nothing else. That matters: a missing hreflang is better than a wrong one. A wrong pairing sends Google to the wrong page; a missing one just skips a signal.
Why did I turn off next-intl's own alternate links?
next-intl's middleware also sends Link response headers with rel="alternate" hreflang entries, built from the pathnames config. For blog posts that goes wrong: /blog/[slug] is the same pattern in both languages, so the header carries the same slug into the other locale. When the TR and EN slugs differ, that produces a hreflang link to a URL that returns 404.
Since every page already builds its own correct alternates.languages in generateMetadata, the fix is to switch the header off:
export const routing = defineRouting({
locales: ["tr", "en"],
defaultLocale: "tr",
alternateLinks: false,
pathnames: { /* ... */ },
});The point: hreflang can reach Google through three channels (HTML tags, sitemap, HTTP headers) and they all have to agree. A generated channel you forgot about can contradict the ones you wrote carefully.
Why can a language switcher 404 on blog posts?
An easy thing to miss: what happens when a reader switches language while on a blog post? A naive switcher carries the current slug over and lands on a URL that doesn't exist in the target language — a 404.
Language switchers are usually client components, so they can't read content files. Compute the mapping on the server and pass it down:
// layout.tsx (server component)
<Header blogSlugMap={getBlogSlugMap(routing.locales)} />// locale-switcher.tsx (client component)
function paramsFor(targetLocale: string) {
const slug = params?.slug;
if (pathname !== "/blog/[slug]" || typeof slug !== "string") return params;
const translated = blogSlugMap?.[targetLocale]?.[slug];
return translated ? { ...params, slug: translated } : params;
}The map itself is built on the server from the same translationKey lookup, looping over every source and target locale pair and skipping posts with no counterpart; a post with no translation keeps its current slug. Index the map by target locale, not source locale. With two languages both produce the same result, but the moment you add a third, a source-indexed map sends users to the wrong slug.
Verify after deploying
Check three things once you're live:
# Does a subpage's hreflang actually point at that page?
curl -s https://example.com/en/about | grep -E 'rel="(alternate|canonical)"'
# Does the sitemap contain localized paths?
curl -s https://example.com/sitemap.xml | grep '/en/'Then submit the sitemap in Google Search Console and run URL Inspection on a few pages. The "User-declared canonical" line should match the canonical you emit.
Summary
Multilingual SEO usually goes wrong not because of some obscure Google rule, but because URL generation is duplicated across several places. Keep the path definitions in one source and feed canonical, hreflang, sitemap, and the language switcher from it — then adding a new page keeps all of them correct for free.