Skip to content

Instantly share code, notes, and snippets.

@dantman
Created August 31, 2026 22:09
Show Gist options
  • Select an option

  • Save dantman/1cd901629c41113ad17fb4cd49d19f39 to your computer and use it in GitHub Desktop.

Select an option

Save dantman/1cd901629c41113ad17fb4cd49d19f39 to your computer and use it in GitHub Desktop.
An experimental mini-library for getting an array of the user's language preferences from Accepts-Language/navigator.languages in a way that works without hydration errors and updates when preferences change
import { normalizeLanguageCode } from "./normalize";
/**
* A regexp to separate language tags from their optional q-factor weight in the Accept-Language header.
*/
const DIRECTIVE_REGEX = /^([^;]+)(?:;\s*q\s*=\s*(\d(?:\.\d+)?|\.\d+)\s*)?$/i;
/**
* Represents a single language directive from the Accept-Language header, including its position, normalized language code, and quality factor.
* e.g. "en;q=0.5" as the first word in the header would result in `{ headerIndex: 0, languageCode: "en", q: 0.5 }`.
* And either "en" or "en;q=1" would result in `{ headerIndex: 0, languageCode: "en", q: 1 }`.
*/
type Directive = {
headerIndex: number;
languageCode: string;
q: number;
};
/**
* Split and normalize the Accept-Language header into Directive objects.
* @param header The Accept-Language header string to parse.
* @returns An iterable of Directive objects representing the parsed language preferences.
*/
function* splitAndNormalizeAcceptsLanguageHeader(
header: string
): Iterable<Directive> {
const headerDirectives = header.split(",");
for (
let headerIndex = 0, directivesLength = headerDirectives.length;
headerIndex < directivesLength;
++headerIndex
) {
const directiveText = headerDirectives[headerIndex];
const match = DIRECTIVE_REGEX.exec(directiveText);
if (match) {
const [, languageText, qFactor] = match;
const languageCode = normalizeLanguageCode(languageText);
// Skip invalid or unrecognized language codes
if (languageCode === undefined) continue;
const q = qFactor ? Number(qFactor) : 1;
if (Number.isNaN(q)) continue; // skip invalid q values
yield { headerIndex, languageCode, q };
} else {
// Invalid directive, skip it
continue;
}
}
}
/**
* Compare two Directive objects for sorting purposes.
*/
function compareDirectives(a: Directive, b: Directive): number {
// Compare quality first
const qualityComparison = b.q - a.q;
if (qualityComparison !== 0) {
return qualityComparison;
}
// If quality is the same, preserve the original order
return a.headerIndex - b.headerIndex;
}
/**
* Parse the Accept-Language header and return a list of unique language codes sorted by preference.
*
* All codes are normalized to their canonical BCP 47 format.
* @param header The Accept-Language header string to parse.
* @returns An array of unique language codes sorted by preference.
*/
export function parseAcceptsLanguage(header: string): string[] {
// 1. Split and normalize the Accept-Language header into Directive objects
const directives = [...splitAndNormalizeAcceptsLanguageHeader(header)];
// 2. Sort the directives by quality and original order
directives.sort(compareDirectives);
// 3. Map the sorted Directive objects to their language codes
const languageCodes = directives.map((directive) => directive.languageCode);
// 4. Use Set to remove duplicate language codes while preserving order
// @note The built-in Set is supposed to preserve the insertion order
const uniqueLanguageCodes = new Set(languageCodes);
// 5. Convert the Set back to an array to return the final list of language codes
return [...uniqueLanguageCodes];
}
import { useSyncExternalStore } from "react";
import * as store from "./store";
/**
* Provides the user's current language preferences to render with.
*
* This is sourced from the `Accepts-Language` header server side,
* and from the browser's `navigator.languages` property client side.
*
* Re-renders if the `navigator.languages` preference changes.
*
* @returns An array of the user's preferred languages.
*/
export function useLanguages(): string[] {
return useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
);
}
// @todo Should we add an optional LanguagesProvider with a context
// that useLanguages will use instead of directly subscribing to the store?
// This may be necessary if we encounter the useSyncExternalStore limitation with suspense.
import { normalizeLanguageCode } from "./normalize";
/**
* Get the list of language codes from the browser's navigator object, yielding them one by one.
* Invalid or unrecognized language codes are skipped.
*
* `navigator.languages` is preferred, but `navigator.language` is used as a fallback if `navigator.languages` is not available.
* We also handle any buggy browsers where `navigator.language` contains multiple comma-separated languages.
*/
function* getLanguagesFromNavigator(): Iterable<string> {
// We are not running in a browser-like environment
if (typeof navigator === "undefined") return;
let foundLanguages = false;
// Yield languages from navigator.languages if available
if (navigator.languages) {
for (const language of navigator.languages) {
const languageCode = normalizeLanguageCode(language);
if (languageCode !== undefined) {
yield languageCode;
foundLanguages = true;
}
}
}
if (foundLanguages) return;
if (navigator.language) {
// There was once a browser bug where navigator.language could contain multiple comma-separated languages
const languages = navigator.language.includes(",")
? navigator.language.split(",")
: [navigator.language];
for (const language of languages) {
const languageCode = normalizeLanguageCode(language);
if (languageCode !== undefined) {
yield languageCode;
}
}
}
}
/**
* Get the list of unique language codes from the browser's navigator object.
*/
function getNavigatorLanguagesInternal(): string[] {
// 1. Get the list of language codes from the navigator
const languageCodes = [...getLanguagesFromNavigator()];
// 2. Use Set to remove duplicate language codes while preserving order
// @note The built-in Set is supposed to preserve the insertion order
const uniqueLanguageCodes = new Set(languageCodes);
// 3. Convert the Set back to an array to return the final list of language codes
return [...uniqueLanguageCodes];
}
/**
* Cache for the last call to getNavigatorLanguages to improve performance.
*
* navigator.language(s) rarely change, so caching the result can improve performance and keep the result effectively immutable.
*/
let cache:
| {
key: string;
result: string[];
}
| undefined = undefined;
/**
* Get the list of unique language codes from the browser's navigator object.
*/
export function getNavigatorLanguages(): string[] {
const cacheKey = JSON.stringify({
language: navigator.language,
languages: navigator.languages,
});
if (cache && cache.key === cacheKey) {
return cache.result;
}
const result = getNavigatorLanguagesInternal();
cache = { key: cacheKey, result };
return result;
}
import { headers } from "next/headers";
import Script from "next/script";
import { cache } from "react";
import { parseAcceptsLanguage } from "./header";
import { makeInitialLanguagesScript } from "./store.client-initial";
import { getServerLanguages, initializeServerLanguages } from "./store.server";
/**
* Retrieves the list of accepted languages from the request headers.
*/
export const getAcceptLanguages = cache(async function getAcceptLanguages() {
const headersList = await headers();
const acceptLanguageHeader = headersList.get("Accept-Language");
if (!acceptLanguageHeader) return [];
return parseAcceptsLanguage(acceptLanguageHeader);
});
/**
* Initializes the server languages from the request's Accept-Language headers.
*
* Must be called and awaited once early in the request lifecycle, before any rendering that depends on the server languages is performed.
*/
export async function initializeServerLanguagesFromRequest() {
const initialLanguages = await getAcceptLanguages();
initializeServerLanguages(initialLanguages);
}
/**
* Injects a script into the HTML that initializes the client-side languages with the server-provided initial languages.
* Must be used in the root layout to ensure the client-side languages are initialized before any interactive scripts run.
*/
export async function InitialLanguagesScript() {
const initialLanguages = getServerLanguages();
const script = makeInitialLanguagesScript(initialLanguages);
return (
// eslint-disable-next-line @next/next/no-before-interactive-script-outside-document -- It is expected that InitialLanguagesScript will be used in the root layout
<Script {...script.attributes} strategy='beforeInteractive'>
{script.unencodedSource}
</Script>
);
}
/**
* Normalize and canonicalize a language code to a BCP 47 compliant format, or return `undefined`.
*
* This function fixes common non-standard language tags, such as POSIX codes, and ensures the output is a valid BCP 47 language tag.
* Likewise it handles known browser bugs like "en-US@posix" or an empty string "" gracefully.
*
* The resulting language code will be in a canonical BCP 47 format, including proper casing and standardized subtags.
*
* @param languageTag - The language tag to normalize and canonicalize.
* @returns The normalized and canonicalized language code, or `undefined` if the input is invalid.
*/
export function normalizeLanguageCode(languageTag: string): string | undefined {
// Trim the input and initialize the language code variable
let languageCode = languageTag.trim();
// Skip wildcard and empty language tags
if (languageCode === "*" || languageCode === "") return undefined;
// Replace POSIX codes with BCP 47 equivalents if necessary
if (
languageCode === "C" ||
languageCode === "posix" ||
languageCode === "POSIX"
) {
languageCode = "en-US";
}
// Normalize "en-US.UTF-8" style language tags by removing the encoding part
if (languageCode.includes(".")) {
languageCode = languageCode.split(".")[0];
}
// Normalize buggy "en-US@posix" style language tags by removing the variant part
if (languageCode.includes("@")) {
languageCode = languageCode.split("@")[0];
}
// Normalize posix-style language tags to BCP 47 by replacing underscores with hyphens
if (languageCode.includes("_")) {
languageCode = languageCode.replaceAll("_", "-");
}
// Canonicalize the language tag using the Intl API
// This will fix lowercase codes like `en-us` to `en-US` and codes with aliases like `ja-Latn-YU-hepburn-heploc` to `ja-Latn-RS-alalc97`
try {
languageCode = Intl.getCanonicalLocales(languageCode.replace(/_/g, "-"))[0];
} catch {
// Drop invalid tags
return undefined;
}
// Return the normalized and canonicalized language code
return languageCode;
}
/**
* The key on window used to store the list of languages used during server-side rendering.
*/
export const INITIAL_LANGUAGES_WINDOW_KEY = "__INITIAL_REQUEST_LANGUAGES__";
declare global {
interface Window {
__INITIAL_REQUEST_LANGUAGES__?: string[];
}
}
/**
* The ID of the script element used to embed the initial languages into the HTML.
*/
export const INITIAL_LANGUAGES_SCRIPT_ID = "initial-languages";
/**
* Represents the structure of the script element containing the initial languages.
*/
export type InitialLanguagesScript = {
attributes: {
id: string;
"data-initial-languages": string;
};
unencodedSource: string;
};
/**
* Creates metadata used to create an inline <script> tag to embed the initial languages into the HTML.
*
* The `attributes` are expected to be spread onto the tag like `<script {...attributes}>`.
* The `unencodedSource` contains the JavaScript code to place inside `<script>...</script>`, it is not encoded to protect against "</script>" style injection and so must be properly escaped.
*
* @param initialLanguages - The list of initial languages to embed in the HTML.
* @returns An object representing the script element containing the initial languages.
*/
export function makeInitialLanguagesScript(initialLanguages: string[]) {
return {
attributes: {
id: INITIAL_LANGUAGES_SCRIPT_ID,
"data-initial-languages": JSON.stringify(initialLanguages),
},
unencodedSource: `window.${INITIAL_LANGUAGES_WINDOW_KEY} = JSON.parse(document.currentScript.dataset.initialLanguages)`,
};
}
/**
* Retrieves the list of client-side initial languages embedded in the HTML by the server.
* This should only be called on the client side.
*/
export function getInitialClientLanguages() {
if (typeof window === "undefined") {
throw new Error(
"getInitialClientLanguages can only be called on the client."
);
}
// On the client, we want to use the static value calculated at the time of initial loading of the page to avoid issues with hydration mismatches in the unlikely event that the year is different at the time of server rendering vs. the time of client rendering
if (window[INITIAL_LANGUAGES_WINDOW_KEY] != undefined) {
return window[INITIAL_LANGUAGES_WINDOW_KEY]!;
} else {
console.warn(
`window.${INITIAL_LANGUAGES_WINDOW_KEY} is undefined, falling back to a hardcoded default`
);
return [];
}
}
import { cache, cacheSignal } from "react";
type RequestStore = {
languages: string[] | undefined;
};
/**
* Retrieves a per-request store that will retain the same instance throughout the request.
*/
const getRequestStore = cache(function getRequestStore(): RequestStore {
if (cacheSignal() == null) {
throw new Error(
"Server store can only be accessed during a request. Calling it here will not return the same store instance in getServerSnapshot."
);
}
return { languages: undefined };
});
/**
* Initializes the server languages into the per-request store.
* @param languages - An array of language strings to set for the current request.
*/
export function initializeServerLanguages(languages: string[]) {
const store = getRequestStore();
store.languages = languages;
}
/**
* Get the server languages from the per-request store.
* @returns An array of language strings for the current request.
* @throws Error if the server languages have not been initialized with {@link initializeServerLanguages}.
*/
export function getServerLanguages() {
const store = getRequestStore();
if (store.languages == null) {
throw new Error("Server languages have not been initialized.");
}
return store.languages;
}
import { isTestEnvironment } from "../../utils/test-env";
import { getNavigatorLanguages } from "./navigatorLanguages";
import { getInitialClientLanguages } from "./store.client-initial";
import { getServerLanguages } from "./store.server";
const listeners = new Set<() => void>();
/**
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/languagechange_event}
*/
function onLanguageChange() {
notify();
}
let eventsSetup = false;
function setupEvents() {
eventsSetup = true;
window.addEventListener("languagechange", onLanguageChange);
}
function teardownEvents() {
window.removeEventListener("languagechange", onLanguageChange);
eventsSetup = false;
}
/**
* Subscribes a listener to be notified whenever the client's language changes.
*
* @returns An unsubscribe function.
*/
export function subscribe(listener: () => void) {
listeners.add(listener);
// Setup events if they haven't been set up yet
if (!eventsSetup) setupEvents();
return () => {
listeners.delete(listener);
// Teardown events if there are no more listeners
if (listeners.size === 0) teardownEvents();
};
}
/**
* Notifies all subscribed listeners of a language change.
*/
function notify() {
for (const listener of listeners) {
listener();
}
}
/**
* Retrieves the current snapshot of the client-side languages, ensuring referential stability.
*/
export function getSnapshot() {
return keepReferentiallyIdentical(getNavigatorLanguages());
}
/**
* Retrieves the current snapshot of the languages used during server-side rendering or on the client during hydration.
*/
export function getServerSnapshot() {
// On hydration, we want to use the initial value from the server if available
if (
typeof window !== "undefined" &&
// We don't want to use the initial value when running tests
!isTestEnvironment
) {
// On the client, we want to use the static value calculated at the time of initial loading of the page to avoid issues with hydration mismatches in the unlikely event that the year is different at the time of server rendering vs. the time of client rendering
return getInitialClientLanguages();
}
return getServerLanguages();
}
// Caches the result of getNavigatorLanguages to ensure referential stability across renders.
let cache:
| {
key: string;
result: string[];
}
| undefined = undefined;
function keepReferentiallyIdentical(result: string[]) {
const cacheKey = result.join(",");
if (cache && cache.key === cacheKey) {
return cache.result;
}
cache = { key: cacheKey, result };
return result;
}
import { parseAcceptsLanguage } from "./header";
import { getNavigatorLanguages } from "./navigatorLanguages";
import { normalizeLanguageCode } from "./normalize";
const testLocales = [
// Simple: Language only
"en", // English
"EN", // English (uppercase)
"es", // Spanish
"fr", // French
"zh", // Chinese
"ar", // Arabic
"hi", // Hindi,
"zh", // Chinese
// Common: Language + Region
"en-US", // English (United States)
"en-us", // English (United States, lowercase)
"en-CA", // English (Canada)
"en-GB", // English (United Kingdom)
"pt-BR", // Portuguese (Brazil)
"zh-CN", // Chinese (Mainland China)
"zh-HK", // Chinese (Traditional, Hong Kong)
"zh-TW", // Chinese (Taiwan)
"fr-CA", // French (Canada)
// Complex: Language + Script + Region
"zh-Hans-CN", // Chinese (Simplified, Mainland China)
"zh-Hant-HK", // Chinese (Traditional, Hong Kong)
"sr-Cyrl-RS", // Serbian (Cyrillic, Serbia)
"sr-Latn-RS", // Serbian (Latin, Serbia)
"az-Latn-AZ", // Azerbaijani (Latin, Azerbaijan)
"uz-Cyrl-UZ", // Uzbek (Cyrillic, Uzbekistan)
// Edge Cases: Extensions and Variants
"en-US-u-ca-gregory", // English (US) with Gregorian calendar extension
"de-CH-1901", // German (Switzerland) with a specific variant
"en-US-u-cu-usd", // English (US) with USD currency extension
"und", // Undetermined language,
"unknown-locale", // An unknown or invalid locale
];
console.table(
testLocales.map((locale) => ({
locale,
normalized: normalizeLanguageCode(locale),
}))
);
console.log(
"Parsed Accept-Language:",
parseAcceptsLanguage("fr-CH, fr;q=0.9, de;q=0.7, en;q=0.8, *;q=0.5")
);
console.log("Navigator Languages:", getNavigatorLanguages());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment