Hreflang Tags Wrong After Next.js App Router i18n

Hreflang tags wrong or missing after a Next.js App Router i18n migration? Search Console flagging no-return-tag errors? Here is the metadata fix to ship.
SEONext.jsInternationalization
May 17, 20266 min read1098 words

The Problem

I ran into this on a client B2B site that migrated from Pages Router to App Router with locale-based routing (/en, /de, /fr, /es). Their old setup used Next.js built-in i18n config from next.config.js, which generated hreflang tags automatically. The migration to App Router uses middleware-driven locale rewrites because App Router does not ship the same built-in config.

Two weeks after the migration, Google Search Console started flagging the property:

International Targeting
  No return tags (hreflang): 1,284 pages
  Hreflang URLs reciprocal: 0 pages confirmed

Organic traffic from .de and .fr dropped about 40% in the same window. Switching locales worked, the canonical tag was right, the hreflang tags were the only thing wrong, and they were wrong in three ways at once: most pages were missing them, a few pages had hreflang values that pointed to the unlocalised root, and the x-default was never set.

Why It Happens

App Router does not have a built-in i18n routing solution. Next.js 13 moved locale routing to userland middleware, and most teams (including this one) follow next-intl or the official i18n example, which detect the locale in middleware and rewrite the URL internally. Navigation works fine. The part that does not survive the migration is hreflang tag generation, because Pages Router did that for free and App Router does not.

Three things go wrong in practice:

  1. generateMetadata does not know the available locales. If you return alternates: { canonical: '/about' } from a page's generateMetadata, Next.js renders one <link rel="canonical"> and no <link rel="alternate" hreflang> tags at all. Most teams realise this and add languages, but they pass relative paths, which Next.js resolves against the request URL. If the request URL is already locale-prefixed, the resulting hreflang URLs double up the locale (/de/de/about).
  2. Middleware rewrites strip the locale before metadata renders. A common middleware pattern rewrites /de/about to /about?locale=de internally for cleaner route files. When generateMetadata runs, headers().get('x-url') (or whatever you use) reports the rewritten URL, and your alternates get built off the unlocalised path.
  3. metadataBase is set to the bare origin. That is right for canonical, but the languages map needs absolute URLs for international targeting. If metadataBase is https://example.com and you pass languages: { de: '/de/about' }, Next.js outputs <link rel="alternate" hreflang="de" href="https://example.com/de/about">, which is correct. But teams often set metadataBase to https://example.com/en when localising and break every alternate URL by accident.

Google requires reciprocal hreflang tags: every translated version of a page must list every other version, including itself. If your alternates map only contains the current locale, Search Console flags the missing return tags and drops the international results.

The Google hreflang documentation covers the rules. The Next.js metadata reference shows the alternates.languages shape but skips the middleware case.

The Fix

You need a single helper that builds the alternates map from a list of supported locales and a current pathname, then use it in every generateMetadata that should be localised. Stop letting individual pages construct hreflang URLs by hand — that is where the inconsistencies start.

Step 1: Build the helper. Drop this into lib/i18n/alternates.ts:

import type { Metadata } from 'next'

const LOCALES = ['en', 'de', 'fr', 'es'] as const
const DEFAULT_LOCALE = 'en'
const BASE_URL = 'https://example.com'

type Locale = (typeof LOCALES)[number]

export function buildAlternates(
  pathWithoutLocale: string,
  currentLocale: Locale,
): Metadata['alternates'] {
  const clean = pathWithoutLocale.replace(/^\/+|\/+$/g, '')
  const suffix = clean ? `/${clean}` : ''

  const languages = Object.fromEntries(
    LOCALES.map((locale) => [
      locale,
      `${BASE_URL}/${locale}${suffix}`,
    ]),
  )

  languages['x-default'] = `${BASE_URL}/${DEFAULT_LOCALE}${suffix}`

  return {
    canonical: `${BASE_URL}/${currentLocale}${suffix}`,
    languages,
  }
}

Absolute URLs everywhere. The pathWithoutLocale argument is the route path stripped of any leading locale segment, so /about for the about page and /blog/post-slug for a blog post. The helper outputs the canonical for the current locale and a languages map that includes every supported locale plus x-default.

Step 2: Use it in generateMetadata. In app/[locale]/about/page.tsx:

import { buildAlternates } from '@/lib/i18n/alternates'

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: 'en' | 'de' | 'fr' | 'es' }>
}) {
  const { locale } = await params
  return {
    title: 'About us',
    alternates: buildAlternates('about', locale),
  }
}

Every localised page uses the same helper with its locale-free path. Hreflang tags are now reciprocal across all four locales and x-default is always set.

Step 3: Confirm the middleware does not rewrite locale out of the URL before metadata. If your middleware rewrites /de/about to an internal path, you have to keep the locale visible somewhere generateMetadata can read. The cleanest pattern is to keep the locale in params via the route segment, which the example above does. If you cannot use a [locale] segment, pass the locale through a custom header in middleware and read it from headers() inside generateMetadata.

Step 4: Verify on a deployed URL. View source on /de/about and look for:

<link rel="canonical" href="https://example.com/de/about">
<link rel="alternate" hreflang="en" href="https://example.com/en/about">
<link rel="alternate" hreflang="de" href="https://example.com/de/about">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about">
<link rel="alternate" hreflang="es" href="https://example.com/es/about">
<link rel="alternate" hreflang="x-default" href="https://example.com/en/about">

Every locale page should output the same set of alternates. Test the same URL via curl -s https://example.com/de/about | grep hreflang from a non-edge location to be sure no edge middleware is mangling the response.

Step 5: Re-submit your sitemaps. Search Console caches hreflang reports for up to 28 days. After confirming the tags on a sample of pages, re-submit the sitemap and wait. The "No return tags" count drops within a few days as Google re-crawls.

The Lesson

App Router gave up the built-in hreflang generator that Pages Router shipped, and middleware-driven locale routing makes it easy to render canonical and hreflang URLs that disagree with each other. Centralise the alternates map in one helper, return reciprocal tags from every localised generateMetadata, always include x-default, and verify on the deployed origin before trusting Search Console.

If your international rankings dropped after a Next.js migration and you need an audit that goes beyond "the tags look right in dev", that is the work I do. See my services. For a related GSC indexing problem I covered, see Duplicate canonical errors in Search Console.

Back to blogStart a project