Storefront Next uses i18next with remix-i18next to support multiple languages and currencies, with runtime switching that doesn’t require a page reload. A server-side instance handles server-side rendering (SSR) with full translation access, while a client-side instance loads translations as static JavaScript chunks, keeping server and client in sync without hydration mismatches.
Prerequisites
Node.js 24+ and pnpm installed
Project dependencies installed (pnpm install) — i18next and remix-i18next are included
At least one translation file at src/locales/[locale]/translations.json (e.g., src/locales/en-GB/translations.json)
For extension translations: run pnpm dev or pnpm build once to generate the aggregated files under src/extensions/locales/. These files are auto-generated and must not be edited manually.
Quick Start
React components use the useTranslation hook. Everything else uses the getTranslation function.
Template — translations (src/locales/), configuration, type augmentation, root.tsx wiring
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 and Client-side Flow
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 lang attribute to prevent hydration mismatches
The initI18next() function in root.tsx accepts an optional { language } parameter to ensure consistency between server and client
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
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
File Structure
1src/locales/2├── index.ts # Exports all language resources3├── en-GB/4│ ├── index.ts # Exports English translations5│ └── translations.json # All English translations (namespaced)6└── es-MX/7 ├── index.ts # Exports Spanish translations8 └── translations.json # All Spanish translations (namespaced)910src/extensions/11├── my-extension/12│ └── locales/13│ ├── en/14│ │ └── translations.json # Extension translations (English)15│ └── es/16│ └── translations.json # Extension translations (Spanish)17└── locales/ # Auto-generated (do not edit manually)18 ├── en/19 │ └── index.ts # Aggregated extension translations20 └── es/21 └── index.ts # Aggregated extension translations2223src/components/24└── locale-switcher/25 └── index.tsx # Client component for switching languages2627src/middlewares/28└── i18next.server.ts # Thin wrapper around SDK's createI18nMiddleware()2930src/routes/31└── 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 the linter (OxLint no-restricted-imports).
They don’t live in src/lib/ anymore.
Configuration
Supported Languages and Currencies
Languages and currencies are configured in multiple places that must be kept in sync:
2. src/middlewares/i18next.server.ts reads supportedLngs and fallbackLng from config automatically — no additional middleware configuration is needed.
⚠️ IMPORTANT: Keep these configurations in sync. Make sure that:
The locales in i18n.supportedLngs match the id values in site.supportedLocales (single-site) or across all entries in commerce.sites[]. supportedLocales (multi-site).
Each locale in supportedLocales has a preferredCurrency that matches one of the site.supportedCurrencies or site’s supportedCurrencies.
Each locale in i18n.supportedLngs has a corresponding translation directory under src/locales/.
If you add a new language, update both places and create the translation files.
Currency System:
The application 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.
See the Currency Switcher component in src/components/currency-switcher/ for the implementation.
Locale Detection
The middleware automatically detects the user’s locale from:
The lng cookie (if previously set)
The Accept-Language HTTP header
Falls back to the configured fallbackLng
Switching Languages and Currencies at Runtime
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 that you can 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}
Currency Switching
Users can manually select a currency independent of their locale using the CurrencySwitcher component. When a new currency is switched:
Server submits an server action.
Middlewares (client and server) run to update latest currency into context.
updateBasket is called to SCAPI to update currency accordingly.
Loader func will revalidate and update 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
Building Your Own Language Switcher:
If you need a custom implementation, here’s how to implement language switching:
1"use client";23import{useTranslation}from "react-i18next";4import{useFetcher}from "react-router";56export function MyLanguageSwitcher(){7 const{i18n} = useTranslation();8 const fetcher = useFetcher();910 const handleLanguageChange = async(newLocale: string)=>{11 // Step 1: Change language client-side for immediate UX12 await i18n.changeLanguage(newLocale);1314 // Step 2: Persist to server cookie for page reloads15 const formData = new FormData();16 formData.append("locale", newLocale);17 void fetcher.submit(formData, {18 method: "POST",19 action: "/action/set-locale",20});21};2223 return(24<select value={i18n.language}onChange={(e) => void handleLanguageChange(e.target.value)}>25<option value="en">English</option>26<option value="es">Spanish</option>27</select>28);29}
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:
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 would use `translation` namespace by default.7 // Since we don't have such namespace in our translations, the `t('namespace:key')` would still work,8 // but its autocomplete would no longer work 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}
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.
Core app namespaces use the camelCase naming convention, for example checkout or miniCart.
src/locales/en/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/es/translations.json:
1{2 "common": {3 "loading": "Cargando",4 "product": "el producto"5},6 "product": {7 "title": "Detalles del Producto",8 "addToCart": "Agregar al Carrito",9 "greeting": "¡Hola, {{name}}!",10 "itemCount": {11 "zero": "Sin artículos",12 "one": "{{count}} artículo",13 "other": "{{count}} artículos"14}15},16 "myNewFeature": {17 "welcome": "Bienvenido a la nueva función"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. This allows extension authors to keep translations co-located with their extension code.
File Structure for Extensions
Create translation files within your extension directory following 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 application translations.
How Locale Discovery Works
Important: The locale aggregation command (sfnext locales aggregate-extensions) is specifically for extension translations only. Main app translations in /src/locales/ are NOT aggregated by this command—they are imported directly.
The command scans two locations to discover all supported locales:
The command 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 for es-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 locales folder are automatically skipped - no error is thrown
Example scenario:
Main app: en-GB, es-MX, fr-FR translations
Extension A: en-GB, es-MX translations
Extension B: en-GB translations only
Extension C: No locales folder
Result: Extension aggregation files generated in /src/extensions/locales/ for en-GB, es-MX, and fr-FR:
en-GB/index.ts: Contains Extension A + Extension B translations only
es-MX/index.ts: Contains Extension A translations only
fr-FR/index.ts: Empty (no extensions have it)
Note: Main app translations remain in /src/locales/ and are not affected by this aggregation process.
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.
Using Extension Translations
In React Components:
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}
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}
The SDK’s i18n support (@salesforce/storefront-next-runtime/i18n) is built on i18next. If you prefer a different library (for example, next-intl, formatjs, lingui), you can replace the i18n layer entirely:
Skip the SDK’s i18n subpath: Don’t import from @salesforce/storefront-next-runtime/i18n or @salesforce/storefront-next-runtime/i18n/client.
Locale resolution still works: The site-context system (createSiteContextMiddleware, locale detection from URL/cookie/header, SiteProvider) is i18n-library-agnostic and handles determining the active locale.
Write your own middleware: Replace src/middlewares/i18next.server.ts with a middleware that initializes your chosen library, reading the resolved locale from requestToLocaleMap (exported from @salesforce/storefront-next-runtime/site-context).
Write your own client init: Replace the initI18next() call in root.tsx with your library’s initialization.
Bridge to SiteProvider: Pass the current language string to SiteProvider’s language prop (it accepts a plain string, no i18next dependency).
Chunk splitting still works: The Vite i18nPlugin splits any files matching /src/locales/([^/]+)/ into per-language chunks, regardless of i18n library.
The SDK separates locale resolution (which locale is active) from translation (turning keys into strings). Only the translation layer is i18next-specific.
Best Practices
Namespace by Route/Feature: Organize translations by feature area (e.g., 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 (English)
Type Safety
The project is configured for type-safe translations. TypeScript will autocomplete 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 locale (resources['en-GB']) 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}