Locale Management
Sur cette page
- useLocale
- Return Value
- Locale Switching Flow
- useLocaleRouter
- Why useLocaleRouter?
- Usage
- Return Value
- URL Strategy
- Using localePath
- useLocale vs useLocaleRouter
- useLanguages
- Return Value
- LanguageOption Type
- Dynamic Languages
- Avoid Static Language Arrays
- LanguageSwitcher Component
- Props
- Styling
- Building Custom Switchers
- Button Group
- With Flags
- With Router Integration
- LocaleDropdown
- Installation
- Basic Usage
- Props
- Variants
- CSS Custom Properties
- Data Attributes
- Custom Rendering
- Expo (useLocaleSwitcher)
- Keyboard Navigation
- Related
useLocale #
Access and update the current locale:
import { useLocale } from '@better-i18n/use-intl'
function LocaleDisplay() {
const { locale, setLocale, isLoading } = useLocale()
return (
<div>
<p>Current locale: {locale}</p>
<button onClick={() => setLocale('tr')}>
Switch to Turkish
</button>
</div>
)
}Return Value #
| Property | Type | Description |
|---|---|---|
locale | string | Current locale code (e.g., "en", "tr") |
setLocale | (locale: string) => void | Function to change the locale |
isLoading | boolean | Whether new messages are being loaded |
Locale Switching Flow #
When you call setLocale(), the provider:
- Fetches new messages from the CDN
- Updates the React context
- Triggers re-render with new translations
- Calls
onLocaleChangecallback (if provided)
function LanguageButtons() {
const { locale, setLocale, isLoading } = useLocale()
if (isLoading) {
return <p>Switching language...</p>
}
return (
<div className="flex gap-2">
<button
onClick={() => setLocale('en')}
className={locale === 'en' ? 'font-bold' : ''}
>
English
</button>
<button
onClick={() => setLocale('tr')}
className={locale === 'tr' ? 'font-bold' : ''}
>
Türkçe
</button>
</div>
)
}useLocaleRouter #
Navigation-first hook for locale switching with TanStack Router integration. Uses router.navigate() instead of state updates.
Why useLocaleRouter? #
Traditional i18n libraries use setLocale() which changes React state but doesn't trigger router navigation:
- Not supported: URL not updated
- Not supported: Loaders don't re-execute
- Not supported: Browser history not updated
useLocaleRouter solves these:
- Supported: Triggers proper SPA navigation
- Supported: Re-executes loaders (fresh messages)
- Supported: Updates URL correctly
- Supported: Works with browser history
Usage #
import { useLocaleRouter } from '@better-i18n/use-intl'
function LanguageSwitcher() {
const { locale, locales, navigate, isReady } = useLocaleRouter()
if (!isReady) return <Skeleton />
return (
<select value={locale} onChange={(e) => navigate(e.target.value)}>
{locales.map((loc) => (
<option key={loc} value={loc}>{loc}</option>
))}
</select>
)
}Return Value #
| Property | Type | Description |
|---|---|---|
locale | string | Current locale from URL |
locales | string[] | Available locale codes from CDN manifest |
defaultLocale | string | Default locale (no URL prefix) |
navigate | (locale: string) => void | Navigate to same page with new locale |
localePath | (path: string, locale?: string) => string | Get localized path |
isReady | boolean | Whether languages are loaded from CDN |
URL Strategy #
The default locale has no prefix in the URL:
| Locale | URL |
|---|---|
| English (default) | /about |
| Turkish | /tr/about |
| German | /de/about |
This provides cleaner URLs for the primary language while supporting SEO-friendly URLs for all locales.
Using localePath #
Generate localized paths for links:
function Navigation() {
const { localePath, locale } = useLocaleRouter()
return (
<nav>
<Link to={localePath('/about')}>About</Link>
<Link to={localePath('/contact')}>Contact</Link>
{/* Force a specific locale */}
<Link to={localePath('/about', 'tr')}>About (Turkish)</Link>
</nav>
)
}useLocale vs useLocaleRouter #
| Feature | useLocale | useLocaleRouter |
|---|---|---|
| State update | React context only | Router navigation |
| URL update | Manual | Automatic |
| Loader re-execution | No | Yes |
| History update | No | Yes |
| Use case | Simple CSR apps | TanStack Router apps |
import { useLanguages } from '@better-i18n/use-intl'
function LanguageList() {
const { languages, isLoading } = useLanguages()
if (isLoading) {
return <p>Loading languages...</p>
}
return (
<ul>
{languages.map((lang) => (
<li key={lang.code}>
{lang.nativeName} ({lang.code})
</li>
))}
</ul>
)
}Return Value #
| Property | Type | Description |
|---|---|---|
languages | LanguageOption[] | Array of available languages |
isLoading | boolean | Whether languages are being fetched |
LanguageOption Type #
interface LanguageOption {
code: string // "en", "tr", "de"
name?: string // "English", "Turkish", "German"
nativeName?: string // "English", "Türkçe", "Deutsch"
flagUrl?: string // URL to flag image
}Dynamic Languages #
Languages are fetched from your project's CDN manifest. When you add a new language in the dashboard, it automatically appears:
Dashboard: Add "Spanish" language
↓
CDN manifest updated
↓
useLanguages() returns updated list
↓
Language switcher shows "Español"Avoid Static Language Arrays #
A common mistake is maintaining a hardcoded list for validation or locale detection:
const SUPPORTED = ['tr', 'en', 'ar', 'de'] // [!code --]
if (!SUPPORTED.includes(lang)) return 'en' // [!code --]Use CDN languages instead:
const { languages } = await i18nReady // [!code ++]
const supported = languages.map(l => l.code) // [!code ++]
if (!supported.includes(lang)) return 'en' // [!code ++]import { LanguageSwitcher } from '@better-i18n/use-intl'
function Header() {
return (
<header>
<nav>
<Logo />
</nav>
</header>
)
}Props #
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | CSS class for the select element | |
loadingLabel | string | "Loading..." | Text shown while loading |
renderOption | (lang: LanguageOption) => ReactNode | Custom option renderer |
Styling #
<LanguageSwitcher
className="
w-40 px-4 py-2
bg-white border border-gray-200 rounded-lg
focus:ring-2 focus:ring-blue-500
text-sm font-medium text-gray-700
"
/>import { useLocale, useLanguages } from '@better-i18n/use-intl'
function ButtonGroupSwitcher() {
const { locale, setLocale } = useLocale()
const { languages, isLoading } = useLanguages()
if (isLoading) return null
return (
<div className="flex gap-1 p-1 bg-gray-100 rounded-lg">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => setLocale(lang.code)}
className={`
px-3 py-1.5 rounded-md text-sm font-medium
transition-colors
${locale === lang.code
? 'bg-white shadow text-gray-900'
: 'text-gray-600 hover:text-gray-900'
}
`}
>
{lang.code.toUpperCase()}
</button>
))}
</div>
)
}With Flags #
function FlagSwitcher() {
const { locale, setLocale } = useLocale()
const { languages, isLoading } = useLanguages()
if (isLoading) return null
return (
<div className="flex gap-2">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => setLocale(lang.code)}
className={`
flex items-center gap-2 px-3 py-2 rounded border
${locale === lang.code
? 'bg-blue-50 border-blue-500'
: 'border-gray-200 hover:border-gray-300'
}
`}
>
{lang.flagUrl && (
<img
src={lang.flagUrl}
alt=""
className="w-5 h-4 object-cover rounded-sm"
/>
)}
<span>{lang.nativeName}</span>
</button>
))}
</div>
)
}With Router Integration #
import { useLocaleRouter, useLanguages } from '@better-i18n/use-intl'
function RouterLanguageSwitcher() {
const { locale, navigate, isReady } = useLocaleRouter()
const { languages } = useLanguages()
if (!isReady) {
return <div className="h-10 w-24 bg-gray-200 animate-pulse rounded" />
}
return (
<div className="flex items-center gap-2">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => navigate(lang.code)}
className={`
flex items-center gap-2 px-3 py-2 rounded
${locale === lang.code
? 'bg-blue-100 border-blue-500'
: 'bg-white border-gray-200'
} border
`}
>
{lang.flagUrl && (
<img src={lang.flagUrl} alt="" className="w-5 h-4 object-cover rounded-sm" />
)}
<span>{lang.nativeName}</span>
</button>
))}
</div>
)
}LocaleDropdown #
A fully-featured, accessible dropdown component for locale switching — with flag emojis, native language names, keyboard navigation, and customizable rendering.
Installation #
bun add @better-i18n/use-intlimport { LocaleDropdown } from '@better-i18n/use-intl'
function Header() {
return (
<header>
<nav>
<Logo />
<LocaleDropdown />
</nav>
</header>
)
}Selecting a locale triggers useLocaleRouter().navigate() — a full TanStack Router navigation with loader re-execution.
Next.js
Requires config and locale props:
'use client';
import { LocaleDropdown } from '@better-i18n/next/client';
import { useLocale } from 'next-intl';
import { i18n } from '@/i18n.config';
function Header() {
const locale = useLocale();
return (
<header>
<nav>
<Logo />
<LocaleDropdown config={i18n.config} locale={locale} />
</nav>
</header>
);
}Remix
import { LocaleDropdown } from '@better-i18n/remix/react';
function Header({ locale }: { locale: string }) {
return (
<header>
<nav>
<Logo />
<LocaleDropdown />
</nav>
</header>
);
}Selecting a locale navigates to the locale-prefixed URL using useNavigate() from react-router.
Props #
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "styled" | "unstyled" | "styled" | Styled includes all inline styles + CSS custom properties. Unstyled gives full control. |
className | string | — | CSS class for root wrapper |
triggerClassName | string | — | CSS class for trigger button |
menuClassName | string | — | CSS class for dropdown menu |
showFlag | boolean | true | Show flag emoji/image |
showNativeName | boolean | true | Show native language name (e.g., "Türkçe") |
showLocaleCode | boolean | true | Show locale code (e.g., "TR") |
renderTrigger | (ctx) => ReactNode | — | Custom trigger renderer |
renderItem | (ctx) => ReactNode | — | Custom item renderer |
Next.js only:
| Prop | Type | Default | Description |
|---|---|---|---|
config | I18nConfig | Required | Config from createI18n() |
locale | string | Required | Current locale |
languages | LanguageOption[] | — | Pre-fetched languages (skips client CDN fetch) |
Remix only:
| Prop | Type | Default | Description |
|---|---|---|---|
defaultLocale | string | "en" | Default locale for URL prefix logic |
Variants #
Styled (default)
Includes all inline styles, skeleton loading animation, and CSS custom property support. Drop-in ready:
<LocaleDropdown />Unstyled
No styles applied — data attributes remain for targeting. Full control via className props:
<LocaleDropdown
variant="unstyled"
className="relative inline-block"
triggerClassName="flex items-center gap-2 px-3 py-2 rounded-lg border"
menuClassName="absolute mt-1 w-full rounded-lg border bg-white shadow-lg"
/>CSS Custom Properties #
Override these on any parent element to theme the styled variant:
| Property | Default | Description |
|---|---|---|
--better-locale-trigger-padding | 6px 10px | Trigger padding |
--better-locale-trigger-radius | 8px | Trigger border-radius |
--better-locale-trigger-border | 1px solid transparent | Trigger border |
--better-locale-trigger-bg | transparent | Trigger background |
--better-locale-text | #374151 | Text color |
--better-locale-border | #e5e7eb | Menu border + skeleton |
--better-locale-menu-bg | #ffffff | Menu background |
--better-locale-active-bg | #f9fafb | Active item background |
--better-locale-hover-bg | #f3f4f6 | Hovered item background |
--better-locale-code-text | #9ca3af | Locale code + globe icon color |
--better-locale-accent | currentColor | Checkmark color |
/* Dark theme example */
.dark [data-better-locale-dropdown] {
--better-locale-text: #e5e7eb;
--better-locale-border: #374151;
--better-locale-menu-bg: #1f2937;
--better-locale-active-bg: #374151;
--better-locale-hover-bg: #2d3748;
--better-locale-code-text: #6b7280;
}Data Attributes #
Use these for CSS targeting with the unstyled variant:
| Attribute | Element | Description |
|---|---|---|
data-better-locale-dropdown | Root <div> | Always present |
data-better-locale-trigger | Trigger <button> | Always present |
data-better-locale-menu | <ul> | Present when open |
data-better-locale-item | Each <li> | Every menu item |
data-active | <li> | Current locale item |
data-focused | <li> | Keyboard-focused item |
Custom Rendering #
renderTrigger
Replaces the trigger button content. The wrapper still handles click, keyboard, and ARIA:
<LocaleDropdown
renderTrigger={({ language, isOpen, isLoading, flag, label }) => (
<div className={`my-trigger ${isOpen ? 'open' : ''}`}>
{isLoading ? (
<span>Loading...</span>
) : (
<>
{flag.type === 'emoji' && <span>{flag.emoji}</span>}
<span>{label}</span>
</>
)}
</div>
)}
/>renderItem
Replaces each item's content. The <li> still handles click, role="option", and data attributes:
<LocaleDropdown
renderItem={({ language, isActive, flag, label }) => (
<div className={`flex items-center gap-2 ${isActive ? 'font-bold' : ''}`}>
{flag.type === 'emoji' && <span>{flag.emoji}</span>}
<span>{label}</span>
{isActive && <span>✓</span>}
</div>
)}
/>Expo (useLocaleSwitcher) #
On React Native, use the useLocaleSwitcher hook instead of the dropdown component:
import { useLocaleSwitcher } from '@better-i18n/expo'
function LanguagePicker() {
const { languages, currentLocale, switchLocale, isLoading } = useLocaleSwitcher()
return (
<FlatList
data={languages}
renderItem={({ item }) => (
<Pressable onPress={() => switchLocale(item.code)}>
<Text style={item.code === currentLocale ? styles.active : undefined}>
{item.nativeName}
</Text>
</Pressable>
)}
/>
)
}Keyboard Navigation #
The dropdown supports full keyboard navigation:
| Key | Action |
|---|---|
↓ / Enter / Space | Open menu, focus first item |
↓ / ↑ | Navigate items (wraps around) |
Home / End | Jump to first / last item |
Enter / Space | Select focused item |
Escape | Close menu |
Related #
- Provider - Configure locale handling
- Translations - Access translations
- TanStack Routing - Path-based routing setup
Better I18N