hreflang for Developers: Multi-Language SEO in Next.js
How to implement hreflang tags in Next.js for multi-language sites. Alternates API, x-default, regional targeting, and common validation errors.
title: "hreflang in Next.js — Multi-Language SEO Implementation" description: "How to implement hreflang tags in Next.js for multi-language sites. Alternates API, x-default, regional targeting, and common validation errors." tags: "hreflang, i18n, nextjs"
You shipped the French translation of your SaaS marketing site on Friday. Traffic looks great until you check Search Console on Monday morning. Google is serving the French /fr/pricing page to users in Texas. The culprit: missing or malformed hreflang tags. Without them, search engines guess which version to show based on IP or browser language. They often guess wrong. You lose the click, the conversion, and the user.
What is the purpose of hreflang?
The hreflang attribute tells search engines which language and regional URL variant to serve based on the searcher's location and language preferences.
It prevents duplicate content penalties across localized sites and ensures users land on the correct translation. If you have an en-US and an en-GB page, the layout and text are nearly identical. Without explicit instruction, Google treats these as duplicate pages and arbitrarily drops one from the index. hreflang explicitly maps these variants, telling the crawler that they are intended for different audiences.
When correctly implemented, a user searching from London sees the GBP pricing page, while a user in Chicago sees the USD pricing page, both ranking for the exact same English search terms.
How do you implement hreflang in Next.js?
Use the alternates.languages object within the Next.js Metadata API in layout.tsx or page.tsx to automatically inject <link rel="alternate" hreflang="..."> tags into the document head.
Stop manually writing <link> tags in your custom document or injecting them via raw HTML. The App Router handles this natively. You pass a dictionary of language codes and their corresponding absolute URLs. Next.js automatically formats the output and handles the self-referencing requirement.
Here is the implementation for a static layout:
// app/[lang]/layout.tsx
import { Metadata } from 'next';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://indxel.com';
export function generateMetadata({ params }): Metadata {
const { lang } = params;
return {
title: 'Indxel - SEO Infrastructure',
description: 'Developer-first SEO infrastructure tool.',
alternates: {
canonical: `${siteUrl}/${lang}`,
languages: {
'en-US': `${siteUrl}/en-US`,
'en-GB': `${siteUrl}/en-GB`,
'fr-FR': `${siteUrl}/fr-FR`,
'de-DE': `${siteUrl}/de-DE`,
},
},
};
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}This generates the following HTML for every page utilizing this layout:
<link rel="canonical" href="https://indxel.com/en-US" />
<link rel="alternate" hreflang="en-US" href="https://indxel.com/en-US" />
<link rel="alternate" hreflang="en-GB" href="https://indxel.com/en-GB" />
<link rel="alternate" hreflang="fr-FR" href="https://indxel.com/fr-FR" />
<link rel="alternate" hreflang="de-DE" href="https://indxel.com/de-DE" />How do you generate hreflang for dynamic Next.js routes?
Await your translation dictionary or database query inside generateMetadata to map localized slugs to their respective language variants.
Static layouts are trivial. Dynamic routes require mapping. A product might be /en/shoes but /fr/chaussures. Hardcoding the layout hreflang fails here because it appends the current slug to all language prefixes, resulting in 404s like /fr/shoes. You must map the exact translated slug for each alternate.
// app/[lang]/products/[slug]/page.tsx
import { Metadata } from 'next';
import { getProductTranslations } from '@/lib/db';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
interface Props {
params: { lang: string; slug: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
// Returns an object: { en: 'shoes', fr: 'chaussures', de: 'schuhe' }
const translations = await getProductTranslations(params.slug);
return {
title: `Product - ${params.slug}`,
alternates: {
canonical: `${siteUrl}/${params.lang}/products/${params.slug}`,
languages: {
'en-US': `${siteUrl}/en/products/${translations.en}`,
'fr-FR': `${siteUrl}/fr/products/${translations.fr}`,
'de-DE': `${siteUrl}/de/products/${translations.de}`,
},
},
};
}If a translation does not exist for a specific region, omit it from the languages object. Google only requires reciprocity between the tags that are present.
What is the role of x-default in hreflang?
The x-default attribute specifies the fallback page for users whose language or region does not match any of your defined hreflang variants.
If a user in Japan searches for your site, and you only define English and Spanish pages, Google needs a default directive. x-default tells the crawler to serve a specific variant (usually the primary English site or a geographic selector page) instead of randomly choosing based on crawl history.
x-default. Without it, unsupported regions might get served an arbitrary language variant, tanking your conversion rates.To implement this in Next.js, add the x-default key to your languages object:
alternates: {
languages: {
'en-US': 'https://indxel.com/en-US',
'fr-FR': 'https://indxel.com/fr-FR',
'x-default': 'https://indxel.com/en-US', // Fallback
},
}How should you structure multi-language URLs?
Subpath routing (example.com/fr/) is the optimal approach for Next.js applications because it shares domain authority and routes seamlessly through Next.js middleware.
Do not use subdomains (fr.example.com) or top-level domains (example.fr) for standard SaaS or content applications. Subdomains split your domain authority, forcing you to build backlinks for each language independently. TLDs require managing multiple DNS zones, SSL certificates, and complex cross-domain authentication state.
| Routing Strategy | Format | Domain Authority | Next.js Integration | Verdict |
|---|---|---|---|---|
| Subpath | example.com/fr/ | Shared | Native (Middleware) | Optimal |
| Subdomain | fr.example.com | Split | Requires Custom Server | Reject |
| TLD | example.fr | Split | Requires DNS/SSL overhead | Reject |
| Parameter | example.com?lang=fr | Shared | Poor SEO indexing | Reject |
Subpaths allow you to use a single Next.js App Router codebase, a single deployment on Vercel or AWS, and a unified authentication cookie.
How do you handle hreflang in Next.js Middleware?
Bypass middleware redirects for search engine bots to ensure they can crawl your localized paths and read the hreflang tags.
Developers often write Next.js middleware that reads the Accept-Language header and forces a 307 redirect to the correct subpath. This breaks SEO. Googlebot crawls primarily from US IP addresses and frequently omits the Accept-Language header. If your middleware forces all US traffic to /en-US/, Googlebot will never reach /fr-FR/. The crawler hits the French URL, gets a 307 redirect to the English URL, and assumes the French page does not exist.
Your middleware must allow direct access to localized paths. Only redirect users if they hit the root / path.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const locales = ['en-US', 'fr-FR', 'de-DE'];
const defaultLocale = 'en-US';
function getLocale(request: NextRequest) {
const headers = new Headers(request.headers);
const acceptLanguage = headers.get('accept-language');
if (!acceptLanguage) return defaultLocale;
const languages = new Negotiator({ headers: { 'accept-language': acceptLanguage } }).languages();
return match(languages, locales, defaultLocale);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Check if the path already has a locale
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
// 2. If it has a locale, bypass redirect. Let bots crawl it.
if (pathnameHasLocale) return NextResponse.next();
// 3. Only redirect if hitting root or un-localized path
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: [
// Skip all internal paths (_next, images, api)
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};What are the most common hreflang errors?
The three critical hreflang errors are non-reciprocal tags, invalid ISO language/region codes, and conflicting canonical URLs.
- Non-reciprocal tags (Return Tag Error). If Page A points to Page B, Page B must point back to Page A. If
/en/lists/fr/as an alternate, but/fr/does not list/en/, Google invalidates the entire relationship. This happens frequently when developers hardcode hreflang on the English layout but forget to include it in the localized CMS data. - Invalid ISO codes. The format is strict.
en-UKis invalid. The ISO 3166-1 alpha-2 code for the United Kingdom isGB. The correct tag isen-GB. Usingen-UKcauses Google to ignore the directive entirely. - Canonical conflicts. If
/fr/pricingcanonicalizes to/en/pricing, but/en/pricinglists/fr/pricingas an alternate, you create an infinite loop. Hreflang tags must only point to indexable, self-referencing canonical URLs.
[language]-[region]. Language uses ISO 639-1 (lowercase). Region uses ISO 3166-1 Alpha 2 (uppercase). en-US is valid. US-en is invalid.How do you catch hreflang errors in CI/CD?
Use the Indxel CLI in your GitHub Actions pipeline to validate reciprocity, ISO codes, and canonical alignment before merging to main.
Manual SEO QA fails at scale. A typical Next.js app with 50 localized pages in 5 languages generates 1,250 hreflang relationships. Indxel validates these in 3 seconds. That's 3 seconds in CI that saves hours of manual Search Console debugging and prevents traffic drops.
Indxel runs locally or in CI, parsing your built HTML and verifying the exact HTTP outputs.
# Run against a local build
npx indxel check --url http://localhost:3000 --diffThe CLI outputs warnings in the same format as ESLint — one line per issue, with the exact file path, rule ID, and failure reason.
✖ 3 critical errors found
/fr/pricing
12:4 error hreflang-reciprocity URL /en/pricing missing return tag for /fr/pricing
13:4 error hreflang-invalid-iso 'en-UK' is not a valid ISO 3166-1 code. Did you mean 'en-GB'?
/de/pricing
15:4 error hreflang-canonical Tag points to /de/pricing, but page canonicalizes to /en/pricingTo block bad code from reaching production, add Indxel to your GitHub Actions workflow. Run it after your Next.js build step.
# .github/workflows/seo-check.yml
name: SEO Infrastructure Validation
on: [pull_request]
jobs:
validate-seo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build Next.js app
run: npm run build
- name: Start server in background
run: npm run start &
- name: Run Indxel validation
run: npx indxel check --ci --url http://localhost:3000When a developer introduces a non-reciprocal tag or breaks a canonical mapping, the CI pipeline fails. You catch the SEO regression in the pull request, not in Google Search Console two weeks later.
Frequently Asked Questions
Can I use underscores instead of hyphens in hreflang codes?
No. You must use hyphens (en-US). Google's XML and HTML parsers explicitly reject underscores (en_US) and will ignore the tag entirely. This is a common bug when passing locale strings directly from backend systems like Java or PHP that use underscores for locales. Map them to hyphens before rendering.
Do I need to self-reference the current page in hreflang?
Yes. Every page must list all alternate versions, including itself. If the /fr/ page lists /en/ and /de/, it must also list /fr/. The Next.js alternates.languages API handles this automatically if you pass the full dictionary of languages to every localized layout.
Should I use hreflang on canonicalized pages?
No. Only add hreflang tags to indexable, self-referencing canonical pages. Adding them to paginated URLs (?page=2), parameterized duplicates (?sort=price), or pages that canonicalize to a different URL confuses search engine crawlers. If a page canonicalizes elsewhere, it should not participate in the hreflang cluster.
How long does Google take to process hreflang changes?
Google processes hreflang tags as it recrawls each individual URL in the cluster. Because reciprocity is required, Google must crawl Page A, then crawl Page B, and verify the relationship. For a large site, establishing a full multi-language cluster can take weeks. This makes upfront CI validation critical.
Guard your multi-language routing
Ship your localized routes with confidence. Add Indxel to your Next.js project to validate your alternates API implementation locally.
npx indxel init
npx indxel check