Internationalization (i18n)
This project uses i18next (with react-i18next and remix-i18next) for internationalization. The implementation follows a dual-instance architecture with server-side and client-side i18next instances. Locale detection is handled by the multisite middleware, which resolves the locale before i18next initializes.
For React components:
For everything else (loaders, actions, utilities, helpers, and tests):
The i18n layer is split between the SDK and the template:
- SDK (
@salesforce/storefront-next-runtime/i18n) — generic infrastructure: middleware factory, context, shared interpolation config - SDK (
@salesforce/storefront-next-runtime/i18n/client) — browser-only client initialization - Template — translations (
src/locales/), configuration, type augmentation, root.tsx wiring
We maintain two 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 run time 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 config.server.ts in two sections that must be kept in sync.
i18n- Translation system configuration:
commerce.sites- Per-site locale and currency configuration:
The i18next middleware reads i18n.fallbackLng and i18n.supportedLngs from the config automatically. You don’t need to configure the middleware separately.
Keep these configurations in sync.
- The locales in
i18n.supportedLngsmust match theidvalues across all entries incommerce.sites[].supportedLocales. - Each locale in
supportedLocaleshas apreferredCurrencythat matches one of the site’ssupportedCurrencies. - Each locale in
i18n.supportedLngsmust have a corresponding translation directory undersrc/locales/. - If you add a new language, update both
i18n.supportedLngsand the relevant site’ssupportedLocales, and create the translation files. - If the arrays don’t match, you can get partial translations or locale/currency mismatches.
Locale detection is handled by the multisite middleware, which runs before i18next initializes. The multisite middleware resolves the locale using a configurable detection chain (by default: URL path, query string, cookie, HTTP header) and passes the resolved locale to i18next via an internal request map. The i18next middleware then initializes with the resolved locale.
If no locale can be resolved from any source, the system falls back to the configured fallbackLng.
For details on how locale detection works and how to customize the detection order, see Configure Site and Locale Detection.
The app supports independent locale and currency switching.
- Locale-based currency: Each locale in
commerce.sites[].supportedLocaleshas apreferredCurrencythat’s used by default. - Manual currency selection: Users can manually select any currency from
commerce.sites[].supportedCurrencies, which takes precedence over the locale’s preferred currency. - Currency priority: User’s manual selection (cookie) → Locale’s preferred currency → Site’s default currency.
See the Currency Switcher component in src/components/currency-switcher/ for the implementation.
Use the useTranslation hook from react-i18next.
With multiple namespaces:
With interpolation:
With pluralization:
Use the getTranslation utility for tests, utilities, or any non-React code.
Use getTranslation with the context parameter for server-side translations.
In actions with error handling:
The i18n utilities (getTranslation, getLocale, mockI18nContext, createI18nMiddleware, initI18next) are provided by the SDK and split across two subpaths:
@salesforce/storefront-next-runtime/i18n— server-capable APIs (getTranslation,getLocale,mockI18nContext,createI18nMiddleware). Safe to import from server modules, route modules, and components.@salesforce/storefront-next-runtime/i18n/client— browser-only APIs (initI18next). This entry pulls ini18next-browser-languagedetector, which has no Node support, so it must only be imported from client-side code (e.g. insideuseEffectinroot.tsx). Importing it from a*.server.tsfile will fail to bundle and is blocked by ESLint.
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-GB/translations.json:
src/locales/it-IT/translations.json:
Extensions can have their own translation files that are automatically discovered and integrated into the i18n system. Extension authors can keep translations co-located with their extension code.
Create translation files within your extension directory using this structure:
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 app translations.
This example shows how to use an extension translation in a React component.
This example shows how to use an extension translation in non-component code.
This example shows how to use an extension translation in route loaders or actions.
The locale aggregation command (sfnext locales aggregate-extensions) is specifically for extension translations only. Main app translations in /src/locales/ aren’t aggregated by this command—they’re 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 Italian (
it-IT) but none of your extensions have Italian translations, an empty aggregation file is still generated forit-IT. - If an extension provides translations for a locale not in the main app, those translations are still aggregated (though the main app doesn’t use them unless configured).
- Extensions without a
localesfolder are automatically skipped—no error is thrown.
Example Scenario:
- Main app:
en-GB,en-US,it-ITtranslations - Extension A:
en-GB,en-UStranslations - Extension B:
en-GBtranslations only - Extension C: No
localesfolder
Result: Extension aggregation files generated in /src/extensions/locales/ for en-GB, en-US, and it-IT:
en-GB/index.ts: Contains Extension A + Extension B translations only.en-US/index.ts: Contains Extension A translations only.it-IT/index.ts: Empty (no extensions have it).
Main app translations remain in /src/locales/ and aren’t 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-US/translations.json
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.
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
The project includes a pre-built LocaleSwitcher component to drop into your UI:
For a custom implementation, here’s how to implement language switching. In a multisite setup, the locale switcher must rebuild the current URL with the new locale prefix and trigger a full page reload to revalidate all loaders.
The /action/set-locale server action, which is located at src/routes/action.set-locale.ts, receives the POST request and sets the locale cookie using the multisite cookie from router context. It then redirects to the provided pathname, which includes the new locale in the URL prefix.
- The client-side language change via
i18n.changeLanguage()provides an immediate UX update. - In a multisite setup, locale switching triggers a full page reload to revalidate all loaders with the new locale and update the URL prefix.
- The preference persists across sessions via the locale cookie (managed by the multisite middleware).
- 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.
Users can manually select a currency independent of their locale using the CurrencySwitcher component. When users switch to a new currency:
- Server submits a server action.
- Middlewares (client and server) run to update the latest currency into context.
- Calls the
updateBasketendpoint in SCAPI to update the currency accordingly. - Loader function revalidates and updates the UI to reflect the selected currency.
- 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
- Namespace by Route/Feature: Organize translations by feature area (for example,
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.
The project is configured for type-safe translations. TypeScript autocompletes available keys and warn about missing translations:
Type definitions are generated from the English (GB) locale. In src/middlewares/i18next.server.ts: