React Router
Integrate Better i18n with React Router
Sync locale with URL parameters using React Router.
Using @better-i18n/vite plugin? Jump to With Vite Plugin for the recommended zero-config approach.
With Vite Plugin
When using @better-i18n/vite, the plugin injects all translation data automatically. The provider reads it with zero props. You just need a small LocaleSync component to bridge locale state with React Router's navigation.
Architecture
BrowserRouter
└── BetterI18nProvider (reads SSR data from Vite plugin — no props)
├── LocaleSync (syncs locale state → URL via react-router-dom navigate)
├── Header / LocaleDropdown
└── Routes
└── /:locale/*Full Example
import { useEffect } from "react";
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from "react-router-dom";
import { BetterI18nProvider, useLocale, useTranslations, LocaleDropdown } from "@better-i18n/use-intl";
/**
* Syncs locale state → URL using react-router-dom's navigate.
* When setLocale("tr") is called (e.g., from LocaleDropdown),
* this component detects the change and navigates to /tr/...
*/
function LocaleSync() {
const { locale } = useLocale();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const segments = location.pathname.split("/").filter(Boolean);
const first = segments[0];
if (first && /^[a-z]{2}$/i.test(first) && first !== locale) {
segments[0] = locale;
navigate("/" + segments.join("/"), { replace: true });
}
}, [locale, location.pathname, navigate]);
return null;
}
function LocaleRedirect() {
const { locale } = useLocale();
return <Navigate to={`/${locale}`} replace />;
}
export default function App() {
return (
<BrowserRouter>
<BetterI18nProvider>
<LocaleSync />
<nav>
<LocaleDropdown />
</nav>
<Routes>
<Route path="/" element={<LocaleRedirect />} />
<Route path="/:locale">
<Route index element={<HomePage />} />
<Route path="about" element={<AboutPage />} />
</Route>
</Routes>
</BetterI18nProvider>
</BrowserRouter>
);
}Why LocaleSync? The provider's built-in URL update uses history.replaceState, which doesn't notify React Router. LocaleSync bridges this gap by watching the locale state and calling navigate() from react-router-dom when it changes.
Without Vite Plugin
If you're not using the Vite plugin, pass project and locale as props manually.
URL Structure
/en/about → English
/tr/about → Turkish
/de/about → GermanRouter Configuration
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { BetterI18nProvider } from '@better-i18n/use-intl'
import { i18nConfig } from './i18n.config'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/:locale/*" element={<LocalizedApp />} /> // [!code ++]
<Route path="*" element={<Navigate to={`/${i18nConfig.defaultLocale}`} replace />} /> // [!code ++]
</Routes>
</BrowserRouter>
)
}
export default AppLocalized App Wrapper
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { BetterI18nProvider } from '@better-i18n/use-intl'
import { i18nConfig } from './i18n.config'
import { AppRoutes } from './routes'
export function LocalizedApp() {
const { locale } = useParams()
const navigate = useNavigate()
const location = useLocation()
const handleLocaleChange = (newLocale: string) => {
// Replace locale in current path
const newPath = location.pathname.replace(`/${locale}`, `/${newLocale}`)
navigate(newPath)
}
return (
<BetterI18nProvider
project={i18nConfig.project}
locale={locale || i18nConfig.defaultLocale}
onLocaleChange={handleLocaleChange}
>
<AppRoutes />
</BetterI18nProvider>
)
}Routes
import { Routes, Route } from 'react-router-dom'
import { HomePage } from '../pages/HomePage'
import { AboutPage } from '../pages/AboutPage'
import { Layout } from '../components/Layout'
export function AppRoutes() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="about" element={<AboutPage />} />
</Route>
</Routes>
)
}Language Switcher
Create a switcher that updates the URL:
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { useLanguages } from '@better-i18n/use-intl'
export function LanguageSwitcher() {
const { locale } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { languages, isLoading } = useLanguages()
const handleChange = (newLocale: string) => {
const newPath = location.pathname.replace(`/${locale}`, `/${newLocale}`)
navigate(newPath)
}
if (isLoading) {
return <select disabled><option>Loading...</option></select>
}
return (
<select
value={locale}
onChange={(e) => handleChange(e.target.value)}
className="px-3 py-2 border rounded"
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName}
</option>
))}
</select>
)
}Localized Links
Create a helper component for localized links:
import { Link, useParams } from 'react-router-dom'
interface LocaleLinkProps {
to: string
children: React.ReactNode
className?: string
}
export function LocaleLink({ to, children, className }: LocaleLinkProps) {
const { locale } = useParams()
// Prepend locale to path
const localizedPath = to.startsWith('/')
? `/${locale}${to}`
: `/${locale}/${to}`
return (
<Link to={localizedPath} className={className}>
{children}
</Link>
)
}Usage:
import { LocaleLink } from './components/LocaleLink'
function Navigation() {
return (
<nav>
<LocaleLink to="/">Home</LocaleLink>
<LocaleLink to="/about">About</LocaleLink>
<LocaleLink to="/contact">Contact</LocaleLink>
</nav>
)
}Locale Validation
Validate the locale parameter:
import { useParams, Navigate } from 'react-router-dom'
import { useLanguages } from '@better-i18n/use-intl'
import { i18nConfig } from './i18n.config'
export function LocalizedApp() {
const { locale } = useParams()
const { languages, isLoading } = useLanguages()
// Wait for languages to load
if (isLoading) {
return <LoadingScreen />
}
// Validate locale
const validLocales = languages.map((l) => l.code)
if (locale && !validLocales.includes(locale)) {
return <Navigate to={`/${i18nConfig.defaultLocale}`} replace />
}
return (
<BetterI18nProvider ...>
<AppRoutes />
</BetterI18nProvider>
)
}Default Locale Without Prefix
To hide the prefix for the default locale:
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { i18nConfig } from './i18n.config'
function App() {
return (
<BrowserRouter>
<Routes>
{/* Default locale without prefix */}
<Route
path="/*"
element={<LocalizedApp locale={i18nConfig.defaultLocale} />}
/>
{/* Other locales with prefix */}
<Route path="/:locale/*" element={<LocalizedAppWithParam />} />
</Routes>
</BrowserRouter>
)
}import { useParams, Navigate } from 'react-router-dom'
import { i18nConfig } from './i18n.config'
export function LocalizedAppWithParam() {
const { locale } = useParams()
// Redirect /en/about to /about for default locale
if (locale === i18nConfig.defaultLocale) {
return <Navigate to={location.pathname.replace(`/${locale}`, '')} replace />
}
return <LocalizedApp locale={locale} />
}SEO Considerations
Add hreflang links for search engines:
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLanguages } from '@better-i18n/use-intl'
export function LocaleHead() {
const location = useLocation()
const { languages } = useLanguages()
const baseUrl = 'https://example.com'
// Remove locale prefix from path
const path = location.pathname.replace(/^\/[a-z]{2}/, '')
return (
<Helmet>
<link rel="canonical" href={`${baseUrl}${location.pathname}`} />
{languages.map((lang) => (
<link
key={lang.code}
rel="alternate"
hreflang={lang.code}
href={`${baseUrl}/${lang.code}${path}`}
/>
))}
<link rel="alternate" hreflang="x-default" href={`${baseUrl}${path}`} />
</Helmet>
)
}Related
- Vite Setup - Basic Vite configuration
- useLocale - Locale hook
- useLanguages - Languages hook