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,
  languages: { code: string; isDefault: boolean }[],
): string | null {
  const url = new URL(request.url);
  const firstSegment = url.pathname.split("/")[1]?.toLowerCase();

  // Match against available locales from CDN manifest
  const match = languages.find(
    (l) => l.code === firstSegment && !l.isDefault,
  );
  return match?.code ?? null; // Use default
}

Pass the result of i18n.getLanguages() as the languages parameter. This way, new languages are picked up automatically from the CDN manifest without a redeploy.

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 (from CDN manifest)
const availableLocales = await i18n.getLocales();
const locale = matchLocale(languages, availableLocales);
// → "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. Fetch language list first (needed for URL detection)
    const languages = await i18n.getLanguages();

    // 2. Try URL path first (explicit user choice)
    const urlLocale = getLocaleFromURL(request, languages);

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

    const messages = await i18n.getMessages(locale);

    // ... 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,
  languages: { code: string }[],
): string | null {
  const hostname = new URL(request.url).hostname;
  const subdomain = hostname.split(".")[0];
  return languages.some((l) => l.code === subdomain) ? subdomain : null;
}

Use i18n.getLanguages() to get the available locales dynamically — new languages are picked up automatically from the CDN manifest without a redeploy.

On this page