Locale Management
useLocale, useLocaleRouter, useLanguages, and LanguageSwitcher
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:
- ❌ URL not updated
- ❌ Loaders don't re-execute
- ❌ Browser history not updated
useLocaleRouter solves these:
- ✅ Triggers proper SPA navigation
- ✅ Re-executes loaders (fresh messages)
- ✅ Updates URL correctly
- ✅ 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 |
Use useLocaleRouter for TanStack Start/Router apps. Use useLocale for simple Vite apps without router integration.
useLanguages
Fetch available languages dynamically from the CDN manifest:
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"No code change needed when you add a language from the dashboard — useLanguages always reflects the current CDN manifest.
Avoid Static Language Arrays
A common mistake is maintaining a hardcoded list for validation or locale detection:
const SUPPORTED = ['tr', 'en', 'ar', 'de']
if (!SUPPORTED.includes(lang)) return 'en'Use CDN languages instead:
const { languages } = await i18nReady
const supported = languages.map(l => l.code)
if (!supported.includes(lang)) return 'en'Hardcoded lists break silently — adding a language in the dashboard works everywhere except wherever the static array was used.
LanguageSwitcher Component
A ready-to-use language selector component:
import { LanguageSwitcher } from '@better-i18n/use-intl'
function Header() {
return (
<header>
<nav>
<Logo />
<LanguageSwitcher />
</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
"
/>Building Custom Switchers
For more control, build your own using the hooks:
Button Group
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.
Live data from CDN — better-i18n/landing project
Installation
bun add @better-i18n/use-intlbun add @better-i18n/nextbun add @better-i18n/remixBasic Usage
Zero-config — reads locale and languages from context automatically:
import { 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.
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>
);
}Import from @better-i18n/next/client — this is the RSC-safe client entrypoint. No transpilePackages needed.
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
Includes all inline styles, skeleton loading animation, and CSS custom property support. Drop-in ready:
<LocaleDropdown />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