Hreflang in Next.js and Nuxt
Next.js and Nuxt both route visitors to the right locale, but neither turns that routing into correct hreflang on its own. Next.js gives you a metadata API and leaves the values to you; Nuxt's i18n module can generate the tags but needs a base URL and correct language codes to do it well.
Most hreflang errors on these sites come from relative addresses, a layout's alternates being overwritten by a page, middleware redirecting crawlers, or a second source of annotations added by a library. This guide shows how to generate the full cluster from your locale configuration in each framework and how to test the output.
Start from one locale map
Both frameworks already hold a list of locales somewhere: in next.config.js, in a library
such as next-intl, or in the i18n block of nuxt.config.ts. Hreflang errors
appear when the annotations are built from something else, such as a hand-typed list in a layout. Put
the facts hreflang needs in one module and import it everywhere:
// i18n/locales.ts
export const SITE = "https://example.com";
export const LOCALES = {
"en-gb": { hreflang: "en-GB", prefix: "" }, // default, no prefix
"en-ie": { hreflang: "en-IE", prefix: "/ie" },
"fr": { hreflang: "fr", prefix: "/fr" },
} as const;
export const DEFAULT_LOCALE = "en-gb";
export type Locale = keyof typeof LOCALES;
Keep the routing segment and the hreflang value separate. The route segment can be anything convenient,
like ie; the hreflang value must be a valid language code with an optional region, like
en-IE. Many Next.js sites use the route segment directly as the hreflang value, which works
only if the segments happen to be valid codes.
If pages have translated slugs, such as /fr/tarifs for /pricing, the map
also needs a path for each page in each locale. Store that with the page content, or in a routes file,
and look it up rather than assuming the path is the same in every language.
Next.js App Router: the metadata API
The App Router has no built-in i18n routing. Most sites put a [locale] segment at the
top of the app directory and use middleware or a library to choose it. Hreflang goes in
the alternates field of the metadata you return from generateMetadata:
// app/[locale]/pricing/page.tsx
import type { Metadata } from "next";
import { LOCALES, DEFAULT_LOCALE, type Locale } from "@/i18n/locales";
import { pathFor } from "@/i18n/paths"; // returns the localised path for a page key
export async function generateMetadata(
{ params }: { params: Promise<{ locale: Locale }> }
): Promise<Metadata> {
const { locale } = await params;
const languages: Record<string, string> = {};
for (const [key, cfg] of Object.entries(LOCALES)) {
languages[cfg.hreflang] = cfg.prefix + pathFor("pricing", key as Locale);
}
languages["x-default"] = pathFor("pricing", DEFAULT_LOCALE);
return {
alternates: {
canonical: LOCALES[locale].prefix + pathFor("pricing", locale),
languages,
},
};
}
Next.js renders each entry as a <link rel="alternate" hreflang="โฆ" href="โฆ"> in
the head, including the current locale, so the self-reference comes for free as long as your loop includes
every locale. In older versions params is a plain object rather than a promise; awaiting
it works in both.
Set metadataBase, or the addresses are wrong
The paths above are relative. Next.js turns them into absolute addresses using metadataBase,
which you set once in the root layout:
// app/layout.tsx
export const metadata = { metadataBase: new URL("https://example.com") };
Without it, Next.js warns at build time and falls back to a local address during development, and in some deployments to the platform's own hostname. Hreflang pointing at a preview domain is a common result.
Layouts and pages do not merge alternates
Metadata from layouts and pages is merged shallowly. If the layout sets alternates and
the page also sets alternates, the page's object replaces the layout's entirely. A page
that sets only a canonical therefore wipes out the layout's hreflang. Generate the whole alternates
object in one place for each page, using a shared helper, rather than splitting it between levels.
Next.js Pages Router: i18n config and next/head
The Pages Router has i18n routing built in through next.config.js, with sub-path or domain
routing. It sets the lang attribute and handles locale prefixes, but it does not print hreflang.
You add it in a shared component with next/head:
// components/Hreflang.tsx
import Head from "next/head";
import { useRouter } from "next/router";
const SITE = "https://example.com";
const CODES = { "en-GB": "en-GB", fr: "fr", de: "de" };
export default function Hreflang() {
const { asPath, locales = [], defaultLocale } = useRouter();
const path = asPath.split(/[?#]/)[0];
const href = (l: string) => SITE + (l === defaultLocale ? "" : "/" + l) + path;
return (
<Head>
{locales.map((l) => (
<link key={l} rel="alternate" hrefLang={CODES[l]} href={href(l)} />
))}
<link rel="alternate" hrefLang="x-default" href={href(defaultLocale!)} />
</Head>
);
}
Two details matter. The query string and fragment are stripped from asPath, so tracking
parameters never leak into the tags. And this version assumes the same path in every locale; if your
slugs are translated, look the paths up instead. With domain routing, build each address from the locale's
domain rather than SITE.
Note that the Pages Router's i18n routing does not work with static export (output: "export").
Statically exported multilingual sites generate each locale's pages themselves and must add hreflang
in the same way.
Nuxt with @nuxtjs/i18n
The official Nuxt i18n module can generate hreflang, canonical and og:locale tags from
its own configuration. It needs two things to get them right: a baseUrl, so addresses are
absolute, and a valid language code on each locale.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@nuxtjs/i18n"],
i18n: {
baseUrl: "https://example.com",
strategy: "prefix_except_default",
defaultLocale: "en",
locales: [
{ code: "en", language: "en-GB" },
{ code: "ie", language: "en-IE" },
{ code: "fr", language: "fr-FR" },
],
},
});
Older versions of the module call the language field iso rather than language.
Then add the generated tags in app.vue or your default layout with the useLocaleHead
composable, passing it to useHead. The option that switches on the hreflang and canonical
output has been renamed between major versions, so check the documentation for the version in your lock
file and confirm the output in the rendered HTML.
- Missing baseUrl produces relative addresses or ones built from the request host, which may be a preview domain.
- Invalid codes come from putting a route code like
iewhere the language code belongs. - Pages without translations still appear in every locale's hreflang if the module thinks the route exists everywhere. If some content exists only in some languages, handle those routes explicitly so the cluster lists only real pages.
- x-default may be generated from the default locale. Check whether your version does this and that it points where you want.
Middleware, redirects and crawlers
Locale detection is where framework sites most often hide pages from Google. A common Next.js middleware
reads the Accept-Language header and redirects to a locale prefix. Google's documentation
says Googlebot usually crawls without an Accept-Language header and mostly from United States
addresses, so this logic sends Google to the default locale, or loops it, however many locales you have.
- Only redirect a request that has no locale in the path, such as the bare root. Never redirect a request
for
/fr/tarifsto/pricingbecause the browser prefers English. - In the Pages Router, set
localeDetection: falsein the i18n config if automatic detection is redirecting crawlers from the root. - Prefer a banner suggesting another language over an automatic redirect, and remember the choice in a cookie only after the visitor clicks.
- Make sure every locale page returns 200 when requested directly with no cookies and no language header.
You can reproduce what Google receives with curl, which sends no language header by default:
curl -sI https://example.com/fr/tarifs | head -1 # expect 200, not 307 or 308
curl -sI https://example.com/ | grep -i "^location" # see where the root sends visitors
Sitemaps, Link headers and libraries: one source only
Framework projects can end up declaring hreflang in three places without anyone deciding to. The App
Router's app/sitemap.ts accepts an alternates.languages object per entry in
recent versions, which writes xhtml:link entries. The next-intl library's middleware can
send hreflang as an HTTP Link response header, controlled by its alternateLinks
option. And your metadata writes head tags. If these are built from different data, they disagree.
Pick one. For most projects the head tags from the metadata API or the Nuxt head helper are easiest
to test. If you use a library that adds a Link header, either switch that off or remove
your own tags, and make sure any sitemap alternates are generated from the same locale map. The sitemap
mismatch guide covers choosing between sources.
curl -sI
on a locale page before assuming your head tags are the only hreflang on the site.The fix, step by step
Step 1
Write down the locale map the site really uses
List every locale the router serves, its path prefix or domain, the valid hreflang
code for it, and the default locale. Compare that with next.config.js, your i18n library
settings or the Nuxt i18n block, and correct any route codes that are not valid language codes. Put the
result in one module that routing, the language switcher and hreflang all import, so there is only one
list to change when you add a market.
Step 2
Build one helper that returns every alternate
Write a function that takes a page key and returns the canonical path for each locale,
including translated slugs, plus the x-default. It should include the current locale, strip query strings
and apply one trailing slash rule that matches your trailingSlash setting. Leave out locales
where the page is not published. Use the same function for the canonical, the hreflang tags and any sitemap
alternates, so they cannot drift apart.
Step 3
Output the tags through the framework head system
In the App Router, return the full alternates object with canonical and
languages from each page's generateMetadata, and set metadataBase in the root
layout. In the Pages Router, render the links with next/head in a shared component. In Nuxt,
set baseUrl and valid language codes, then pass useLocaleHead output to useHead.
Remove any hand-written hreflang left in layouts or custom documents.
Step 4
Stop middleware redirecting crawlers
Review middleware and i18n settings for automatic language detection. Limit redirects to requests with no locale in the path, never redirect a request that already names a locale, and disable browser-language detection where it redirects the root for crawlers. Request each locale page with curl, sending no cookies and no language header, and confirm it returns 200 with its own content rather than a 307 or 308 to another locale.
Step 5
Keep sitemaps and headers from competing
Check whether hreflang is also being produced by app/sitemap.ts, a sitemap
module or an i18n library's HTTP Link header. Decide which single source you will keep and
switch off the others, or feed every one from the same helper so they are identical. Check the response
headers of a locale page with curl -sI, because a Link header will not appear when you view
the page source.
Step 6
Test the production build output
Run a production build locally or on a staging deployment with the live base URL and fetch the raw HTML of one page per locale. Confirm every hreflang address is absolute, uses the production host, matches the canonical and includes the page itself and the x-default. Then deploy and run the Hreflang Checker across each locale. Add a test to your pipeline that fails if any hreflang points at a preview host.
Keeping it right as the project grows
- Test the rendered head in CI. A small test that builds the site, fetches a page per locale and checks the hreflang block catches regressions from framework upgrades and refactors.
- Watch major upgrades. Changes to how
paramsis passed, how metadata is merged or what i18n module options are called can alter the output silently. - Never hard-code hosts. Read the production base URL from configuration and fail the build if it is missing, so preview hosts cannot leak into tags.
- Add new locales in one place. A new market should mean one entry in the locale map and translated content, nothing else.
For other head tags in the same framework, see fixing meta tags in Next.js.
1. Does Next.js add hreflang tags automatically?
No. The Pages Router's i18n routing handles locale prefixes, domains and the lang attribute, but it does not print hreflang; you add the links yourself, usually with next/head in a shared component. The App Router has no built-in i18n routing at all, but its metadata API renders hreflang for you from the alternates.languages object you return. In both cases you decide the values, so the accuracy depends on building them from your locale configuration.
2. Why are my Next.js hreflang URLs relative or pointing at localhost?
In the App Router, relative paths in alternates are made absolute using metadataBase, which is normally set in the root layout. If it is missing, Next.js warns at build time and falls back to a local or platform-provided address, so tags can point at localhost or a preview domain. Set metadataBase to your production address, ideally from an environment variable that fails the build if it is empty, and check the rendered HTML of a production build.
3. My page's canonical works but the layout's hreflang disappeared. Why?
Next.js merges metadata from layouts and pages shallowly. When a page returns an alternates object, even one with only a canonical, it replaces the layout's whole alternates object, including languages. The fix is to generate canonical and languages together for each page from one shared helper, rather than setting languages in the layout and canonical in the page. That also guarantees the canonical and the page's own hreflang line use exactly the same address.
4. Is it safe to redirect visitors based on Accept-Language in middleware?
Only with care. Googlebot usually crawls without an Accept-Language header and mostly from the United States, so middleware that redirects based on browser language can send it to the default locale every time, hiding your other versions. Limit automatic redirects to requests with no locale in the path, never redirect a URL that already names a locale, and consider a banner suggesting another language instead. Every locale page should load directly with a 200 status.
5. Which Nuxt i18n settings matter for hreflang?
The two most important are baseUrl, which makes the generated addresses absolute and fixes the host, and a valid language code on each locale, which becomes the hreflang value. Older versions of the module call that field iso and newer ones language. The strategy setting decides the address pattern, and the head composable must be called in app.vue or a layout with SEO output switched on. Option names have changed between major versions, so check the documentation for yours.
6. Should I put hreflang in the Next.js sitemap or in the page head?
Either works, as Google treats them as equivalent, but use only one unless both come from the same helper. Head tags from the metadata API are easy to test by fetching a page. A sitemap from app/sitemap.ts with alternates suits large sites where head blocks get long. Also check whether an i18n library adds hreflang as an HTTP Link header, because that counts as a third source and can quietly disagree with the other two.
7. Can I use the route segment as the hreflang value?
Only if the segment is already a valid language code, optionally with a region. Segments like fr or de-at are fine. Segments like uk for a British store, ie for Ireland, or eu for a European store are not, because uk is the code for Ukrainian, ie is not a language code, and eu is Basque. Keep a separate hreflang value for each locale in your configuration and map from the route segment to it when you build the tags.
The Hreflang Checker follows each cluster of locale pages and validates return tags, language codes, x-default, sitemap consistency, target reachability and canonical alignment, so you can see what your Next.js or Nuxt build actually sends to Google.
Open Hreflang Fixes โ