npm i next-intl-alternates

hreflang that survives review.

Canonical and alternate URLs for a next-intl site. Four lines of configuration produce a set of links that is easy to get subtly wrong, and wrong here fails quietly: the annotation is dropped, and nothing tells you.

Turn the configuration and watch the head change

locales
enjade
defaultLocale
localePrefix
this page exists in
pathname
options

what Next puts in the head

<link rel="canonical" href="https://example.com/about" />

<link rel="alternate" hreflang="en" href="https://example.com/about" />

<link rel="alternate" hreflang="ja" href="https://example.com/ja/about" />

<link rel="alternate" hreflang="de" href="https://example.com/de/about" />

<link rel="alternate" hreflang="x-default" href="https://example.com/about" />

  • en carries no prefix, so its canonical is the bare path. A canonical that redirects points at a page that is not there.

Configure it once

It takes next-intl's routing object as it is — locales, default locale, prefixes, localized pathnames — plus the origin, which next-intl has no reason to know.

// src/libs/alternates.ts
import { createAlternates } from "next-intl-alternates";
import { routing } from "@/i18n/routing";

export const getAlternates = createAlternates({
  baseUrl: "https://example.com",
  ...routing,
});
// src/app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const { locale, slug } = await params;
  const article = await getArticle(locale, slug);

  return {
    alternates: getAlternates({
      locale,
      pathname: "/blog/[slug]",
      params: { slug },
      // this article was only ever written in Japanese
      availableLocales: article.locales,
    }),
  };
}

The result drops straight into Metadata["alternates"]. Nothing else about your metadata changes.

What it is careful about

The home page that redirects

With as-needed prefixes the default locale has no prefix, and its home page is the origin. Join a locale prefix to a path of "/" and you get "/ja/", which redirects to "/ja" with a 308. A canonical URL that redirects points at a page that is not there.

The language the page does not have

An article written in one language only has one version. Listing a language the page does not have makes search engines drop the annotation — all of it, not just that line.

x-default with nowhere to point

x-default is for a reader whose language you do not publish in. It belongs on the default locale, except on a page the default locale does not have, where it belongs on the version that exists.

The pathname that is not the route

With localized pathnames, /about is /kaisha in Japanese. Every line has to point at that locale's own pathname, or the annotation names URLs that 404.