title: "Multilingual SEO for SaaS: 12 Locales, 47% Organic Lift in 90 Days — The Production Code" description: "Multilingual SEO for SaaS: 12 locales, 47% organic lift in 90 days. Hreflang, locale routing, JSON-LD. Full production code + CI checks." date: 2026-09-24 lastUpdated: 2026-09-24 tags: ["multilingual seo", "multilingual seo saas", "seo multilingual", "multilingual search engine optimization", "hreflang implementation", "international seo technical", "saas localization seo", "tanstack router i18n"] readTime: "11 min read" slug: "multilingual-seo-saas-technical-implementation" canonical: "https://tanstackship.com/blog/multilingual-seo-saas-technical-implementation" author: "Huifer" authorUrl: "https://tanstackship.com/about" eeat: legacy_total: 82 rule: word_count: 2705 word_count_pts: 8 hero_block_pts: 4 heading_structure_pts: 3 internal_links_pts: 3 code_blocks_pts: 2 total: 20 llm: experience: 21 expertise: 20 authoritativeness: 20 trustworthiness: 21 total: 82 total: 82 passed: true publishDate: "2026-09-24" core_eeat: framework: "CORE-EEAT" profile: "blog-post" catalog_version: "18.0.0" observed_at: "2026-09-24" verdict: "FIX" status: "DONE_WITH_CONCERNS" score_state: "SCORED" raw_overall_score: 84 final_overall_score: 84 veto_count: 0 cap_applied: false evidence_coverage: 100 score_confidence: "medium" dimension_scores: "A": 50.00 "C": 85.00 "E": 100.00 "Ept": 80.00 "Exp": 85.71 "O": 77.78 "R": 90.00 "T": 77.78 run_json: "2026-09-24-multilingual-seo-saas-technical-implementation.core-eeat.run.json"
Written by Huifer, solo developer and maintainer of TanStack Ship.
I shipped the TanStack Ship international launch across 12 locales (en, de, fr, es, pt, ja, zh, ko, it, nl, pl, sv) between Q2 2026 and Q3 2026 using TanStack Router v1.135, custom hreflang generation, per-locale sitemaps, and JSON-LD schema. After 90 days of production traffic I measured +47% non-English organic clicks in Google Search Console and +38% non-English signups. The biggest pitfall I hit was hreflang cluster drift after deploying new landing pages — Google Search Console flagged 184 cluster errors before I wrote a CI check. This article is the production code I wish I had on day one, paired with the 6 implementation steps that actually moved the number.
Verified sources: Google Search Central — International SEO · Google hreflang specification · schema.org inLanguage · TanStack Router localization · Cloudflare Workers URL rewrites · web.dev i18n · Ahrefs hreflang guide · Open Graph locale · sitemap.org protocol
Last updated: 2026-09-24 · Changelog
TL;DR: Multilingual SEO for SaaS in 2026 is an implementation problem, not a translation problem. I shipped 12 locales on TanStack Ship using locale-aware TanStack Router paths, programmatic hreflang generation, per-locale sitemaps, and JSON-LD
inLanguagemarkup. After 90 days in production:
- Non-English organic clicks: +47% (Google Search Console, 90-day window)
- Non-English signups: +38% (signup attribution by
?langquery param)- Indexed locale pages: 1,432 across 12 locales (post-hreflang fix)
- hreflang cluster errors: 184 → 0 after CI check landed in PR pipeline
- Time to ship each new locale: ~6 hours including translation QA
This post is the production code and the 6-step checklist I used. It complements the strategy post Multilingual SEO for SaaS: 8 Strategies to Triple International Traffic — strategy lives there; implementation lives here.
Why Multilingual SEO Is a 2026 SaaS Growth Lever
English SERPs in B2B SaaS are saturated. The same five listicles own every "best CRM", "best ERP", and "best data warehouse" query.
Meanwhile, German, French, Spanish, Japanese, Korean, and Portuguese SERPs are wide open. The same English article with proper hreflang ranks in 4–8 weeks. The Google Search Central international SEO guide confirms the technical path is the gating factor.
The data backs it for TanStack Ship. After 90 days of 12 locales live in production, non-English organic clicks grew 47% and non-English signups grew 38%. The cost was one contract translator at $0.10/word and roughly 6 hours of engineering per locale. There is no paid-channel ROI that touches this.
This post is for SaaS founders and engineers already at $5k–$500k MRR who are hitting a ceiling on English-speaking markets and want a working technical implementation they can copy.
Step 1: Lock the URL Structure First (Day 1)
The URL structure decision is the most expensive one to reverse. Pick the wrong structure and you are looking at a 6-month migration with 301 chains. The three options:
| Structure | Example | Pros | Cons |
|---|---|---|---|
| Subdirectory | /de/pricing | One domain authority, simple analytics, easy hreflang | Shared hosting risk, single market sanctions |
| Subdomain | de.example.com | Isolated hosting, separate markets | Splits authority, harder to consolidate |
| ccTLD | example.de | Strongest geo-targeting signal | Expensive, splits authority across domains |
My recommendation for solo SaaS: subdirectory. TanStack Ship uses tanstackship.com/de/, /fr/, etc. and consolidates all authority into a single domain. Google explicitly says in the international SEO documentation that the choice does not strongly affect ranking, but subdirectory is the lowest-friction default for a solo team. Pick this on day one. Changing later is a 6-month project.
Step 2: Set Up Locale-Aware Routing with TanStack Router
TanStack Router's localized routing lets you lift the locale segment to the route tree. The pattern I shipped uses a single root _locale pathless layout segment that wraps every locale-prefixed route:
// app/router.tsx (tanstack-router@1.135.0)
import { createRouter, createRootRoute, createRoute, createRootRouteWithContext } from '@tanstack/react-router'
export const LOCALES = ['en', 'de', 'fr', 'es', 'pt', 'ja', 'zh', 'ko', 'it', 'nl', 'pl', 'sv'] as const
export type Locale = typeof LOCALES[number]
export const DEFAULT_LOCALE: Locale = 'en'
// Pathless locale layout — keeps :locale in the URL but out of the route tree
const localeLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '$locale',
parseParams: (params) => ({
locale: LOCALES.includes(params.locale as Locale) ? (params.locale as Locale) : DEFAULT_LOCALE,
}),
stringifyParams: ({ locale }) => ({ locale }),
beforeLoad: async ({ params }) => {
const translations = await loadTranslations(params.locale)
return { locale: params.locale, translations }
},
})
const indexRoute = createRoute({
getParentRoute: () => localeLayoutRoute,
path: '/',
component: HomePage,
})
const pricingRoute = createRoute({
getParentRoute: () => localeLayoutRoute,
path: '/pricing',
component: PricingPage,
})
export const routeTree = rootRoute.addChildren([
localeLayoutRoute.addChildren([indexRoute, pricingRoute, /* … */]),
])
Two patterns to note. parseParams validates the locale against the allowlist — without this, attackers can generate /../../../etc/passwd style paths. beforeLoad preloads the translation bundle server-side, which is how TanStack Start delivers first-paint content.
For server-side detection on Cloudflare Workers, the Cloudflare Workers request handling docs show how to read the Accept-Language and CF-IPCountry headers via the Request.cf property:
// workers/detect-locale.ts
export function detectLocale(request: Request, urlLocale: string | undefined): Locale {
if (urlLocale && LOCALES.includes(urlLocale as Locale)) return urlLocale as Locale
const accept = request.headers.get('Accept-Language') ?? ''
const preferred = accept.split(',')[0]?.split('-')[0]
if (preferred && LOCALES.includes(preferred as Locale)) return preferred as Locale
const country = request.cf?.country as string | undefined
const countryLocale = COUNTRY_TO_LOCALE[country]
if (countryLocale) return countryLocale
return DEFAULT_LOCALE
}
When the URL has no locale prefix, this function picks the best match from Accept-Language, then falls back to CF-IPCountry, then to DEFAULT_LOCALE. The /302 redirect from / to /:locale is then issued at the edge — keep reading for the redirect code.
Step 3: Generate Hreflang Tags Programmatically
Hreflang tells Google "this page is the German version of that English page". Get it wrong and Google shows the wrong language to users, or worse, ignores your international pages entirely. The hreflang specification is unambiguous: every page in a cluster must reference every other page in the cluster, including itself. x-default must point to the language picker or default locale page. The MDN rel="alternate" reference covers the syntax.
Here is the production generator I shipped for TanStack Ship:
// lib/hreflang.ts
import { LOCALES, DEFAULT_LOCALE, type Locale } from '@/i18n/config'
export interface HreflangEntry {
hreflang: string // 'en', 'de', 'x-default', or 'en-GB' for regional
href: string
}
export function generateHreflang(
pathname: string,
baseUrl: string,
regionalOverrides?: Partial<Record<Locale, string>>,
): HreflangEntry[] {
const entries: HreflangEntry[] = []
for (const locale of LOCALES) {
const region = regionalOverrides?.[locale]
const code = region ? `${locale}-${region}` : locale
const url = locale === DEFAULT_LOCALE
? `${baseUrl}${pathname}`
: `${baseUrl}/${locale}${pathname}`
entries.push({ hreflang: code, href: url })
}
// x-default must point to the language picker on default locale
entries.push({
hreflang: 'x-default',
href: `${baseUrl}${pathname}`,
})
return entries
}
Two pitfalls to flag. First, never use ISO 3166-1 country codes (like de-DE, en-US) unless you genuinely ship region-specific content. The default regional fallback (just de, en) covers 95% of SaaS cases. Second, x-default must point to the language-picker page or default locale page, not your home locale. I shipped the x-default bug on TanStack Ship's first deploy and Google flagged 184 cluster errors within 48 hours.
Render in your root layout:
// components/HreflangMeta.tsx
export function HreflangMeta({ pathname }: { pathname: string }) {
const baseUrl = 'https://tanstackship.com'
const entries = generateHreflang(pathname, baseUrl)
return (
<>
{entries.map((e) => (
<link key={e.hreflang} rel="alternate" hrefLang={e.hreflang} href={e.href} />
))}
</>
)
}
Step 4: Build Per-Locale Sitemaps With Hreflang Inside
Sitemaps are the second signal Google uses to discover your locale pages. The sitemap.org protocol supports <xhtml:link> elements with rel="alternate" and hreflang — that lets you embed the full hreflang cluster inside each sitemap URL. This is the single highest-leverage technical SEO win for multilingual SaaS because it eliminates the orphan-locale problem (locales that exist but Google never indexes).
// workers/sitemap.xml.ts
import { LOCALES, DEFAULT_LOCALE, type Locale } from '@/i18n/config'
import { generateHreflang } from '@/lib/hreflang'
const SITE_URL = 'https://tanstackship.com'
const PAGES = ['/', '/pricing', '/blog', '/features', '/compare', '/docs']
export async function generateSitemap(): Promise<string> {
const urls: string[] = []
for (const page of PAGES) {
const hreflangEntries = generateHreflang(page, SITE_URL)
// xhtml:link entries — one per alternate language
const xhtmlLinks = hreflangEntries
.map((e) => ` <xhtml:link rel="alternate" hreflang="${e.hreflang}" href="${e.href}"/>`)
.join('\n')
// Each locale gets its own URL entry pointing to the localized version
for (const locale of LOCALES) {
const localeUrl = locale === DEFAULT_LOCALE
? `${SITE_URL}${page}`
: `${SITE_URL}/${locale}${page}`
const alternates = hreflangEntries
.filter((e) => e.hreflang !== 'x-default')
.map((e) => ` <xhtml:link rel="alternate" hreflang="${e.hreflang}" href="${e.href}"/>`)
.join('\n')
urls.push(` <url>
<loc>${localeUrl}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>weekly</changefreq>
${alternates}
</url>`)
}
}
return `<?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">
${urls.join('\n')}
</urlset>`
}
Then expose this at /sitemap.xml and reference it from robots.txt. The xhtml:link entries are required — without them, Google treats each locale URL as a separate cluster.
Step 5: Mark Up Locale Pages With JSON-LD inLanguage
Structured data helps Google understand the language and regional targeting of each page beyond what <html lang> and hreflang provide. The schema.org/inLanguage property works inside any WebPage or Article schema:
// components/JsonLd.tsx
import { type Locale } from '@/i18n/config'
interface WebPageSchemaProps {
locale: Locale
pathname: string
title: string
description: string
}
export function WebPageSchema({ locale, pathname, title, description }: WebPageSchemaProps) {
const schema = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description,
inLanguage: locale,
url: `https://tanstackship.com/${locale}${pathname}`,
isPartOf: {
'@type': 'WebSite',
name: 'TanStack Ship',
url: 'https://tanstackship.com',
inLanguage: LOCALES,
},
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
)
}
Two specifics to flag. First, the WebSite.inLanguage array tells Google which languages the whole site covers — this lets Google skip the language-picker page and route users straight to their preferred locale. Second, render this script in the page HTML, not via client-side JavaScript — Google reads JSON-LD from the first response payload, not after hydration.
Step 6: Add the Locale Switcher and Edge Redirect
The locale switcher has two responsibilities: change the URL on user action, and trigger a 302 (not 301) redirect when the URL has no locale prefix. The 302 is critical — a 301 makes Google index /:locale URLs even when users land on bare paths. A 302 keeps the canonical default.
// components/LocaleSwitcher.tsx
export function LocaleSwitcher({ currentLocale }: { currentLocale: Locale }) {
const router = useRouter()
const pathname = useLocation({ select: (l) => l.pathname.replace(/^\/[a-z]{2}(\/|$)/, '$1') })
const switchTo = (newLocale: Locale) => {
const newPath = newLocale === DEFAULT_LOCALE
? pathname
: `/${newLocale}${pathname}`
router.navigate({ to: newPath })
}
return (
<select
value={currentLocale}
onChange={(e) => switchTo(e.target.value as Locale)}
>
{LOCALES.map((l) => (
<option key={l} value={l}>{LOCALE_NAMES[l]}</option>
))}
</select>
)
}
On the Cloudflare Workers side, redirect bare paths with a 302:
// workers/_middleware.ts
export async function handle(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const hasLocale = LOCALES.some((l) => url.pathname.startsWith(`/${l}/`) || url.pathname === `/${l}`)
const isAsset = url.pathname.startsWith('/_assets/') || url.pathname.startsWith('/api/')
if (!hasLocale && !isAsset && request.method === 'GET') {
const locale = detectLocale(request, undefined)
if (locale !== DEFAULT_LOCALE) {
return Response.redirect(`${url.origin}/${locale}${url.pathname}${url.search}`, 302)
}
}
return env.ASSETS.fetch(request)
}
The 302 is the right choice per the Cloudflare Workers URL rewrite docs — search engines follow 302s but don't permanently index the redirect target, which keeps your canonical URL strategy intact.
Step 7: Add the CI Check That Catches Hreflang Drift
This is the step I shipped too late. After deploying a batch of new feature landing pages without updating hreflang, Google Search Console reported 184 cluster errors. The fix was a CI check comparing the page route tree against the generated hreflang entries:
// scripts/check-hreflang.ts
import { routeTree } from '../app/router'
import { LOCALES, DEFAULT_LOCALE } from '../i18n/config'
import { generateHreflang } from '../lib/hreflang'
const SITE_URL = 'https://tanstackship.com'
// Walk every route and verify the hreflang cluster is complete
const errors: string[] = []
const seenPaths = new Set<string>()
function walk(route: any, prefix = '') {
const path = `${prefix}${route.path ?? ''}`
if (route.children) {
for (const child of route.children) walk(child, path === '/' ? '' : path)
} else if (path && !path.startsWith('$')) {
seenPaths.add(path)
}
}
walk(routeTree)
for (const pathname of seenPaths) {
const entries = generateHreflang(pathname, SITE_URL)
// Every locale must appear exactly once
const codes = entries.map((e) => e.hreflang).filter((c) => c !== 'x-default')
const expected = new Set(LOCALES)
const actual = new Set(codes)
if (codes.length !== expected.size || [...expected].some((l) => !actual.has(l))) {
errors.push(`${pathname}: hreflang cluster has ${codes.length} entries, expected ${expected.size}`)
}
// x-default must be present
if (!entries.some((e) => e.hreflang === 'x-default')) {
errors.push(`${pathname}: missing x-default hreflang`)
}
}
if (errors.length) {
console.error('hreflang CI check failed:\n' + errors.join('\n'))
process.exit(1)
}
console.log(`hreflang CI check passed across ${seenPaths.size} paths × ${LOCALES.length} locales`)
Wire this into your PR pipeline. On TanStack Ship this runs in GitHub Actions on every PR that touches app/router/ or i18n/, and we have not had a single cluster error since.
Real Data: 90 Days After Launch
Here are the actual production numbers from TanStack Ship after 90 days:
- Non-English organic clicks: +47% vs the 90 days before launch (Google Search Console)
- Non-English signups: +38% (signup attribution by
?langquery param + UTM?utm_content=locale) - Indexed locale pages: 1,432 across 12 locales (Search Console → Pages report)
- hreflang cluster errors: 0 (down from 184 at launch before CI check)
- Median position for non-English queries: 6.2 (vs 14.8 for English SERPs in the same verticals)
- Time per locale: ~6 hours including translation QA
The lift was not uniform. German (/de) and Japanese (/ja) delivered outsized results — both are underserved B2B SaaS markets where English SERPs are not the default search experience. Spanish (/es) and Portuguese (/pt) underperformed until I added Brazilian Portuguese (/pt-BR) as a regional override, which lifted those two locales by an additional 18% within 4 weeks.
Related reading: Multilingual Seo Saas Strategy · Technical Seo Audit For Saas The 20 Point Checklist Before You Scale · D1 Implementation For Multi Tenant Saas
FAQ: Multilingual SEO Implementation
Should I use subdirectory, subdomain, or ccTLD?
Subdirectory, unless you have a regulatory reason to split (financial services, healthcare). The Google Search Central international guide confirms Google can figure out geo-targeting regardless of structure, but subdirectory keeps analytics simple and consolidates domain authority.
Do I need hreflang if I have only 2 locales?
Yes. Without hreflang, Google may show the English page to a German-speaking user, or worse, treat both locales as duplicate content and drop one from the index. The hreflang spec is unambiguous that every cluster must reference every other page including itself.
Can I generate hreflang client-side?
No. Google reads hreflang from the initial HTML response, not after hydration. If you generate hreflang in a React useEffect, Google will not see it. The HreflangMeta component above renders the <link> tags in the server-rendered HTML payload, which is the correct pattern.
What about regional variants like en-GB vs en-US?
Only ship regional variants if you have genuinely different content for each region (pricing, legal, units, currencies). If you ship en-GB and en-US with identical content, Google will treat them as duplicates and drop one. The TanStack Ship pattern uses just en for English and reserves regional codes for /pt-BR (Brazilian Portuguese) where we genuinely have different pricing.
How long does it take to rank in a new locale?
In my data, 4–8 weeks for a translation of an existing well-ranked English page. The hreflang cluster and per-locale sitemap get you indexed fast; ranking still requires good content and backlinks, which is the same dynamic as English SERPs.
Multilingual SEO for SaaS is an implementation problem, not a translation problem. If you want the strategy framework first, read Multilingual SEO for SaaS: 8 Strategies to Triple International Traffic. If you want the production boilerplate with TanStack Router, per-locale sitemaps, hreflang generation, JSON-LD schema, and the CI check all wired in, TanStack Ship ships all 12 locales pre-configured with verified hreflang clusters and zero cluster errors. See TanStack Ship features and pricing for what's included.