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:
@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:
| Mode | Description |
|---|---|
"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:
- Detects preferred language via
detectPreferredLanguage() - Displays banner if detected language differs from current
- "Switch" button — calls
onSwitchLanguage(label shown in the suggested language) - "Stay" button — saves preference to localStorage, closes banner
- "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:
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:
"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:
"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:
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:
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.
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",
// ...
});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.ts2. 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.
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:
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-copyneeded
Adding Package Translations to an App
- Install the package as a dependency
- Choose a strategy (HTTP Backend or Direct Bundle)
- For HTTP Backend:
- Add
viteStaticCopytargets invite.config.tsfor each language - Add the namespace to
i18n.init({ ns: [...] }) - Optionally spread the resources for static initialization
- Add
- For Direct Bundle:
- Import the
resourcesobject and spread it into your i18next config
- Import the
Summary
| Aspect | URL-Based (web) | i18next (app) | Isolated (editor) |
|---|---|---|---|
| Languages | All 10 | Subset (fr, en) | All 10 |
| Switching | router.push() | i18n.changeLanguage() | i18n.changeLanguage() |
| Detection | detectPreferredLanguage() | i18next-browser-languagedetector | i18next-browser-languagedetector |
| Components | LanguageBanner + LanguageSelect | LanguageSelect | LanguageSelect |
| Styles | @import "@workspace/i18n/styles.css" | @import "@workspace/i18n/styles.css" | @import "@workspace/i18n/styles.css" |
| Translations | Dictionary files | HTTP Backend + static | Direct Bundle |