Bridge Training
i18n

Usage

How to integrate and use @workspace/i18n in your application.

Installation

Add the dependency to your app's package.json:

{
  "dependencies": {
    "@workspace/i18n": "workspace:*"
  }
}

Import Styles

The package ships a Tailwind CSS source directive that enables component styling. You must import it in your app's root stylesheet:

globals.css
@import "@workspace/i18n/styles.css";

This is required for the LanguageBanner and LanguageSelect components to render correctly.

Constants

Language Codes

Use ALL_LANGUAGE_CODES for the full list, or define an app-specific subset:

import { ALL_LANGUAGE_CODES, Lang, type LanguageCode } from "@workspace/i18n";

// Full list (10 languages)
const allLanguages = ALL_LANGUAGE_CODES;

// App-specific subset
const SUPPORTED_LANGUAGES = ["fr", "en"] as const;
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];

Type-Safe References

Use the Lang constant to avoid string typos:

import { Lang } from "@workspace/i18n";

const defaultLocale = Lang.FR; // "fr"
const fallback = Lang.EN;      // "en"

Language Names

Native display names for each language, useful for UI labels:

import { LANGUAGE_NAMES } from "@workspace/i18n";

LANGUAGE_NAMES.fr; // "Français"
LANGUAGE_NAMES.en; // "English"
LANGUAGE_NAMES.de; // "Deutsch"

OpenGraph Locales

Standard language_TERRITORY format for meta tags and social sharing:

import { OG_LOCALES } from "@workspace/i18n";

OG_LOCALES.fr; // "fr_FR"
OG_LOCALES.en; // "en_US"

Language Detection

Detect the user's preferred language using browser signals:

import { detectPreferredLanguage, saveLanguagePreference } from "@workspace/i18n";

// Priority: localStorage → navigator.language → fallback
const preferred = detectPreferredLanguage(ALL_LANGUAGE_CODES, Lang.FR);

// Save user's explicit choice
saveLanguagePreference(Lang.EN);

The detection works in both SSR and browser contexts (returns the fallback on the server).

Validation

Parse and Validate

import { parseLanguage, isValidLanguage } from "@workspace/i18n";

// Validate and return a supported language (or fallback)
const lang = parseLanguage(userInput, Lang.FR, ["fr", "en"] as const);

// Type guard
if (isValidLanguage(someString)) {
  // someString is LanguageCode
}

Selector Options

Generate { value, label } options for dropdown components:

import {
  getLanguageSelectorOptions,
  ALL_LANGUAGE_SELECTOR_OPTIONS,
} from "@workspace/i18n";

// Pre-computed options for all languages
ALL_LANGUAGE_SELECTOR_OPTIONS;
// [{ value: "ca", label: "Català" }, { value: "de", label: "Deutsch" }, ...]

// Options for an app-specific subset
const options = getLanguageSelectorOptions(["fr", "en"] as const);
// [{ value: "fr", label: "Français" }, { value: "en", label: "English" }]

URL Locale Replacement

For apps with locale-prefixed URLs (e.g. /fr/about/en/about):

import { replaceLocaleInPathname } from "@workspace/i18n";

replaceLocaleInPathname("/fr/about", "en"); // "/en/about"

Components

LanguageSelect

A language dropdown built on the shared Radix UI Select component.

import { LanguageSelect, ALL_LANGUAGE_SELECTOR_OPTIONS } from "@workspace/i18n";

<LanguageSelect
  languages={ALL_LANGUAGE_SELECTOR_OPTIONS}
  value={currentLang}
  onValueChange={setLanguage}
/>

Display modes:

ModeDescription
"label"Language name only (default)
"icon"Globe icon only (name is sr-only)
"icon-and-label"Globe icon + language name
// Icon-only for compact UI
<LanguageSelect
  display="icon"
  languages={options}
  value={lang}
  onValueChange={setLang}
/>

LanguageBanner

A banner that suggests switching to the user's detected preferred language. Shows only when the detected language differs from the current one.

import { LanguageBanner, ALL_LANGUAGE_CODES } from "@workspace/i18n";

<LanguageBanner
  currentLanguage={locale}
  supportedLanguages={ALL_LANGUAGE_CODES}
  onSwitchLanguage={(lang) => switchTo(lang)}
