This project uses i18next with remix-i18next for internationalization. The implementation follows a dual-instance architecture with server-side and client-side i18next instances.
For React Components:
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation('product');
return <h1>{t('title')}</h1>;
}For Everything Else (loaders, actions, utilities, helpers, tests):
import { getTranslation } from '@/lib/i18next';
// Client-side or non-component code
const { t } = getTranslation();
const message = t('product:title');
// Server-side (loaders/actions) - pass the context
export function loader(args: LoaderFunctionArgs) {
const { t } = getTranslation(args.context);
return { title: t('product:title') };
}We maintain 2 separate instances of i18next:
- Server-side instance: Has access to all translations for the entire site
- Client-side instance: Dynamically imports translations as static JavaScript chunks
Both instances support dynamic language switching at runtime without page reloads.
- Server-side middleware detects the user locale and initializes i18next
- Server has access to all translations from all locales and renders SSR content with translations
- Client-side initializes its own i18next instance, reading the language from the HTML
langattribute to prevent hydration mismatches- The
initI18next()function inroot.tsxaccepts an optional{ language }parameter to ensure consistency between server and client
- The
- When a translation is first requested, the client dynamically imports ALL translations for the current language
- This triggers an HTTP request for a JavaScript chunk (e.g.,
/assets/locales-en-[hash].js) - The chunk is served as a static asset (pre-built, minified, and cached with long-term headers)
- Much more efficient than an API endpoint: no server processing, CDN-friendly, immutable caching
- This triggers an HTTP request for a JavaScript chunk (e.g.,
- All namespaces for that language are loaded and cached in memory
- Subsequent translation requests use the cached data (no additional requests)
- When users switch languages, the client loads the new language's translations dynamically (if not already cached) and updates the UI immediately
Languages and currencies are configured in multiple places that must be kept in sync:
1. config.server.ts - Application-level configuration:
site: {
locale: 'en-US',
currency: 'USD',
supportedLocales: [
{
id: 'en-US',
preferredCurrency: 'USD',
},
{
id: 'es-MX',
preferredCurrency: 'MXN',
},
// Add more locales here...
],
// Currencies that users can manually select
supportedCurrencies: ['MXN', 'USD'],
},
i18n: {
fallbackLng: 'en-US',
supportedLngs: ['es-MX', 'en-US'], // Your supported languages
}2. src/middlewares/i18next.server.ts - i18next middleware configuration:
detection: {
cookie: localeCookie,
fallbackLanguage: 'en-US',
supportedLanguages: ['es-MX', 'en-US'], // Must match config.server.ts
}
⚠️ IMPORTANT: These configurations must be kept in sync:
- The locales in
i18n.supportedLngsshould match theidvalues insite.supportedLocales- The
supportedLanguagesin the middleware should match both arrays above- Each locale in
site.supportedLocalesshould have apreferredCurrencythat matches one of thesite.supportedCurrencies- If you add a new language, update all three places
- If the arrays don't match, you may get partial translations or locale/currency mismatches
Currency System:
The application supports independent locale and currency switching:
- Locale-based currency: Each locale in
supportedLocaleshas apreferredCurrencythat's used by default - Manual currency selection: Users can manually select any currency from
supportedCurrencies, which takes precedence over the locale's preferred currency - Currency priority: User's manual selection (cookie) → Locale's preferred currency → Default site currency
See the Currency Switcher component in src/components/currency-switcher/ for the implementation.
The middleware automatically detects the user's locale from:
- The
lngcookie (if previously set) - The
Accept-LanguageHTTP header - Falls back to the configured
fallbackLng
Users can switch languages dynamically without reloading the page using the LocaleSwitcher component. The language change happens in two steps:
- Client-side update: Immediately changes the displayed language using i18next's
changeLanguage()method - Server-side persistence: Submits to a server action that sets the
lngcookie to persist the preference across page reloads
Using the LocaleSwitcher Component:
The project includes a pre-built LocaleSwitcher component that you can drop into your UI:
import LocaleSwitcher from '@/components/locale-switcher';
export function Footer() {
return (
<footer>
{/* Other footer content */}
<LocaleSwitcher />
</footer>
);
}Users can manually select a currency independent of their locale using the CurrencySwitcher component. When a new currency is switched:
- Server will submit an server action
- Middlewares (client and server) will run to update latest currency into context
updateBasketis called to SCAPI to update currency accordingly- Loader func will revalidate and update the UI to reflect the selected currency
Using the CurrencySwitcher Component:
import CurrencySwitcher from '@/components/currency-switcher';
import LocaleSwitcher from '@/components/locale-switcher';
export function Footer() {
return (
<footer>
<div>
<h3>Language</h3>
<LocaleSwitcher />
</div>
<div>
<h3>Currency</h3>
<CurrencySwitcher />
</div>
</footer>
);
}Key Points:
- Currency selection is independent of locale
- Manual currency selection takes precedence over locale's preferred currency
- The preference persists across locale changes
- Falls back to locale's preferred currency if no manual selection is made
Building Your Own Language Switcher:
If you need a custom implementation, here's how to implement language switching:
'use client';
import { useTranslation } from 'react-i18next';
import { useFetcher } from 'react-router';
export function MyLanguageSwitcher() {
const { i18n } = useTranslation();
const fetcher = useFetcher();
const handleLanguageChange = async (newLocale: string) => {
// Step 1: Change language client-side for immediate UX
await i18n.changeLanguage(newLocale);
// Step 2: Persist to server cookie for page reloads
const formData = new FormData();
formData.append('locale', newLocale);
void fetcher.submit(formData, {
method: 'POST',
action: '/action/set-locale',
});
};
return (
<select
value={i18n.language}
onChange={(e) => void handleLanguageChange(e.target.value)}
>
<option value="en">English</option>
<option value="es">Spanish</option>
</select>
);
}How It Works:
The /action/set-locale server action (located at src/routes/action.set-locale.ts) receives the POST request and sets the lng cookie using the same cookie object that the middleware uses for detection:
import { data, type ActionFunction } from 'react-router';
import { localeCookie } from '@/middlewares/i18next.server';
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const locale = formData.get('locale') as string;
if (!locale) {
throw new Response('Locale is required', { status: 400 });
}
const cookieHeader = await localeCookie.serialize(locale);
return data(
{ success: true },
{
headers: {
'Set-Cookie': cookieHeader,
},
}
);
};Key Points:
- Language changes are immediate (no page reload required)
- The preference persists across sessions via the
lngcookie - All client-side translations are loaded as static assets (one JavaScript chunk per language)
- Switching languages triggers the dynamic import of the new language's translations if not already loaded
src/locales/
├── index.ts # Exports all language resources
├── en-US/
│ ├── index.ts # Exports English translations
│ └── translations.json # All English translations (namespaced)
└── es-MX/
├── index.ts # Exports Spanish translations
└── translations.json # All Spanish translations (namespaced)
src/extensions/
├── my-extension/
│ └── locales/
│ ├── en/
│ │ └── translations.json # Extension translations (English)
│ └── es/
│ └── translations.json # Extension translations (Spanish)
└── locales/ # Auto-generated (do not edit manually)
├── en/
│ └── index.ts # Aggregated extension translations
└── es/
└── index.ts # Aggregated extension translations
src/components/
└── locale-switcher/
└── index.tsx # Client component for switching languages
src/lib/
├── i18next.ts # getTranslation() utility for non-components
└── i18next.client.ts # Client-side i18next initialization
src/middlewares/
└── i18next.server.ts # Server-side i18next setup and middleware
src/routes/
└── action.set-locale.ts # Server action to persist locale preference
scripts/
└── aggregate-extension-locales.js # Auto-aggregates extension translations
Use the useTranslation hook from react-i18next:
import { useTranslation } from 'react-i18next';
function ProductInfo() {
// Specify the namespace to load
const { t } = useTranslation('product');
// NOTE: without passing in a namespace, the above hook would use `translation` namespace by default.
// Since we don't have such namespace in our translations, the `t('namespace:key')` would still work,
// but its autocomplete would no longer work in your IDE.
return (
<div>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
<button>{t('addToCart')}</button>
</div>
);
}With multiple namespaces:
import { useTranslation } from 'react-i18next';
function ProductPage() {
// Load multiple namespaces at once
const { t } = useTranslation(['home', 'product']);
return (
<div>
<h1>{t('home:title')}</h1>
<p>{t('product:description')}</p>
<button>{t('product:addToCart')}</button>
</div>
);
}With interpolation:
const { t } = useTranslation('cart');
const message = t('itemCount.other', { count: 5 }); // "Cart (5 items)"With pluralization:
const { t } = useTranslation('cart');
const text = t('summary.itemsInCart', { count: 1 }); // "1 item in cart"
const text2 = t('summary.itemsInCart', { count: 3 }); // "3 items in cart"Use the getTranslation utility for tests, utilities, or any non-React code:
import { getTranslation } from '@/lib/i18next';
// In tests
describe('ActionCard', () => {
const { t } = getTranslation();
test('shows edit button', () => {
render(<ActionCard onEdit={vi.fn()} />);
const button = screen.getByRole('button', { name: t('actionCard:edit') });
expect(button).toBeInTheDocument();
});
});
// In utility functions
export function getCountryName(countryCode: string): string {
const { t } = getTranslation();
return t(`countries:${countryCode}.name`, { defaultValue: countryCode });
}
// In form schemas (for Zod error messages)
const schema = z.object({
email: z.string().email(t('error:validation.invalidEmail')),
});Use getTranslation with the context parameter for server-side translations:
import { getTranslation, i18nextContext } from '@/lib/i18next';
import type { LoaderFunctionArgs } from 'react-router';
export function loader(args: LoaderFunctionArgs) {
// Get translations by passing the context
const { t } = getTranslation(args.context);
const translatedTitle = t('product:title');
// Get the current locale for formatting (if needed)
const i18nextData = args.context.get(i18nextContext);
const locale = i18nextData?.getLocale() ?? 'en-US';
const date = new Date().toLocaleDateString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return { translatedTitle, date };
}In actions with error handling:
import type { ActionFunctionArgs } from 'react-router';
import { getTranslation } from '@/lib/i18next';
export async function action(args: ActionFunctionArgs) {
const { t } = getTranslation(args.context);
try {
// ... perform action
return { success: true, message: t('product:addedToCart', { productName: 'Widget' }) };
} catch (error) {
return { success: false, message: t('error:api.unexpectedError') };
}
}All translations are stored in a single JSON file per language with namespace-based organization.
i18next uses the concept of namespaces to organize translations into logical groups. In our implementation, namespaces are simply the top-level keys in each translations.json file. For example, "common", "product", "checkout", and "myNewFeature" are all namespaces that help organize translations by feature or domain.
src/locales/en/translations.json:
{
"common": {
"loading": "Loading",
"product": "the product"
},
"product": {
"title": "Product Details",
"addToCart": "Add to Cart",
"greeting": "Hello, {{name}}!",
"itemCount": {
"zero": "No items",
"one": "{{count}} item",
"other": "{{count}} items"
}
},
"myNewFeature": {
"welcome": "Welcome to the new feature"
}
}src/locales/es/translations.json:
{
"common": {
"loading": "Cargando",
"product": "el producto"
},
"product": {
"title": "Detalles del Producto",
"addToCart": "Agregar al Carrito",
"greeting": "¡Hola, {{name}}!",
"itemCount": {
"zero": "Sin artículos",
"one": "{{count}} artículo",
"other": "{{count}} artículos"
}
},
"myNewFeature": {
"welcome": "Bienvenido a la nueva función"
}
}// In React components
const { t } = useTranslation('myNewFeature');
<p>{t('welcome')}</p>
// In non-component code
const { t } = getTranslation();
const message = t('myNewFeature:welcome');
// Simple translation
<p>{t('title')}</p>
// With interpolation
<p>{t('greeting', { name: 'John' })}</p>
// With pluralization
<p>{t('itemCount', { count: items.length })}</p>Extensions can have their own translation files that are automatically discovered and integrated into the i18n system. This allows extension authors to keep translations co-located with their extension code.
Create translation files within your extension directory following this structure:
src/extensions/
├── my-extension/
│ ├── components/
│ ├── locales/
│ │ ├── en/
│ │ │ └── translations.json
│ │ └── es/
│ │ └── translations.json
│ └── index.ts
Extension translations automatically use the extPascalCase naming convention based on the extension folder name:
store-locator→extStoreLocatorbopis→extBopismy-extension→extMyExtension
This convention prevents namespace collisions between extensions and core application translations.
Important: The locale aggregation script (aggregate-extension-locales.js) is specifically for extension translations only. Main app translations in /src/locales/ are NOT aggregated by this script—they are imported directly.
The script scans two locations to discover all supported locales:
- Main app locales:
/src/locales/{locale}/ - Extension locales:
/src/extensions/{extension-name}/locales/{locale}/
The script merges locales from both sources and generates extension-only aggregation files under /src/extensions/locales/ for each discovered locale. This means:
- If your main app supports Spanish (
es-MX) but none of your extensions have Spanish translations, an empty aggregation file is still generated fores-MX - If an extension provides translations for a locale not in the main app, those translations are still aggregated (though the main app won't use them unless configured)
- Extensions without a
localesfolder are automatically skipped - no error is thrown
Example scenario:
- Main app:
en-US,es-MX,fr-FRtranslations - Extension A:
en-US,es-MXtranslations - Extension B:
en-UStranslations only - Extension C: No
localesfolder
Result: Extension aggregation files generated in /src/extensions/locales/ for en-US, es-MX, and fr-FR:
en-US/index.ts: Contains Extension A + Extension B translations onlyes-MX/index.ts: Contains Extension A translations onlyfr-FR/index.ts: Empty (no extensions have it)
Note: Main app translations remain in /src/locales/ and are not affected by this aggregation process.
1. Create the translation files:
Create locales/{lang}/translations.json within your extension directory for each supported language.
Example: src/extensions/bopis/locales/en/translations.json
{
"deliveryOptions": {
"title": "Delivery:",
"pickupOrDelivery": {
"shipToAddress": "Ship to Address",
"pickUpInStore": "Pick Up in Store"
}
},
"storePickup": {
"title": "Store Pickup Location",
"viewButton": "View",
"closeButton": "Close"
}
}2. Translations are automatically aggregated:
When you run pnpm dev or pnpm build, the system automatically:
- Discovers all extension translation files
- Aggregates them with the appropriate namespace
- Makes them available to your extension code
No manual configuration is required.
In React Components:
import { useTranslation } from 'react-i18next';
export function DeliveryOptions() {
// Use your extension's namespace
const { t } = useTranslation('extBopis');
return (
<div>
<h3>{t('deliveryOptions.title')}</h3>
<button>{t('deliveryOptions.pickupOrDelivery.pickUpInStore')}</button>
</div>
);
}In Non-Component Code:
import { getTranslation } from '@/lib/i18next';
export function getDeliveryMessage() {
const { t } = getTranslation();
// Use namespace prefix with colon
return t('extBopis:deliveryOptions.title');
}In Route Loaders/Actions:
import { getTranslation } from '@/lib/i18next';
import type { LoaderFunctionArgs } from 'react-router';
export function loader(args: LoaderFunctionArgs) {
const { t } = getTranslation(args.context);
return {
message: t('extBopis:storePickup.title'),
};
}- Namespace by Route/Feature: Organize translations by feature area (e.g.,
product,checkout,account) - Use the Right Tool:
- React components: Use
useTranslation()hook - Everything else: Use
getTranslation()function- Non-component code (tests, utilities, schemas):
getTranslation() - Server-side loaders/actions:
getTranslation(context)
- Non-component code (tests, utilities, schemas):
- React components: Use
- Use TypeScript: The project includes type-safe translations based on the English locale
- Interpolation: Use
{{variable}}syntax in translation strings (not{variable}) - Pluralization: Use nested objects with
zero,one,otherkeys for count-based translations - Lazy Loading: Client-side translations are loaded on-demand when first requested
- Fallback Chain: Missing translations fall back to the configured
fallbackLng(English)
The project is configured for type-safe translations. TypeScript will autocomplete available keys and warn about missing translations:
// ✅ TypeScript knows these keys exist
const { t } = useTranslation('product');
t('title');
t('addToCart');
// With namespace prefix in non-component code
const { t } = getTranslation();
t('product:title');
t('cart:empty.title');
// ❌ TypeScript will warn about this
t('nonexistent.key');Type definitions are generated from the English locale (resources.en) in src/middlewares/i18next.server.ts:
declare module 'i18next' {
interface CustomTypeOptions {
resources: typeof resources.en; // Use `en` as source of truth for the types
}
}During the migration to i18next translations in PR #447, a lot of the necessary changes have been done for you. There were some migration gotchas, and here are the important ones that you need to be aware of.
Symptom: Validation messages show as keys (e.g., checkout:contactInfo.emailRequired) instead of translated text.
Root Cause: Zod schemas created at module load time execute before i18next initializes in RSC apps, where client-side i18next initialization is separate from server-side.
Convert module-level schema exports to factory functions that accept t:
Before:
import uiStrings from '@/temp-ui-string';
export const contactInfoSchema = z.object({
email: z
.string()
.min(1, uiStrings.checkout.contactInfo.emailRequired)
.email(uiStrings.checkout.contactInfo.emailInvalid),
});After:
import type { TFunction } from 'i18next';
export const createContactInfoSchema = (t: TFunction) => {
return z.object({
email: z.string().min(1, t('checkout:contactInfo.emailRequired')).email(t('checkout:contactInfo.emailInvalid')),
});
};Usage in Components:
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { createContactInfoSchema } from '@/lib/checkout-schemas';
function ContactForm() {
const { t } = useTranslation();
const schema = useMemo(() => createContactInfoSchema(t), [t]);
const form = useForm({
resolver: zodResolver(schema),
// ...
});
}Key Points:
- Use
useMemoto avoid recreating schema on every render - Add
[t]to dependency array - Factory pattern ensures
t()is called at runtime, not module load time
Symptom: Tests or Storybook fail with this error:
Caused by: Error: [vitest] No "createCookie" export is defined on the "react-router" mock.
Did you forget to return it from "vi.mock"?
Root Cause: The i18next middleware depends on createCookie from react-router, but in test environments (Vitest, Storybook), react-router may not be fully available or needs to be mocked.
Make sure your own mock of react-router includes createCookie. For example, in this file:
vi.mock('react-router', () => ({
createCookie: (name: string) => ({
name,
parse: () => null,
serialize: () => '',
}),
createContext: vi.fn().mockImplementation(() => ({})),
...
}))Before (flat structure):
uiStrings.checkout.shippingAddress.title;After (namespace:key):
const { t } = getTranslation();
t('checkout:shippingAddress.title');
// or with explicit namespace. You can pass in a namespace to the useTranslation hook.
const { t } = useTranslation('checkout');
t('shippingAddress.title');Namespace Guidelines:
- Use colon separator:
namespace:key.path - Group related translations by feature/domain
- Common namespaces:
common,errors,validation,product,checkout,customer, etc.