Better I18NBetter I18N
Remix & Hydrogen

Locale Detection

Detect user locale from URLs, headers, and custom strategies

@better-i18n/remix provides built-in utilities for detecting the user's preferred locale. You can combine multiple strategies depending on your needs.

URL Path Detection

The most common pattern for Remix apps is detecting the locale from the URL path prefix:

/products/hat       → "en" (default, no prefix)
/tr/products/hat    → "tr"
/fr/products/hat    → "fr"
app/lib/i18n.ts
export function getLocaleFromURL(request: Request): string | null {
  const url = new URL(request.url);
  const firstSegment = url.pathname.split("/")[1]?.toLowerCase();

  // Check if it matches a known locale
  const knownLocales = ["en", "tr", "es", "fr", "de"];
  if (firstSegment && knownLocales.includes(firstSegment) && firstSegment !== "en") {
    return firstSegment;
  }

  return null; // Use default
}

For a dynamic approach, use i18n.getLanguages() to fetch available locales from the CDN manifest instead of hardcoding them. This returns LanguageOption[] with code, nativeName, and isDefault fields.

Accept-Language Header Detection

The SDK exports parseAcceptLanguage() and matchLocale() for detecting locale from the browser's Accept-Language header. The built-in detectLocale() method combines both:

import { i18n } from "~/i18n.server";

// Automatic detection from Accept-Language header
const locale = await i18n.detectLocale(request);
// Parses "tr-TR,tr;q=0.9,en-US;q=0.8" → "tr"

How It Works

  1. Parse the Accept-Language header into a priority-sorted list
  2. Match against available locales from the CDN manifest
  3. Fallback to defaultLocale if no match found

Using Utilities Directly

You can also use the parsing and matching utilities independently:

import { parseAcceptLanguage, matchLocale } from "@better-i18n/remix";

// Parse header into priority-sorted language list
const languages = parseAcceptLanguage("tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7");
// → ["tr-TR", "tr", "en-US", "en"]

// Match against your available locales
const locale = matchLocale(languages, ["en", "tr", "es", "fr"]);
// → "tr"

Matching Strategy

matchLocale() tries three strategies in order:

PriorityStrategyExample
1Exact match"tr-TR" matches "tr-TR"
2Base language"tr-TR" matches "tr" (strips region)
3Region expansion"tr" matches "tr-TR" (first variant)

Returns null if no match is found.

Combined Strategy

For production apps, combine URL detection with Accept-Language fallback:

server.ts
import { i18n } from "~/i18n.server";

export default {
  async fetch(request: Request): Promise<Response> {
    // 1. Try URL path first (explicit user choice)
    const urlLocale = getLocaleFromURL(request);

    // 2. Fall back to Accept-Language header
    const locale = urlLocale || await i18n.detectLocale(request);

    const [messages, languages] = await Promise.all([
      i18n.getMessages(locale),
      i18n.getLanguages(),
    ]);

    // ... pass to loader context
  },
};

URL path takes priority because it represents an explicit user choice (they clicked a link or typed the URL), while Accept-Language is an implicit browser preference.

Custom Detection

You can implement any detection strategy by extracting a locale string and passing it to getMessages():

// Cookie-based detection
function getLocaleFromCookie(request: Request): string | null {
  const cookie = request.headers.get("cookie");
  const match = cookie?.match(/locale=(\w+)/);
  return match?.[1] ?? null;
}

// Subdomain-based detection
function getLocaleFromSubdomain(request: Request): string | null {
  const hostname = new URL(request.url).hostname;
  const subdomain = hostname.split(".")[0];
  const knownLocales = ["en", "tr", "es", "fr", "de"];
  return knownLocales.includes(subdomain) ? subdomain : null;
}

The hardcoded knownLocales list above is fine for subdomain detection, but for URL-path and switcher logic prefer i18n.getLanguages() — it reads from the CDN manifest so new languages are picked up automatically without a redeploy.

On this page