Recipe: Multi-locale storefront
This guide covers three common scenarios:
- Single shop, multiple languages (i18n storefront with a single catalog).
- Multiple shops, one per market (different catalog / pricing / stock per country).
- Subdomains or sub-paths (
/es,/en,de.example.com, etc.).
How the widget detects the locale
The widget applies this cascade (highest priority on top):
data-locale="es"attribute on the<script>.- URL parameter
?lang=es. <html lang="es">of the document.navigator.languagefrom the browser.- Backend default (resolved by
LocaleHeaderFilterin the API).
Citation: detection cascade described in
Widget · i18nand backed byLocaleHeaderFilterin the backend (see memoryproject_i18n_auto_detect).
Supported languages
The widget renders UI in at least: es, en, de, fr, it, pt. For unsupported languages, the widget falls back to en and the backend still generates responses in the user's language.
Scenario 1. One shop, multiple languages
If your catalog is single and only the UI changes, set data-locale per render:
<script
src="https://cdn.neuroon.ai/widget@0.9.10/widget.js"
data-token="<%= widgetToken %>"
data-container="#neuroon-search"
data-locale="<%= currentLocale %>" async></script>
Clarifications:
- The Neuroon catalog supports descriptions / names in a single language per product. If your product has
namein Spanish, the widget can translate UI copy on the fly but not the product content. - For storefronts with catalogs translated product by product, use scenario 2 with one shop per language.
Scenario 2. Multi-shop per market
When pricing, stock or catalog vary by country, the natural path is one shop per market, each with its own Shop API Key and Widget Token:
| Market | shopId | UI locale | Catalog |
|---|---|---|---|
| ES | shop_es_xxx | es | EUR prices, ES descriptions |
| EN | shop_en_xxx | en | EUR/GBP prices, EN descriptions |
| DE | shop_de_xxx | de | EUR prices, DE descriptions |
Your backend resolves which shopId (and therefore which token) to use on each render:
const shopByLocale = {
es: process.env.NEUROON_SHOP_ID_ES,
en: process.env.NEUROON_SHOP_ID_EN,
de: process.env.NEUROON_SHOP_ID_DE,
};
async function getWidgetTokenForLocale(locale: string) {
const shopId = shopByLocale[locale] ?? shopByLocale.en;
const apiKey = process.env[`NEUROON_API_KEY_${locale.toUpperCase()}`];
const res = await fetch(
`${process.env.NEUROON_API_URL}/api/shops/${shopId}/widget-token`,
{ method: 'POST', headers: { 'X-Shop-API-Key': apiKey } },
);
if (!res.ok) throw new Error('Token issuance failed');
return (await res.json()).token;
}
Each shop is synced separately with its own cron (/products/sync).
Scenario 3. URL routing per locale
Path-based (/es/, /en/)
// Next.js i18n
export default async function Layout({
params: { locale },
children,
}: { params: { locale: string }; children: React.ReactNode }) {
const token = await getWidgetTokenForLocale(locale);
return (
<html lang={locale}>
<body>
{children}
<Script
src="https://cdn.neuroon.ai/widget@0.9.10/widget.js"
strategy="afterInteractive"
data-token={token}
data-container="#neuroon-search"
data-locale={locale}
/>
</body>
</html>
);
}
Sub-domain (es.example.com, de.example.com)
function detectLocale(host: string): string {
const sub = host.split('.')[0];
return ['es', 'en', 'de', 'fr', 'it', 'pt'].includes(sub) ? sub : 'en';
}
Then use detectLocale(req.headers.host) to resolve the locale on every request.
Override via URL parameter
For QA and A/B testing it is useful to force the locale via ?lang=:
// The widget already detects ?lang=es from the URL, no extra code needed.
// To persist the preference:
const url = new URL(window.location);
const lang = url.searchParams.get('lang');
if (lang) localStorage.setItem('neuroon-locale', lang);
Multi-locale cart bridge
The cart bridge does not change between scenarios. The payload only contains product identifiers and aggregates; the currency is defined by the shop, not by the UI locale.
Conversion tracking
In multi-shop, track:conversion must go to the correct shopId:
const shopId = resolveShopFromOrder(order); // same criteria as in the frontend
const apiKey = resolveApiKey(shopId);
await fetch(`${API_URL}/api/plugin/shops/${shopId}/track/conversion`, {
method: 'POST',
headers: { 'X-Shop-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
Best practices
- One Widget Token per shop, not per locale. For scenario 1 (single shop, multiple languages), use the same token and just change
data-locale. - Cache per shop, not per user. Tokens are per-shop and support thousands of simultaneous impressions.
- Canonical URL aliases. Verify the canonical domain (
example.comorwww.example.com) and use the same one across all calls. - Pricing: if your product has different prices per market and you use a single shop, the only clean way is to duplicate the product with different
externalId(SKU-001-ES,SKU-001-EN). In practice, one shop per market is usually preferable.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
The widget starts in en even though the site is in es | Missing <html lang> or overridden by a previous script | Set explicit data-locale="es" in the script. |
| Results with mixed descriptions (ES + EN) | Single catalog with products synced in different languages | Normalize to a single language per product or use multi-shop. |
| Conversions attributed to the wrong shop | Backend uses a global shopId instead of resolving per order | Pass shopId from the order context. |
Next steps
- Widget · i18n — supported locales and string customization.
plugins/custom/server-to-server— multi-shop server-to-server.- Recipe · Server-to-server token.