/>

Behavior:

  1. Detects preferred language via detectPreferredLanguage()
  2. Displays banner if detected language differs from current
  3. "Switch" button — calls onSwitchLanguage (label shown in the suggested language)
  4. "Stay" button — saves preference to localStorage, closes banner
  5. "X" button — dismisses without saving (banner reappears on next visit)

Custom labels:

<LanguageBanner
  currentLanguage={locale}
  supportedLanguages={ALL_LANGUAGE_CODES}
  labels={{
    message: (langName) => `Il semble que vous préfériez ${langName}`,
    stayIn: (langName) => `Rester en ${langName}`,
  }}
  onSwitchLanguage={handleSwitch}
/>

Integration Patterns

URL-Based Routing (Next.js)

Used in apps/web — language is part of the URL (/fr/about, /en/about).

Configuration:

lib/i18n-config.ts
import {
  ALL_LANGUAGE_CODES,
  isValidLanguage,
  LANGUAGE_NAMES,
  type LanguageCode,
  OG_LOCALES,
  parseLanguage,
} from "@workspace/i18n";

export const i18n = {
  defaultLocale: "fr" as const satisfies LanguageCode,
  locales: ALL_LANGUAGE_CODES,
} as const;

export const localeNames = LANGUAGE_NAMES;
export const ogLocales = OG_LOCALES;

export function parseLocale(lang: string): LanguageCode {
  return parseLanguage(lang, i18n.defaultLocale, i18n.locales);
}

Language selector with Next.js router:

components/language-selector.tsx
"use client";

import {
  ALL_LANGUAGE_SELECTOR_OPTIONS,
  type LanguageCode,
  LanguageSelect,
} from "@workspace/i18n";
import { usePathname, useRouter } from "next/navigation";
import { replaceLocaleInPathname } from "@workspace/i18n/validation";

export function LanguageSelector({ currentLocale }: { currentLocale: LanguageCode }) {
  const pathname = usePathname();
  const router = useRouter();

  return (
    <LanguageSelect
      languages={ALL_LANGUAGE_SELECTOR_OPTIONS}
      value={currentLocale}
      onValueChange={(lang) => router.push(replaceLocaleInPathname(pathname, lang))}
    />
  );
}

Language banner with URL switching:

components/language-banner-wrapper.tsx
"use client";

import { ALL_LANGUAGE_CODES, LanguageBanner, type LanguageCode } from "@workspace/i18n";
import { usePathname, useRouter } from "next/navigation";

export function LanguageBannerWrapper({ locale }: { locale: LanguageCode }) {
  const pathname = usePathname();
  const router = useRouter();

  return (
    <LanguageBanner
      currentLanguage={locale}
      supportedLanguages={ALL_LANGUAGE_CODES}
      onSwitchLanguage={(lang) =>
        router.push(replaceLocaleInPathname(pathname, lang))
      }
    />
  );
}

i18next-Based (Vite + React)

Used in apps/app — language is managed by i18next state.

Configuration with app-specific subset:

lib/i18n/constants.ts
import { getLanguageSelectorOptions, parseLanguage } from "@workspace/i18n";

