Back to blog
Hreflang Validator9 min readSeptember 3, 2026

How to Implement Hreflang in Next.js with the App Router

Hreflang in Next.js is declared with alternates.languages inside generateMetadata, no SEO library needed. App Router, next-intl, translated routes and the unprefixed default locale.


Next.js is the platform where hreflang is cleanest: no plugin, no module, no SEO library. The App Router covers it natively through the alternates.languages field of generateMetadata. What you do need is a clear idea of which URL belongs to which locale, because the framework simply prints what you hand it and validates nothing.

The basic App Router implementation

Any layout or page can export generateMetadata. Whatever you return in alternates.languages ends up as alternate tags in the head. The key is that the same languages object must be identical across every version of the page, and must include the page itself.

typescript
// app/[locale]/services/page.tsx
import type { Metadata } from 'next'

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

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>
}): Promise<Metadata> {
  const { locale } = await params

  const languages = Object.fromEntries(
    LOCALES.map((l) => [l, `${SITE}/${l}/services`]),
  )

  return {
    alternates: {
      canonical: `${SITE}/${locale}/services`,
      languages: {
        ...languages,
        'x-default': `${SITE}/en/services`,
      },
    },
  }
}

Because the languages object is built by walking the full locale list, self-reference and the return tag come for free: all three versions declare exactly the same group. That is why this pattern fails far less often than writing the tags by hand.

In Next.js 15 and later, params is a promise and has to be awaited. If you copy an older example that destructures params directly, TypeScript will warn you, but plain JavaScript will fail silently and you will end up with URLs containing "[object Promise]".

The unprefixed default locale

Many sites serve the main language with no prefix (example.com/services) and everything else with one (example.com/es/servicios). That is next-intl’s "as-needed" setting. This is where most implementations break, because the filesystem segment is still [locale] with the value "en" and it is tempting to build the URL from it.

typescript
// With localePrefix: 'as-needed' and defaultLocale: 'en'
const SITE = 'https://example.com'

function urlFor(locale: string, path: string) {
  // The default locale has NO prefix in the public URL,
  // even though the router segment says 'en'.
  return locale === 'en' ? `${SITE}${path}` : `${SITE}/${locale}${path}`
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>
}): Promise<Metadata> {
  const { locale } = await params

  return {
    alternates: {
      canonical: urlFor(locale, '/services'),
      languages: {
        en: urlFor('en', '/services'),
        es: urlFor('es', '/services'),
        'x-default': urlFor('en', '/services'),
      },
    },
  }
}

If you build the URL as `${SITE}/${locale}${path}` without that condition, the English version will declare https://example.com/en/services. That URL normally redirects to the unprefixed one, and an hreflang tag pointing at a redirect is a tag Google treats as invalid.

Translated routes

If you also translate the slug — /services in English, /servicios in Spanish — you cannot reuse one path across locales. You need an explicit map per page. next-intl solves this with its pathnames config, but the principle holds with any approach: each locale has its own route and the hreflang group has to reflect it.

typescript
const PATHS = {
  en: '/services',
  es: '/servicios',
  fr: '/prestations',
} as const

const languages = Object.fromEntries(
  Object.entries(PATHS).map(([l, path]) => [
    l,
    l === 'en' ? `${SITE}${path}` : `${SITE}/${l}${path}`,
  ]),
)

Dynamic pages

On parameterised routes — product pages, articles — the group has to be built from the content, not from the locale list. If an article exists in only two of three languages, the group must have two entries. Declaring a locale whose URL does not exist produces a tag pointing at a 404, and that invalidates the entire group.

Check that the translation exists before adding it to the languages object. It is more code, but it is the difference between a valid group and one Google discards outright.

What to check once it is deployed

In Next.js the fault is almost never in the syntax: it is in the URLs you generated. Extra prefixes, untranslated routes, locales declared for content that does not have them. Since the framework prints without validating, the fault only shows in the served HTML, and only by comparing the versions of one group against each other.

Try the tool for free

Analyze your URLs with Hreflang Validator by iRankly. No sign-up, no credit card.

Use tool for free

If you are on another platform, or want the conceptual detail of what each attribute does:


Try the tool for free

Analyze your URLs with Hreflang Validator by iRankly. No sign-up, no credit card.

Use tool for free