Hreflang for Next.js and React (with next-intl)
Implement hreflang tags in Next.js App Router using next-intl. Covers metadata generation, sitemap hreflang, and testing your implementation.
Next.js doesn't generate hreflang tags by default. If you're building a multilingual site with the App Router and next-intl, you need to add hreflang annotations yourself. The good news is that Next.js gives you the right hooks to do it cleanly -- through the Metadata API and route-based sitemap generation.
This guide covers the full implementation: setting up next-intl routing, generating hreflang in page metadata, adding hreflang to your sitemap, and testing the result.
Prerequisites
This guide assumes you're using:
- Next.js 14+ with the App Router
- next-intl for internationalized routing
- TypeScript (examples use TypeScript, but the concepts apply to JavaScript too)
If you're still on the Pages Router, the approach is different -- you'll need to inject <link> tags via next/head manually.
Setting Up next-intl Routing
Before generating hreflang, you need next-intl configured with locale-based routing. If you already have this set up, skip to the hreflang section.
Install next-intl:
npm install next-intl
Create your i18n routing configuration:
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'es', 'ja'],
defaultLocale: 'en',
localePrefix: 'always' // URLs always include locale: /en/about, /fr/about
});
Set up the middleware to handle locale detection and routing:
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/', '/(en|fr|de|es|ja)/:path*']
};
Your file structure should look like this:
src/
app/
[locale]/
layout.tsx
page.tsx
about/
page.tsx
blog/
[slug]/
page.tsx
i18n/
routing.ts
middleware.ts
Every page lives under [locale]/, and next-intl handles routing users to the correct locale prefix.
Generating Hreflang Tags in Metadata
Next.js App Router uses the generateMetadata function (or the static metadata export) to set page-level metadata. This is where you add hreflang via the alternates.languages property.
Static Pages
For a page with a known path, generate the hreflang alternates directly:
// src/app/[locale]/about/page.tsx
import { routing } from '@/i18n/routing';
import { getTranslations } from 'next-intl/server';
const BASE_URL = 'https://example.com';
export async function generateMetadata({
params
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'about' });
// Build hreflang alternates for all locales
const languages: Record<string, string> = {};
for (const loc of routing.locales) {
languages[loc] = `${BASE_URL}/${loc}/about`;
}
// Add x-default pointing to the default locale
languages['x-default'] = `${BASE_URL}/${routing.defaultLocale}/about`;
return {
title: t('title'),
alternates: {
languages
}
};
}
This produces the following HTML in the <head>:
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />
<link rel="alternate" hreflang="de" href="https://example.com/de/about" />
<link rel="alternate" hreflang="es" href="https://example.com/es/about" />
<link rel="alternate" hreflang="ja" href="https://example.com/ja/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/about" />
Dynamic Pages (Blog Posts, Products)
For dynamic routes like blog posts, generate hreflang in generateMetadata the same way, but use the slug from the route params:
// src/app/[locale]/blog/[slug]/page.tsx
import { routing } from '@/i18n/routing';
const BASE_URL = 'https://example.com';
export async function generateMetadata({
params
}: {
params: Promise<{ locale: string; slug: string }>
}) {
const { locale, slug } = await params;
// Check which locales have this content available
const availableLocales = await getAvailableLocales(slug);
const languages: Record<string, string> = {};
for (const loc of availableLocales) {
languages[loc] = `${BASE_URL}/${loc}/blog/${slug}`;
}
languages['x-default'] = `${BASE_URL}/${routing.defaultLocale}/blog/${slug}`;
return {
title: `Blog post: ${slug}`,
alternates: {
languages
}
};
}
The getAvailableLocales function should check your CMS or content source to determine which translations exist for a given piece of content. Only include locales where the content actually exists -- never point hreflang at a 404.
Extracting the Helper
Since you'll be building hreflang alternates on every page, extract it into a utility:
// src/lib/hreflang.ts
import { routing } from '@/i18n/routing';
const BASE_URL = 'https://example.com';
export function buildAlternates(
path: string,
availableLocales?: string[]
): Record<string, string> {
const locales = availableLocales ?? routing.locales;
const languages: Record<string, string> = {};
for (const locale of locales) {
languages[locale] = `${BASE_URL}/${locale}${path}`;
}
languages['x-default'] = `${BASE_URL}/${routing.defaultLocale}${path}`;
return languages;
}
Now every page's metadata function is clean:
import { buildAlternates } from '@/lib/hreflang';
export async function generateMetadata() {
return {
alternates: {
languages: buildAlternates('/about')
}
};
}
Hreflang in Your XML Sitemap
HTML hreflang tags work well for pages that get crawled regularly. But for large sites or pages that are crawled less frequently, you should also include hreflang annotations in your XML sitemap.
Next.js App Router supports sitemap generation via a sitemap.ts file. Here's how to include hreflang:
// src/app/sitemap.ts
import { MetadataRoute } from 'next';
import { routing } from '@/i18n/routing';
const BASE_URL = 'https://example.com';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticPages = ['', '/about', '/pricing', '/contact'];
const entries: MetadataRoute.Sitemap = [];
for (const page of staticPages) {
for (const locale of routing.locales) {
const languages: Record<string, string> = {};
for (const altLocale of routing.locales) {
languages[altLocale] = `${BASE_URL}/${altLocale}${page}`;
}
languages['x-default'] = `${BASE_URL}/${routing.defaultLocale}${page}`;
entries.push({
url: `${BASE_URL}/${locale}${page}`,
lastModified: new Date(),
alternates: {
languages
}
});
}
}
// Add dynamic pages (blog posts, products, etc.)
const posts = await getAllBlogPosts();
for (const post of posts) {
const availableLocales = post.locales; // ['en', 'fr', 'de']
for (const locale of availableLocales) {
const languages: Record<string, string> = {};
for (const altLocale of availableLocales) {
languages[altLocale] = `${BASE_URL}/${altLocale}/blog/${post.slug}`;
}
languages['x-default'] = `${BASE_URL}/${routing.defaultLocale}/blog/${post.slug}`;
entries.push({
url: `${BASE_URL}/${locale}/blog/${post.slug}`,
lastModified: post.updatedAt,
alternates: {
languages
}
});
}
}
return entries;
}
This generates a sitemap with <xhtml:link> entries for each locale variant:
<url>
<loc>https://example.com/en/about</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/en/about" />
</url>
For more on sitemap-based hreflang, see the hreflang in XML sitemaps guide and the dynamic sitemaps guide for Next.js.
Handling Language-Region Codes
If you target specific regions (not just languages), your next-intl config and hreflang codes need to match:
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en-us', 'en-gb', 'fr-fr', 'fr-ca', 'de-de'],
defaultLocale: 'en-us',
localePrefix: 'always'
});
This gives you URLs like /en-us/about, /fr-ca/about, and hreflang values of en-us, fr-ca, etc.
When to use language-region codes vs. language-only:
- Language-only (
en,fr): When your content is the same for all speakers of that language regardless of country. Most content sites fall into this category. - Language-region (
en-us,en-gb): When your content differs by region -- different pricing, product availability, legal text, or cultural references.
For more detail on code selection, see the hreflang implementation guide.
Common Pitfalls in Next.js Hreflang
Forgetting Self-Referencing Tags
Every page must include a hreflang tag pointing to itself. If the English page lists French and German alternates but doesn't list itself, Google may ignore the annotations. The buildAlternates helper above handles this automatically because it loops through all locales including the current one.
Trailing Slash Inconsistency
Next.js has a trailingSlash config option. If your site uses trailing slashes, make sure your hreflang URLs include them too. A mismatch between the canonical URL and the hreflang URL (one with trailing slash, one without) can cause Google to treat them as different pages.
// next.config.js
module.exports = {
trailingSlash: true // URLs end with /
};
If trailingSlash is true, your hreflang URLs must also end with /:
languages[locale] = `${BASE_URL}/${locale}/about/`; // trailing slash
Missing x-default
Always include x-default. It tells search engines which URL to show users whose language or region doesn't match any of your specific hreflang values. Typically, this points to your default locale or a language-selection page.
Hardcoding the Base URL
Don't hardcode https://example.com in multiple places. Pull it from an environment variable:
// src/lib/hreflang.ts
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://example.com';
This prevents mismatches between development, staging, and production environments.
Not Generating Hreflang for All Locale Variants
A common mistake is only generating hreflang on the default locale's pages. Every locale variant of every page needs the full set of hreflang tags. If /en/about has hreflang tags but /fr/about doesn't, the annotations are incomplete and Google may ignore them.
The generateMetadata approach handles this naturally because it runs for every locale variant of every page.
Testing Your Implementation
After implementing hreflang, verify it works correctly.
Check the HTML output
Run your Next.js app in development mode and view the page source. Look for <link rel="alternate" hreflang="..."> tags in the <head>. Confirm:
- Every locale has a tag (including the current page's locale)
x-defaultis present- All URLs are absolute (not relative)
- URLs match your actual routing structure
npm run dev
# Then view source at http://localhost:3000/en/about
Check the sitemap
Visit /sitemap.xml and verify each <url> entry includes <xhtml:link> elements for all locale alternates.
Verify return links
Pick a URL from the hreflang tags (say the French version). Visit that page and check its source. It should contain hreflang tags pointing back to all other locales, including the page you started from. Every link must be bidirectional.
Build and test production
Run next build && next start and repeat the checks. Metadata generation can behave differently in production mode, especially with static generation.
Validate with external tools
After deploying, use Google Search Console's International Targeting report to check for hreflang errors. You can also run a crawl with Screaming Frog or a similar tool to extract hreflang data across all pages.
Test with curl for quick checks
You can check hreflang tags without opening a browser:
curl -s https://example.com/en/about | grep 'hreflang'
This outputs all hreflang link tags on the page. Quick way to verify during deployment.
Full Working Example
Here's a minimal but complete example tying everything together:
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'fr', 'de'],
defaultLocale: 'en',
localePrefix: 'always'
});
// src/lib/hreflang.ts
import { routing } from '@/i18n/routing';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://example.com';
export function buildAlternates(
path: string,
availableLocales?: string[]
): Record<string, string> {
const locales = availableLocales ?? [...routing.locales];
const languages: Record<string, string> = {};
for (const locale of locales) {
languages[locale] = `${BASE_URL}/${locale}${path}`;
}
languages['x-default'] = `${BASE_URL}/${routing.defaultLocale}${path}`;
return languages;
}
// src/app/[locale]/page.tsx
import { buildAlternates } from '@/lib/hreflang';
import { getTranslations } from 'next-intl/server';
export async function generateMetadata({
params
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'home' });
return {
title: t('title'),
alternates: {
languages: buildAlternates('')
}
};
}
export default async function HomePage() {
const t = await getTranslations('home');
return <h1>{t('heading')}</h1>;
}
This gives you correct hreflang tags on every page with minimal boilerplate. The buildAlternates function is the only piece you need to call in each page's metadata.
Beyond Hreflang: Other i18n Metadata
While you're setting up multilingual metadata, also consider:
<html lang="...">: next-intl handles this automatically when you set thelangattribute in your root layout based on the locale param.- Canonical URLs: Each locale variant should have its own canonical URL pointing to itself, not to the default locale version.
- Open Graph locale tags: Add
og:localeandog:locale:alternatefor social sharing.
return {
title: t('title'),
alternates: {
canonical: `${BASE_URL}/${locale}/about`,
languages: buildAlternates('/about')
},
openGraph: {
locale: locale,
alternateLocales: routing.locales.filter(l => l !== locale)
}
};
For more on the fundamentals of hreflang and when to use it, see the hreflang guide. And for a gallery of hreflang markup patterns in plain HTML, check the hreflang HTML examples.
References
- next-intl Documentation - Routing
- Next.js Documentation - Metadata API
- Next.js Documentation - Sitemap Generation
- Google Search Central - Localized versions of your page
Validate your Next.js hreflang output
Check that your implementation generates correct hreflang tags across all locales.
Related Articles
Hreflang in Next.js is just metadata generation. Get the routing right, build one helper function, and every page handles itself.
Generate perfect hreflang tags
Create and validate hreflang markup for your multilingual site. Free.
Try Hreflang Generator