Musa Yılmaz
·6 min read

Getting hreflang and Multilingual SEO Right in Next.js

nextjsseoi18nnext-intl

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:

// 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"
    done

Empty output means the sitemap is clean.

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. 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 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;
}

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.