i18nLanguage DetectionLocalizationSaaSUX

Language Detection Strategies: Browser, Geo, User Preference

Implement language detection for SaaS applications — browser Accept-Language, geolocation, user preferences, and URL-based strategies with proper fallback chains.

Sam Rivera
Sam Rivera
June 12, 202612 min read

TL;DR: Detecting the right language for each visitor involves layering browser headers, geolocation, stored preferences, and URL signals. This guide covers detection strategies, fallback chains, and persistence. Patterns from tanstackship.com.

Introduction

Showing users the wrong language is one of the fastest ways to lose international visitors. A German user landing on an English-only page has a 70% bounce rate. Effective language detection requires a multi-layered strategy with sensible fallbacks. For an overview of i18n architecture patterns, start with our i18n for SaaS: Architecture Patterns Compared.

Detection Layer Strategy

PrioritySourceHow It WorksReliability
1URL path/language prefix/de/dashboardHighest
2User preference (saved)Cookie, localStorage, DBHigh
3Browser Accept-Languagenavigator.languagesMedium
4Geo IP locationCloudflare, MaxMindMedium
5Default localeConfigurable fallbackLowest

URL-Based Detection (Highest Priority)

ts
// TanStack Router: locale from URL path
import { createRootRoute, createRouter, redirect } from '@tanstack/react-router'

const SUPPORTED_LOCALES = ['en', 'de', 'zh', 'ja', 'ar']
const DEFAULT_LOCALE = 'en'

// Root route with locale parameter
const rootRoute = createRootRoute({
  beforeLoad: ({ params, location }) => {
    const locale = params.locale
    
    if (!locale || !SUPPORTED_LOCALES.includes(locale)) {
      // Detect and redirect
      const detected = detectUserLocale()
      throw redirect({
        to: `/${detected}${location.pathname}`,
        replace: true,
      })
    }
    
    // Set locale for the app
    setLocale(locale)
  },
})

Browser Language Detection (Middleware)

ts
// Cloudflare Worker: Accept-Language parsing
function parseAcceptLanguage(header: string | null): string[] {
  if (!header) return [DEFAULT_LOCALE]
  
  return header
    .split(',')
    .map(entry => {
      const [lang, quality = 'q=1'] = entry.trim().split(';')
      const q = parseFloat(quality.replace('q=', '')) || 1
      const locale = lang.split('-')[0] // 'en-US' → 'en'
      return { locale, q }
    })
    .sort((a, b) => b.q - a.q)
    .map(entry => entry.locale)
    .filter(locale => SUPPORTED_LOCALES.includes(locale))
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)
    const pathLocale = url.pathname.split('/')[1]
    
    if (SUPPORTED_LOCALES.includes(pathLocale)) {
      return env.ASSETS.fetch(request)
    }
    
    // Detect from Accept-Language
    const acceptLang = request.headers.get('Accept-Language')
    const preferredLocales = parseAcceptLanguage(acceptLang)
    const detectedLocale = preferredLocales[0] || 'en'
    
    // Redirect to detected locale path
    return Response.redirect(`${url.origin}/${detectedLocale}${url.pathname}`, 302)
  }
}

User Preference Persistence

tsx
// React: save and restore language preference
import { setLocale } from '~/paraglide/runtime.js'

function LanguageSwitcher() {
  const switchLanguage = (locale: string) => {
    // Save preference
    localStorage.setItem('preferred-locale', locale)
    document.cookie = `locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365}`
    
    // Switch locale (Paraglide.js)
    setLocale(locale)
    
    // Update URL without full reload
    const url = new URL(window.location.href)
    url.pathname = url.pathname.replace(/^\/[a-z]{2}/, `/${locale}`)
    window.history.pushState({}, '', url)
  }
  
  return (
    <select onChange={(e) => switchLanguage(e.target.value)}>
      <option value="en">English</option>
      <option value="de">Deutsch</option>
      <option value="zh">中文</option>
      <option value="ja">日本語</option>
      <option value="ar">العربية</option>
    </select>
  )
}

Detection Decision Flow

Visitor arrives
      ↓
URL has locale? ──Yes──→ Use URL locale
      No
      ↓
Has saved preference? ──Yes──→ Use saved locale
      No
      ↓
Check Accept-Language
      ↓
Locale supported? ──Yes──→ Use browser locale
      No
      ↓
Geo IP detection
      ↓
Use default (en) ──────→ Show locale switcher prominently

Conclusion

Layer your detection strategy: URL path first, saved preference second, browser headers third, geolocation fourth. Always show a prominent language switcher — automated detection should never be the only way to change language. Cache the user's choice in a cookie and localStorage for persistence across sessions.

For SEO aspects of language detection, see the hreflang Tags Ultimate Guide. If you're building locale-specific landing pages, Building a Language-Specific Landing Page Strategy covers the approach. For running experiments across languages, check out the Multi-Language A/B Testing Guide.