Better I18NBetter I18N

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

PropertyTypeDescription
localestringCurrent locale code (e.g., "en", "tr")
setLocale(locale: string) => voidFunction to change the locale
isLoadingbooleanWhether new messages are being loaded

Locale Switching Flow

When you call setLocale(), the provider:

  1. Fetches new messages from the CDN
  2. Updates the React context
  3. Triggers re-render with new translations
  4. Calls onLocaleChange callback (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

PropertyTypeDescription
localestringCurrent locale from URL
localesstring[]Available locale codes from CDN manifest
defaultLocalestringDefault locale (no URL prefix)
navigate(locale: string) => voidNavigate to same page with new locale
localePath(path: string, locale?: string) => stringGet localized path
isReadybooleanWhether languages are loaded from CDN

URL Strategy

The default locale has no prefix in the URL:

LocaleURL
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

FeatureuseLocaleuseLocaleRouter
State updateReact context onlyRouter navigation
URL updateManualAutomatic
Loader re-executionNoYes
History updateNoYes
Use caseSimple CSR appsTanStack 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

PropertyTypeDescription
languagesLanguageOption[]Array of available languages
isLoadingbooleanWhether 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

PropTypeDefaultDescription
classNamestringCSS class for the select element
loadingLabelstring"Loading..."Text shown while loading
renderOption(lang: LanguageOption) => ReactNodeCustom 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-intl
bun add @better-i18n/next
bun add @better-i18n/remix

Basic 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

PropTypeDefaultDescription
variant"styled" | "unstyled""styled"Styled includes all inline styles + CSS custom properties. Unstyled gives full control.
classNamestringCSS class for root wrapper
triggerClassNamestringCSS class for trigger button
menuClassNamestringCSS class for dropdown menu
showFlagbooleantrueShow flag emoji/image
showNativeNamebooleantrueShow native language name (e.g., "Türkçe")
showLocaleCodebooleantrueShow locale code (e.g., "TR")
renderTrigger(ctx) => ReactNodeCustom trigger renderer
renderItem(ctx) => ReactNodeCustom item renderer

Next.js only:

PropTypeDefaultDescription
configI18nConfigRequiredConfig from createI18n()
localestringRequiredCurrent locale
languagesLanguageOption[]Pre-fetched languages (skips client CDN fetch)

Remix only:

PropTypeDefaultDescription
defaultLocalestring"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:

PropertyDefaultDescription
--better-locale-trigger-padding6px 10pxTrigger padding
--better-locale-trigger-radius8pxTrigger border-radius
--better-locale-trigger-border1px solid transparentTrigger border
--better-locale-trigger-bgtransparentTrigger background
--better-locale-text#374151Text color
--better-locale-border#e5e7ebMenu border + skeleton
--better-locale-menu-bg#ffffffMenu background
--better-locale-active-bg#f9fafbActive item background
--better-locale-hover-bg#f3f4f6Hovered item background
--better-locale-code-text#9ca3afLocale code + globe icon color
--better-locale-accentcurrentColorCheckmark 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:

AttributeElementDescription
data-better-locale-dropdownRoot <div>Always present
data-better-locale-triggerTrigger <button>Always present
data-better-locale-menu<ul>Present when open
data-better-locale-itemEach <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:

KeyAction
/ Enter / SpaceOpen menu, focus first item
/ Navigate items (wraps around)
Home / EndJump to first / last item
Enter / SpaceSelect focused item
EscapeClose menu

On this page