export const SUPPORTED_LANGUAGES = ["fr", "en"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const DEFAULT_LANGUAGE: SupportedLanguage = "fr";

// Generate selector options with native language labels
export const LANGUAGES = getLanguageSelectorOptions(SUPPORTED_LANGUAGES);

// Parse and validate language strings
export const getSupportedLanguage = (language: string | undefined | null) =>
  parseLanguage(language, DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES);

Language selector with i18next:

components/language-field.tsx
import { LanguageSelect } from "@workspace/i18n";
import { LANGUAGES, type SupportedLanguage } from "@/lib/i18n/constants";

<LanguageSelect
  languages={LANGUAGES}
  value={currentLang}
  onValueChange={(lang) => i18n.changeLanguage(lang)}
/>

Standalone Library (Isolated i18next Instance)

Used in apps/editor — creates an isolated i18next instance to avoid conflicts when embedded in a host application.

i18n/config.ts
import {
  ALL_LANGUAGE_CODES,
  ALL_LANGUAGE_SELECTOR_OPTIONS,
  LANGUAGE_NAMES,
  type LanguageCode,
} from "@workspace/i18n";
import i18next from "i18next";

// Isolated instance to avoid conflicts with host app
const i18n = i18next.createInstance();

i18n.init({
  supportedLngs: ALL_LANGUAGE_CODES,
  fallbackLng: "en",
  // ...
});
components/language-selector.tsx
import { LanguageSelect } from "@workspace/i18n";

<LanguageSelect
  display="icon-and-label"
  languages={ALL_LANGUAGE_SELECTOR_OPTIONS}
  value={language}
  onValueChange={setLanguage}
/>

Sharing Package Translations

Packages that ship their own translations (e.g. @workspace/bridge-react) follow a standard pattern for exporting and loading them in consuming apps.

Exporting Translations from a Package

1. Store translation JSON files per language

packages/bridge-react/src/i18n/
├── locales/
│   ├── en.json
│   ├── fr.json
│   ├── de.json
│   └── ...
└── index.ts

2. Export a namespace constant and a resources object

// packages/bridge-react/src/i18n/index.ts
import en from "./locales/en.json";
import fr from "./locales/fr.json";

export const PRIMITIVES_NAMESPACE = "primitives" as const;

export const primitivesResources = {
  en: { [PRIMITIVES_NAMESPACE]: en },
  fr: { [PRIMITIVES_NAMESPACE]: fr },
} as const;

3. Expose as a sub-path export

// package.json
{
  "exports": {
    "./i18n": "./src/i18n/index.ts"
  }
}

Loading Strategies

HTTP Backend (apps/app)

The main app uses i18next-http-backend to load translations as JSON files at runtime. Package translations need to be copied to the public/locales/ directory so the backend can serve them.

vite.config.ts
import { viteStaticCopy } from 'vite-plugin-static-copy';

export default defineConfig({
  plugins: [
    viteStaticCopy({
      targets: [
        {
          src: '../../packages/bridge-react/src/i18n/locales/en.json',
          dest: 'locales/en',
          rename: 'primitives.json',
        },
        {
          src: '../../packages/bridge-react/src/i18n/locales/fr.json',
          dest: 'locales/fr',
          rename: 'primitives.json',
        },
      ],
    }),
  ],
});

Register the namespaces in i18next:

i18n.use(Backend).init({
  ns: ['translation', 'primitives', 'rich-editor'],
  fallbackLng: 'fr',
});

Resources can also be provided statically for faster initial render:

lib/i18n/constants.ts
import { primitivesResources } from '@workspace/bridge-react/i18n';

export const resources = {
  en: {
    translation: translationEn,
    ...primitivesResources.en,
  },
  fr: {
    translation: translationFr,
    ...primitivesResources.fr,
  },
} as const;

Direct Bundle (apps/editor)

The editor bundles all translations directly in JS — no HTTP loading needed. Simpler but increases bundle size.

import en from './locales/en.json';
import fr from './locales/fr.json';

const i18n = i18next.createInstance();

i18n.init({
  resources: {
    en: { translation: en },
    fr: { translation: fr },
  },
});

Key differences:

  • Creates an isolated i18next instance (createInstance()) to avoid conflicts with a host app
  • Translations are tree-shaken at build time
  • No vite-plugin-static-copy needed

Adding Package Translations to an App

  1. Install the package as a dependency
  2. Choose a strategy (HTTP Backend or Direct Bundle)
  3. For HTTP Backend:
    • Add viteStaticCopy targets in vite.config.ts for each language
    • Add the namespace to i18n.init({ ns: [...] })
    • Optionally spread the resources for static initialization
  4. For Direct Bundle:
    • Import the resources object and spread it into your i18next config

Summary

AspectURL-Based (web)i18next (app)Isolated (editor)
LanguagesAll 10Subset (fr, en)All 10
Switchingrouter.push()i18n.changeLanguage()i18n.changeLanguage()
DetectiondetectPreferredLanguage()i18next-browser-languagedetectori18next-browser-languagedetector
ComponentsLanguageBanner + LanguageSelectLanguageSelectLanguageSelect
Styles@import "@workspace/i18n/styles.css"@import "@workspace/i18n/styles.css"@import "@workspace/i18n/styles.css"
TranslationsDictionary filesHTTP Backend + staticDirect Bundle

On this page