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.
Quick Start Examples
For React components:
1import{useTranslation}from "react-i18next";23function MyComponent(){4 // Get translation function for 'product' namespace - dynamically renders localized text based on user's language5 const{t} = useTranslation("product");6 return<h1>{t("title")}</h1>;7}
For everything else (loaders, actions, utilities, helpers, and tests):
1import{getTranslation}from "@salesforce/storefront-next-runtime/i18n";23// Client-side or non-component code4const{t} = getTranslation();5const message = t("product:title");67// Server-side (loaders/actions) - pass the context8export function loader(args: LoaderFunctionArgs){9 const{t} = getTranslation(args.context);10 return{title: t("product:title")};11}
Architecture Overview
The i18n layer is split between the SDK and the template:
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.supportedLngs must match the id values across all entries in commerce.sites[].supportedLocales.
Each locale in supportedLocales has a preferredCurrency that matches one of the site’s supportedCurrencies.
Each locale in i18n.supportedLngs must have a corresponding translation directory under src/locales/.
If you add a new language, update both i18n.supportedLngs and the relevant site’s supportedLocales, and create the translation files.
If the arrays don’t match, you can get partial translations or locale/currency mismatches.
Important
Locale Detection
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.
The app supports independent locale and currency switching.
Locale-based currency: Each locale in commerce.sites[].supportedLocales has a preferredCurrency that’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.
1import{useTranslation}from "react-i18next";23function ProductInfo(){4 // Specify the namespace to load5 const{t} = useTranslation("product");6 // NOTE: without passing in a namespace, the above hook uses `translation` namespace by default.7 // Since we don't have such namespace in our translations, the `t('namespace:key')` still works,8 // but its autocomplete no longer works in your IDE.910 return(11<div>12<h1>{t("title")}</h1>13<p>{t("description")}</p>14<button>{t("addToCart")}</button>15</div>16);17}
1src/locales/2├── index.ts # Exports all language resources3├── en-GB/4│ ├── index.ts # Exports English (GB) translations5│ └── translations.json # All English (GB) translations (namespaced)6├── en-US/7│ ├── index.ts # Exports English (US) translations8│ └── translations.json # All English (US) translations (namespaced)9└── it-IT/10 ├── index.ts # Exports Italian translations11 └── translations.json # All Italian translations (namespaced)1213src/extensions/14├── my-extension/15│ └── locales/16│ ├── en-GB/17│ │ └── translations.json # Extension translations (English GB)18│ ├── en-US/19│ │ └── translations.json # Extension translations (English US)20│ └── it-IT/21│ └── translations.json # Extension translations (Italian)22└── locales/ # Auto-generated (do not edit manually)23 ├── en-GB/24 │ └── index.ts # Aggregated extension translations25 ├── en-US/26 │ └── index.ts # Aggregated extension translations27 └── it-IT/28 └── index.ts # Aggregated extension translations2930src/components/31└── locale-switcher/32 └── index.tsx # Client component for switching languages3334src/middlewares/35└── i18next.server.ts # Thin wrapper around SDK's createI18nMiddleware()3637src/routes/38└── action.set-locale.ts # Server action to persist locale preference
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 in i18next-browser-languagedetector, which has no Node support, so it must only be imported from client-side code (e.g. inside useEffect in root.tsx). Importing it from a *.server.ts file will fail to bundle and is blocked by ESLint.
Adding New Translations
Approach: Single JSON File Per Language
All translations are stored in a single JSON file per language with namespace-based organization.
Understanding Namespaces
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:
1{2 "common": {3 "loading": "Loading",4 "product": "the product"5},6 "product": {7 "title": "Product Details",8 "addToCart": "Add to Cart",9 "greeting": "Hello, {{name}}!",10 "itemCount": {11 "zero": "No items",12 "one": "{{count}} item",13 "other": "{{count}} items"14}15},16 "myNewFeature": {17 "welcome": "Welcome to the new feature"18}19}
src/locales/it-IT/translations.json:
1{2 "common": {3 "loading": "Caricamento",4 "product": "il prodotto"5},6 "product": {7 "title": "Dettagli del Prodotto",8 "addToCart": "Aggiungi al Carrello",9 "greeting": "Ciao, {{name}}!",10 "itemCount": {11 "zero": "Nessun articolo",12 "one": "{{count}} articolo",13 "other": "{{count}} articoli"14}15},16 "myNewFeature": {17 "welcome": "Benvenuto nella nuova funzione"18}19}
Using Your New Translations
1// In React components2const{t} = useTranslation('myNewFeature');3<p>{t('welcome')}</p>45// In non-component code6const{t} = getTranslation();7const message = t('myNewFeature:welcome');89// Simple translation10<p>{t('title')}</p>1112// With interpolation13<p>{t('greeting', {name: 'John'})}</p>1415// With pluralization16<p>{t('itemCount', {count: items.length})}</p>
Extension Translations
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.
File Structure for Extensions
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 → extStoreLocator
bopis → extBopis
my-extension → extMyExtension
This convention prevents namespace collisions between extensions and core app translations.
Using Extension Translations
This example shows how to use an extension translation in a React component.
1import{useTranslation}from "react-i18next";23export function DeliveryOptions(){4 // Use your extension's namespace5 const{t} = useTranslation("extBopis");67 return(8<div>9<h3>{t("deliveryOptions.title")}</h3>10<button>{t("deliveryOptions.pickupOrDelivery.pickUpInStore")}</button>11</div>12);13}
This example shows how to use an extension translation in non-component code.
1import{getTranslation}from "@salesforce/storefront-next-runtime/i18n";23export function getDeliveryMessage(){4 const{t} = getTranslation();5 // Use namespace prefix with colon6 return t("extBopis:deliveryOptions.title");7}
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.
Important
The script scans two locations to discover all supported locales:
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 for it-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 locales folder are automatically skipped—no error is thrown.
Example Scenario:
Main app: en-GB, en-US, it-IT translations
Extension A: en-GB, en-US translations
Extension B: en-GB translations only
Extension C: No locales folder
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.
Note
Adding Translations to an Extension
1. Create the translation files:
Create locales/{lang}/translations.json within your extension directory for each supported language.
1{2 "deliveryOptions": {3 "title": "Delivery:",4 "pickupOrDelivery": {5 "shipToAddress": "Ship to Address",6 "pickUpInStore": "Pick Up in Store"7}8},9 "storePickup": {10 "title": "Store Pickup Location",11 "viewButton": "View",12 "closeButton": "Close"13}14}
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.
Switching Languages and Currencies at Run Time
Language Switching
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 lng cookie to persist the preference across page reloads
Using the LocaleSwitcher Component
The project includes a pre-built LocaleSwitcher component to drop into your UI:
1import LocaleSwitcher from "@/components/locale-switcher";23export function Footer(){4 return(5<footer>6{/* Other footer content */}7<LocaleSwitcher />8</footer>9);10}
Building Your Own Language Switcher
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.
1"use client";23import{useTranslation}from "react-i18next";4import{useFetcher}from "react-router";5import{6 buildUrl,7 sanitizePrefix,8 resolvePrefix,9}from "@salesforce/storefront-next-runtime/multi-site";10import{useConfig}from "@salesforce/storefront-next-runtime/config";11import{useCurrentSiteAndLocaleRef}from "@/hooks/use-current-site-and-locale-ref";1213export function MyLanguageSwitcher(){14 const{i18n} = useTranslation();15 const fetcher = useFetcher();16 const config = useConfig();17 const{siteRef, localeRef} = useCurrentSiteAndLocaleRef();1819 const handleLanguageChange = async(newLocale: string)=>{20 const newLocaleRef = config.localeAliasMap?.[newLocale] ?? newLocale;2122 // Strip current prefix, rebuild with new locale23 const currentPrefix = config.url?.prefix24 ? resolvePrefix(config.url.prefix, {siteId: siteRef, localeId: localeRef})25 : "";26 const barePath = sanitizePrefix(location.pathname, currentPrefix) || "/";2728 const pathname = buildUrl({29 to: barePath,30 urlConfig: config.url,31 params:{siteId: siteRef, localeId: newLocaleRef},32});3334 // Step 1: Change language client-side for immediate UX35 await i18n.changeLanguage(newLocale);3637 // Step 2: Persist to server cookie and navigate to new locale URL38 const formData = new FormData();39 formData.append("locale", newLocale);40 formData.append("pathname", pathname);41 await fetcher.submit(formData, {42 method: "POST",43 action: "/action/set-locale",44});4546 // Step 3: Full page reload to revalidate all loaders with new locale47 window.location.href = pathname;48};4950 return(51<select value={i18n.language}onChange={(e) => void handleLanguageChange(e.target.value)}>52{config.i18n.supportedLngs.map((locale) =>(53<option key={locale}value={locale}>54{locale}55</option>56))}57</select>58);59}
How It Works
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.
1import{redirect, type ActionFunction}from 'react-router';2import{getMultiSiteCookies}from '@salesforce/storefront-next-runtime/multi-site';34export const action: ActionFunction = async({request, context})=>{5 const formData = await request.formData();6 const locale = formData.get('locale')as string;7 const pathname = formData.get('pathname')as string;89 if(!locale){10 throw new Response('Locale is required', {status: 400});11}1213 const cookies = getMultiSiteCookies(context);14 if(!cookies){15 throw new Response('Site and locale cookies were not initialized', {status: 500});16}1718 const cookieHeader = await cookies.localeCookie.serialize(locale);1920 return redirect(pathname || '/', {21 headers:{22 'Set-Cookie': cookieHeader,23},24});25};
Key Points
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.
Currency Switching
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 updateBasket endpoint in SCAPI to update the currency accordingly.
Loader function revalidates and updates the UI to reflect the selected currency.
Using the CurrencySwitcher Component
1import CurrencySwitcher from "@/components/currency-switcher";2import LocaleSwitcher from "@/components/locale-switcher";34export function Footer(){5 return(6<footer>7<div>8<h3>Language</h3>9<LocaleSwitcher />10</div>11<div>12<h3>Currency</h3>13<CurrencySwitcher />14</div>15</footer>16);17}
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
Best Practices
Namespace by Route/Feature: Organize translations by feature area (for example, product, checkout, account).
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, other keys 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.
Type Safety
The project is configured for type-safe translations. TypeScript autocompletes available keys and warn about missing translations:
1// ✅ TypeScript knows these keys exist2const{t} = useTranslation("product");3t("title");4t("addToCart");56// With namespace prefix in non-component code7const{t} = getTranslation();8t("product:title");9t("cart:empty.title");1011// ❌ TypeScript will warn about this12t("nonexistent.key");
Type definitions are generated from the English (GB) locale. In src/middlewares/i18next.server.ts:
1declare module "i18next"{2 interface CustomTypeOptions{3 resources: typeof resources["en-GB"]; // Use `en-GB` as source of truth for the types4}5}