SayKit
Guides

Locale detection

Patterns for picking a locale from headers, cookies, URL segments, or the browser

SayKit doesn't take an opinion on where the locale comes from. You feed catalogue.match() one or more guesses, and it returns the best supported locale. That makes it easy to layer detection strategies.

catalogue.match(['fr-CA', 'en-US']);
// → 'fr' if your configured locales include 'fr'

This guide walks through the common sources and how to combine them.

How match() works

For each guess, in order, match() tries two things:

  1. Exact match against your configured locales.
  2. Language-prefix match (fr-CA to fr, or fr to fr-CA).

The first guess that satisfies either step wins. If no guess matches, the first locale the catalogue was written with is returned as a fallback.

const catalogue = createCatalogue({ en, fr });

catalogue.match(['de', 'fr-CA', 'en']); // 'fr'
// 'de': no exact, no prefix
// 'fr-CA': no exact, prefix matches 'fr' → returns 'fr'

This means an earlier guess always wins, even when a later guess would have matched exactly. Order your guesses by trust, not by closeness.

match() accepts strings and arrays interchangeably:

catalogue.match('fr-CA', 'en-US');
catalogue.match(['fr-CA', 'en-US']);
catalogue.match(headerLocales, [cookieLocale], 'en');

Sources

Browser

In a browser-only app, the user's preferred languages come from navigator.languages:

const say = catalogue.locale(catalogue.match(navigator.languages));

navigator.languages is already a most-preferred-first list, which is exactly what match() wants.

Accept-Language header

On the server, parse the Accept-Language header:

function parseAcceptLanguage(header: string): string[] {
  return header
    .split(',')
    .map((s) => {
      const [tag, q] = s.split(';q=');
      return { tag: tag!.trim(), q: q ? Number(q) : 1 };
    })
    .sort((a, b) => b.q - a.q)
    .map((entry) => entry.tag);
}

const guesses = parseAcceptLanguage(request.headers.get('accept-language') ?? '');
const locale = catalogue.match(guesses);

Use this to remember the user's choice across sessions:

const fromCookie = request.cookies.get('locale')?.value;
const locale = catalogue.match(fromCookie, headerGuesses);

When a user switches locale, write the cookie:

response.cookies.set('locale', newLocale, { path: '/', maxAge: 60 * 60 * 24 * 365 });

URL segment

The clearest, most shareable option, the locale is in the path:

/             → default
/fr           → fr
/fr/about     → fr
/en/about     → en
const fromUrl = url.pathname.split('/')[1];
const locale = catalogue.match([fromUrl, ...cookieAndHeaderGuesses]);

How you read the segment is framework-specific (a route parameter, a slice of request.url, an Astro.params entry, etc.), but the match() call is the same.

Combining sources

The strongest signal usually wins. A good default ordering:

const locale = catalogue.match(
  [urlLocale], // explicit URL > everything
  [cookieLocale], // remembered preference
  acceptLanguageGuesses, // hint from the browser
  ['en'], // last-resort default
);

match() processes each guess fully (exact, then prefix) before moving on, so the URL guess can match by prefix and still beat a later exact match from a cookie.

Switching locales

Persist the choice (cookie, profile, …) and either:

  • Reload: simplest, works with any routing strategy.
  • Re-route: for URL-based locales, push the new path.
  • Re-render: call store.set(newLocale) on a store; everything below a SayProvider following it re-renders.
function LocaleSwitcher() {
  return (
    <select onChange={(e) => navigate(`/${e.target.value}`)}>
      <option value="en">English</option>
      <option value="fr">Fran&ccedil;ais</option>
    </select>
  );
}

Next

  • Runtime: catalogues, views, and per-request locales
  • Dynamic loading: lazy-load catalogues to keep bundles small

On this page