TL;DR: hreflang tags tell search engines which language version of a page to show in search results. For single-page applications (SPAs), proper hreflang implementation requires dynamic tag injection via the
<head>, consistent canonical URL patterns, and language-specific sitemaps. This guide covers everything from basic tag syntax to advanced patterns for SPAs built with TanStack Router or React Router, including automatic locale detection, fallback hierarchies, and Google Search Console verification.
What Are hreflang Tags and Why They Matter
hreflang is an HTML attribute that tells search engines the relationship between pages in different languages. When a German user searches for "SaaS pricing," Google uses hreflang to show the German version of your page instead of the English one — even if the English version has higher domain authority.
Implementing hreflang tags correctly is a crucial component of any technical globalization strategy. Without them, your multilingual content can suffer from duplicate content penalties, wrong-language rankings, and wasted crawl budget — all of which undermine your international SEO efforts.
Without hreflang, multilingual sites suffer from three problems:
| Problem | SEO Impact | Fix |
|---|---|---|
| Duplicate content | Google sees /en/pricing and /de/pricing as the same page, diluting ranking signals | hreflang tells Google these are translations, not duplicates |
| Wrong language ranking | English page ranks for German keyword, causing high bounce rate | hreflang directs users to their language |
| Crawl budget waste | Googlebot crawls every language variant of every page | Proper annotations consolidate crawl signals |
A 2025 study by Aleyda Solis found that 63% of multilingual websites still have incorrect or missing hreflang implementation — making this a quick SEO win for sites that get it right.
hreflang Tag Syntax and Patterns
Basic Syntax
<link rel="alternate" hreflang="en" href="https://tanstackship.com/en/page" />
<link rel="alternate" hreflang="de" href="https://tanstackship.com/de/page" />
<link rel="alternate" hreflang="zh" href="https://tanstackship.com/zh/page" />
<link rel="alternate" hreflang="x-default" href="https://tanstackship.com/en/page" />
Two Implementation Methods
| Method | Where | Pros | Cons |
|---|---|---|---|
HTML <link> tags | In the <head> of each page | Simple, works everywhere | Requires per-page dynamic injection |
| XML sitemaps | In sitemap.xml with xhtml:link | Centralized management, no HTML changes | Google may take longer to discover |
Best practice: Use both. HTML tags for fast discovery, sitemaps for completeness.
Implementing hreflang in SPAs: The Challenge
SPAs render JavaScript on the client, meaning static HTML files cannot contain dynamic hreflang tags. There are three approaches:
Approach 1: Server-Side Injection (Recommended for TanStack Start)
With TanStack Start's SSR, you can inject hreflang tags on the server before HTML reaches the client. This approach ensures search engine crawlers receive complete hreflang markup in the initial response, avoiding the SEO risks associated with client-side hydration. For a comprehensive walkthrough of building a full-stack application with SSR, see the TanStack Start tutorial.
The following code demonstrates the hreflang generation utility and its integration into the root route:
// src/lib/hreflang.ts
interface HreflangConfig {
baseUrl: string
currentPath: string
locales: string[]
defaultLocale: string
}
export function generateHreflangLinks(config: HreflangConfig): string[] {
const { baseUrl, currentPath, locales, defaultLocale } = config
// Normalize path: strip existing locale prefix if present
const pathWithoutLocale = currentPath.replace(/^\/(en|de|zh)/, "")
return [
// One link per locale
...locales.map(
(locale) =>
`<link rel="alternate" hreflang="${locale}" href="${baseUrl}/${locale}${pathWithoutLocale}" />`
),
// x-default fallback
`<link rel="alternate" hreflang="x-default" href="${baseUrl}/${defaultLocale}${pathWithoutLocale}" />`,
]
}
// src/routes/__root.tsx — inject hreflang via SSR
import { Head } from "@tanstack/react-start"
import { generateHreflangLinks } from "../lib/hreflang"
export const Route = createRootRoute({
component: () => {
const { locale } = Route.useRouteContext()
// Strip leading slash for sitemap path
const path = location.pathname
const hreflangLinks = generateHreflangLinks({
baseUrl: "https://tanstackship.com",
currentPath: path,
locales: ["en", "de", "zh"],
defaultLocale: "en",
})
return (
<html lang={locale}>
<head>
<Head />
<meta property="og:locale" content={locale === "zh" ? "zh_CN" : locale === "de" ? "de_DE" : "en_US"} />
{hreflangLinks.map((link) => (
<link
key={link.match(/hreflang="([^"]+)"/)?.[1] || Math.random()}
rel="alternate"
hrefLang={link.match(/hreflang="([^"]+)"/)?.[1]}
href={link.match(/href="([^"]+)"/)?.[1]}
/>
))}
</head>
<body>
<Outlet />
</body>
</html>
)
},
})
The generateHreflangLinks function normalizes the current path by stripping any existing locale prefix, then generates one <link> tag per supported locale plus an x-default fallback. The root route component renders these tags as JSX in <head>, guaranteeing every server-delivered page includes complete hreflang annotations.
Approach 2: Static Site Generation (Pre-rendered per Locale)
For statically generated SPAs, generate one HTML file per locale per page:
// scripts/generate-hreflang.ts
import fs from "fs/promises"
import path from "path"
interface PageConfig {
path: string
locales: string[]
}
async function injectHreflang(page: PageConfig) {
const { path: pagePath, locales } = page
const baseUrl = "https://tanstackship.com"
const hreflangTags = locales
.map(
(locale) =>
`<link rel="alternate" hreflang="${locale}" href="${baseUrl}/${locale}${pagePath}" />`
)
.join("
")
// Inject into each locale's HTML file
for (const locale of locales) {
const filePath = path.join("dist", locale, pagePath, "index.html")
const html = await fs.readFile(filePath, "utf-8")
const updatedHtml = html.replace(
"</head>",
`${hreflangTags}
<link rel="alternate" hreflang="x-default" href="${baseUrl}/${locale}${pagePath}" />
</head>`
)
await fs.writeFile(filePath, updatedHtml)
}
}
Approach 3: Client-Side Dynamic Injection (Least Recommended)
As a last resort for client-only SPAs, inject tags after initial render:
// src/hooks/useHreflang.ts
import { useEffect } from "react"
import { useLocation } from "@tanstack/react-router"
export function useHreflang(locales: string[]) {
const location = useLocation()
const baseUrl = "https://tanstackship.com"
useEffect(() => {
const path = location.pathname.replace(/^\/(en|de|zh)/, "")
// Remove existing hreflang tags
document
.querySelectorAll('link[rel="alternate"][hreflang]')
.forEach((el) => el.remove())
// Inject new ones
locales.forEach((locale) => {
const link = document.createElement("link")
link.rel = "alternate"
link.setAttribute("hreflang", locale)
link.href = `${baseUrl}/${locale}${path}`
document.head.appendChild(link)
})
// x-default
const defaultLink = document.createElement("link")
defaultLink.rel = "alternate"
defaultLink.setAttribute("hreflang", "x-default")
defaultLink.href = `${baseUrl}/${locales[0]}${path}`
document.head.appendChild(defaultLink)
}, [location.pathname, locales])
}
Warning: Client-side injection is slow for SEO — Googlebot may not wait for JavaScript to execute before reading the
<head>. Use server-side injection whenever possible.
Sitemap-Based hreflang (The Complete Way)
XML sitemaps with xhtml:link annotations provide a fallback for pages Googlebot might miss:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://tanstackship.com/en/pricing</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://tanstackship.com/en/pricing"/>
<xhtml:link rel="alternate" hreflang="de" href="https://tanstackship.com/de/preise"/>
<xhtml:link rel="alternate" hreflang="zh" href="https://tanstackship.com/zh/pricing"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://tanstackship.com/en/pricing"/>
<lastmod>2026-06-09</lastmod>
</url>
<url>
<loc>https://tanstackship.com/de/preise</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://tanstackship.com/en/pricing"/>
<xhtml:link rel="alternate" hreflang="de" href="https://tanstackship.com/de/preise"/>
<xhtml:link rel="alternate" hreflang="zh" href="https://tanstackship.com/zh/pricing"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://tanstackship.com/en/pricing"/>
<lastmod>2026-06-09</lastmod>
</url>
</urlset>
Generating Multi-Locale Sitemaps with TanStack Start
// src/server/sitemap.ts
import { createServerFn } from "@tanstack/react-start"
interface SitemapUrl {
path: string
updatedAt: string
locale: string
}
export const generateSitemap = createServerFn({ method: "GET" }).handler(
async () => {
const baseUrl = "https://tanstackship.com"
const locales = ["en", "de", "zh"]
// Get all published blog posts with their translations
const posts = await env.DB.prepare(
`SELECT b.slug, b.updated_at, b.locale
FROM blog_posts b
WHERE b.published = 1`
).all()
// Build URL groups (same content, different languages)
const urlGroups = buildUrlGroups(posts.results as SitemapUrl[], baseUrl)
return generateSitemapXml(urlGroups, locales, baseUrl)
}
)
Common hreflang Mistakes (and How to Fix Them)
Mistake 1: Missing Return Tags
If page A links to page B via hreflang, page B must link back to page A. Google calls this "bidirectional annotation."
<!-- ❌ WRONG: Page A links to B, but B does not link back -->
<!-- Page A: -->
<link rel="alternate" hreflang="de" href="/de/page" />
<!-- Page B: -->
<!-- No hreflang tags at all! -->
<!-- ✅ CORRECT: Bidirectional links -->
<!-- Page A: -->
<link rel="alternate" hreflang="en" href="/en/page" />
<link rel="alternate" hreflang="de" href="/de/page" />
<!-- Page B: -->
<link rel="alternate" hreflang="en" href="/en/page" />
<link rel="alternate" hreflang="de" href="/de/page" />
Mistake 2: Inconsistent Language Codes
<!-- ❌ WRONG: Mixed ISO formats -->
<link rel="alternate" hreflang="english" href="/en/page" />
<link rel="alternate" hreflang="de-de" href="/de/page" />
<!-- ✅ CORRECT: ISO 639-1 language codes with optional ISO 3166-1 region -->
<link rel="alternate" hreflang="en" href="/en/page" />
<link rel="alternate" hreflang="en-US" href="/en/page" />
<link rel="alternate" hreflang="de" href="/de/page" />
<link rel="alternate" hreflang="de-DE" href="/de/page" />
<!-- De-duplicate: If two hreflang values point to the same URL, use the more specific one -->
Mistake 3: Conflicting Canonical URLs
<!-- ❌ WRONG: Canonical points to different locale than hreflang -->
<link rel="canonical" href="/en/page" />
<link rel="alternate" hreflang="de" href="/de/page" />
<!-- ✅ CORRECT: Self-referencing canonical -->
<link rel="canonical" href="/de/page" />
<link rel="alternate" hreflang="en" href="/en/page" />
<link rel="alternate" hreflang="de" href="/de/page" />
Mistake 4: Using hreflang with Fragment Identifiers
<!-- ❌ WRONG: Google ignores fragments -->
<link rel="alternate" hreflang="de" href="/page#section2" />
<!-- ✅ CORRECT: Use full URL without fragment -->
<link rel="alternate" hreflang="de" href="/de/page" />
The x-default hreflang: When to Use It
x-default tells Google which page to show when no language matches the user's preferences. This is critical for SPAs because browser language detection may not align with your supported locales.
<!-- x-default should point to your most common entry point -->
<link rel="alternate" hreflang="x-default" href="https://tanstackship.com/en" />
<!-- For a global SaaS product with English as default -->
<link rel="alternate" hreflang="x-default" href="https://tanstackship.com/en" />
<!-- For a regional product, point to the most likely market -->
<link rel="alternate" hreflang="x-default" href="https://tanstackship.com/de" />
Use x-default when:
- The user's browser language does not match any of your supported languages
- The user is a search engine crawler (Googlebot typically sets
enoren-us) - You want a language selection landing page (less common in 2026, users prefer direct entry)
Testing and Validation
Google Search Console
- Go to International Targeting > Language in Search Console
- Review the report for "no return tags" and "inconclusive hreflang" errors
- Use the URL Inspection Tool to verify hreflang on specific pages
Automated Testing Script
#!/bin/bash
# hreflang-test.sh — Validate hreflang tags on a set of URLs
BASE_URL="https://tanstackship.com"
LOCALES=("en" "de" "zh")
PAGES=("/pricing" "/features" "/blog/react-internationalization-guide-2026")
for page in "${PAGES[@]}"; do
for locale in "${LOCALES[@]}"; do
url="${BASE_URL}/${locale}${page}"
echo "Testing: $url"
# Fetch and extract hreflang tags
tags=$(curl -s "$url" | grep -o 'rel="alternate"[^>]*hreflang="[^"]*"[^>]*' | wc -l)
echo " Found $tags hreflang tags"
# Check for return links
has_return=$(curl -s "$url" | grep -c "hreflang=\"${locale}\"")
if [ "$has_return" -eq 0 ]; then
echo " ⚠️ Missing self-referencing hreflang for ${locale}"
fi
done
done
Online Validators
- Aleyda Solis' hreflang Tags Generator Tool
- Merkle's hreflang Testing Tool
- DeepCrawl's International SEO module
hreflang for Dynamic Content (Blog Posts, CMS Pages)
For content-driven SPAs with blog posts in multiple languages, hreflang tags must be generated dynamically based on database relationships between translated content. If you are implementing a multilingual content system, our React internationalization guide covers the broader i18n architecture for SPAs that works alongside these hreflang patterns.
The following utility fetches all translations for a given blog post and generates the corresponding hreflang link tags:
// src/lib/blog-hreflang.ts
interface BlogPostTranslation {
locale: string
slug: string
}
export async function getBlogPostHreflang(
postId: string,
currentLocale: string
): Promise<string[]> {
const translations = await env.DB.prepare(
`SELECT locale, slug FROM blog_posts
WHERE translation_group_id = (
SELECT translation_group_id FROM blog_posts WHERE id = ?
) AND published = 1`
)
.bind(postId)
.all()
return (translations.results as BlogPostTranslation[]).map(
(t) =>
`<link rel="alternate" hreflang="${t.locale}" href="https://tanstackship.com/${t.locale}/blog/${t.slug}" />`
)
}
-- Database schema for translated blog posts
CREATE TABLE blog_posts (
id TEXT PRIMARY KEY,
translation_group_id TEXT NOT NULL,
locale TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
published INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(locale, slug)
);
CREATE INDEX idx_blog_posts_translation_group
ON blog_posts(translation_group_id);
The database schema stores each blog post with a translation_group_id that groups all language variants of the same content. The getBlogPostHreflang function queries this group to generate complete hreflang annotations, ensuring every blog post correctly links to its translated counterparts regardless of which language the user is viewing.
Performance Impact: Does hreflang Affect Page Speed?
hreflang tags are <link> elements in the <head> — they do not block rendering or add measurable page weight. For a typical SaaS site with 3-5 locales, the total hreflang markup is under 1 KB. The real performance consideration is server-side injection: generating hreflang tags at request time adds negligible overhead (< 1 ms) compared to the benefits of correct international SEO.
Checklist: hreflang for SPA Production Readiness
- Every page has self-referencing hreflang (links to its own locale)
- Every page has cross-referencing hreflang (links to all other locales)
- Return tags are verified bidirectionally (page A ↔ page B for all pairs)
- x-default hreflang is set on all pages
- Canonical URLs are self-referencing per locale
- Language codes use valid ISO 639-1 format
- Sitemaps include
xhtml:linkannotations for all locale variants - Google Search Console shows no hreflang errors
- Dynamic pages (blog posts) generate hreflang from database translation groups
- Locale switcher updates hreflang tags without full page reload
- No conflicting hreflang declarations in robots.txt or HTTP headers
- Tested with a real multilingual user flow in all supported languages
Conclusion
hreflang implementation for SPAs requires more attention than for traditional multi-page sites, because dynamic client-side rendering can delay or prevent tag discovery. The key principles are:
- Inject hreflang server-side whenever possible — TanStack Start's SSR makes this straightforward
- Always include return links — bidirectional annotations are a hard requirement, not a suggestion
- Use both HTML tags and sitemaps — sitemaps serve as a fallback for pages Googlebot does not fully render
- Keep canonical URLs self-referencing per locale to avoid conflicting signals
- Automate testing — manual hreflang verification does not scale beyond a handful of pages
Multilingual SEO is a long-term investment. Getting hreflang right ensures your content competes in every language market you target, without the drag of duplicate content penalties or wrong-language rankings.