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"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
- Parse the
Accept-Languageheader into a priority-sorted list - Match against available locales from the CDN manifest
- Fallback to
defaultLocaleif 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:
| Priority | Strategy | Example |
|---|---|---|
| 1 | Exact match | "tr-TR" matches "tr-TR" |
| 2 | Base language | "tr-TR" matches "tr" (strips region) |
| 3 | Region 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:
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.