Multi-Language Stores
EverShop serves multiple languages from one running process. Enabling a language does not change the bundle, so there is no rebuild — the enabled list is read from the database, the dictionaries are already in memory, and every request picks its locale on the way in.
This page covers the routing and resolution half of localization: which languages are enabled, how a request gets a locale, how URLs are prefixed, and what a theme can read. For authoring the dictionaries themselves, see Translation.
The enabled-languages model
Three settings describe a store's languages:
| Setting key | Meaning | Default |
|---|---|---|
storeLanguage | The storefront's default language. Served unprefixed. | Falls back to shop.language config, then en |
storeLanguages | The additional storefront languages, as a JSON array of locale codes. | [] |
adminLanguage | The language the admin panel renders in. Independent of the storefront. | en |
The getters live in modules/setting/services/setting.ts and are exported from @evershop/evershop/setting/services:
import {
getStoreLanguage,
getEnabledLanguages,
getAdditionalLanguages,
getAdminLanguage
} from '@evershop/evershop/setting/services';
const defaultLocale = await getStoreLanguage(); // 'en'
const enabled = await getEnabledLanguages(); // ['en', 'de', 'fr']
const additional = await getAdditionalLanguages();// ['de', 'fr']
const adminLocale = await getAdminLanguage(); // 'en'
All four are async — they read the setting cache, which is DB-backed.
getEnabledLanguages() composition rules
getEnabledLanguages() is the single source of truth for "which locales may this store serve". It is the default plus the additional list, run through the pure helper mergeEnabledLocales(defaultLocale, list) in lib/locale/localeResolution.ts:
- Every code is trimmed and lower-cased; empty or non-string entries are dropped.
- The default locale is always first, and always present regardless of whether it also appears in
storeLanguages. - The result is deduplicated.
- A missing, empty, or non-array
storeLanguagescollapses to[defaultLocale]— a half-seeded store behaves as single-language rather than erroring.
mergeEnabledLocales('en', ['DE', 'fr', 'en', '']); // ['en', 'de', 'fr']
mergeEnabledLocales('en', 'not-an-array'); // ['en']
getAdditionalLanguages() is derived — getEnabledLanguages() minus the default — so a legacy storeLanguages row that happens to contain the default never produces a duplicate.
The "Additional languages" control is commented out in the 2.2.1 admin store-settings screen (modules/setting/pages/admin/storeSetting/StoreSetting.tsx). Only storeLanguage and adminLanguage are editable there.
storeLanguages is therefore currently settable only through the settings API:
curl -X POST https://yourstore.com/api/settings \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <admin-access-token>" \
-d '{"storeLanguages": ["de", "fr"]}'
Everything downstream of the setting — resolution, prefixing, the switcher, hreflang, the sitemap — works as documented once the row exists. Do not build merchant instructions around picking languages in the admin UI until the control ships.
Saving the setting logs a warning (it does not block) when an additional locale has no translations/<locale>/ folder — the store will serve that locale with English fallbacks.
URL shape
The default language is served unprefixed; every additional language is served under a /<locale> path prefix, with the same slugs:
/ → default locale, home
/shoes → default locale
/de → German home
/de/shoes → German, same product/category slug
/admin/products → admin (never prefixed)
/api/products → REST API (never prefixed)
A first path segment is only treated as a locale prefix when it is an enabled, non-default locale. Anything else — including the default locale's own code — falls through to normal route matching, which is what keeps /en/... from becoming a duplicate of /.
Because a locale code can shadow a real path, EverShop rejects a url_key equal to an enabled locale code (modules/base/services/assertUrlKeyAvailable.ts). You cannot create a category with the URL key de while German is enabled.
Locale resolution order
One app-level middleware in bin/lib/addDefaultMiddlewareFuncs.ts resolves the locale. It runs after the cookie parser and before route matching, and it branches on the raw path in this order:
| # | Path | Locale comes from | Context |
|---|---|---|---|
| 1 | /api/admin… | getAdminLanguage() | isAdmin: true, available: [locale] |
| 2 | /api and /api/… | the X-Locale request header | isAdmin: false, available: getEnabledLanguages() |
| 3 | /admin and /admin/… | getAdminLanguage() | isAdmin: true, available: [locale] |
| 4 | everything else (storefront) | the first path segment | isAdmin: false, available: getEnabledLanguages() |
Storefront — the path segment
const { locale, isPrefixed } = pickStorefrontLocale(
fullPath.split('/')[1],
enabled,
defaultLocale
);
pickStorefrontLocale returns the segment only when it is enabled and not the default; otherwise it returns { locale: defaultLocale, isPrefixed: false }.
REST API — the X-Locale header
API routes are RESTful and unprefixed, so the locale travels in a header:
curl https://yourstore.com/api/products/123 -H "X-Locale: de"
pickApiLocale honors the header only when the value is one of the enabled locales. An unknown, disabled, or arbitrary value silently falls back to the store default — a client cannot request a language you have not enabled.
You rarely set this header by hand from a storefront page. EverShop injects a small same-origin window.fetch wrapper (FETCH_LOCALE_PATCH) that adds X-Locale from window.eContext.locale to every same-origin request that does not already carry one, so fetch() calls and the GraphQL client inherit the page's locale automatically.
Admin — adminLanguage
Both /admin/* and /api/admin/* resolve to getAdminLanguage() with isAdmin: true, which disables all URL prefixing for the request. Admin language is deliberately decoupled from the storefront: a German-facing store can be administered in English.
Admin REST endpoints declare bare paths (/api/products/:id, not /api/admin/products/:id), so in practice they fall into branch 2 and resolve via X-Locale. This is an accepted edge case — those endpoints rarely render translated text, and admin route URLs skip prefixing anyway via route.isAdmin.
The whole middleware short-circuits when NODE_ENV=test. Under the test runner request.locale and request.localePath are undefined and there is no locale scope, so translate() falls back to the configured default language. Integration tests that assert on localized output must set the locale explicitly.
request.locale and request.localePath
The middleware sets two fields on the request (declared in @evershop/evershop/types/request):
export interface EvershopRequest extends ExpressRequest {
/** Resolved request locale, set by the locale middleware. */
locale?: string;
/** Canonical request path with the locale prefix stripped. */
localePath?: string;
}
| Request | originalUrl | localePath | locale |
|---|---|---|---|
/shoes | /shoes | /shoes | en |
/de/shoes | /de/shoes | /shoes | de |
/admin/products | /admin/products | /admin/products | en |
/api/products | /api/products | not set | from X-Locale |
This split is the core of the design. Route matching and the url_rewrite lookup both use the prefix-stripped localePath, which is why one route definition and one URL rewrite serve every language — you never register /de/... variants. Meanwhile request.originalUrl is never mutated, so it stays prefixed and remains the canonical, SEO-correct URL for head tags and redirects.
Both fields are optional, so consumers fall back defensively:
const path = request.localePath ?? request.originalUrl.split('?')[0];
Use the same pattern in your own middleware. Note that /api/* branches set locale but not localePath.
The locale context (AsyncLocalStorage)
Passing a locale down through services, GraphQL resolvers, and email builders by hand would mean threading an argument through everything. Instead the middleware wraps the rest of the request in an AsyncLocalStorage scope, so any code the request transitively calls can read the active locale without receiving it.
return runWithLocale(
{
locale,
defaultLocale,
available: enabled,
dict: getDictionary(locale),
isAdmin: false
},
() => next()
);
The accessors are in @evershop/evershop/lib/locale/localeContext and are server-only — they import node:async_hooks and must never reach a client bundle.
| Function | Returns |
|---|---|
runWithLocale(ctx, fn) | Runs fn with ctx visible to all sync and async code it calls. |
getLocaleContext() | The full context, or undefined outside any scope. |
getActiveLocale() | The active locale code, falling back to the configured store language off-request. |
getRequestDictionary() | The full dictionary for the request. |
localizeUrl(url) | Applies the request's locale prefix to an already-built URL string. |
The LocaleContext shape:
interface LocaleContext {
locale: string;
defaultLocale: string;
available: string[];
dict: Record<string, string>;
isAdmin: boolean;
}
How translate() and resolvers see it
translate() reads getLocaleContext()?.dict when no explicit locale argument is given — that is the entire mechanism by which a server-side string comes out in the request's language. A GraphQL resolver is inside the same scope, so it can call getActiveLocale() or localizeUrl() with no plumbing.
Off-request callers — cron jobs, event subscribers, queue workers — run outside any scope. There getLocaleContext() is undefined, getActiveLocale() falls back to the configured store language, and translate() uses the default dictionary. When an off-request job must produce output in a specific language (an order-confirmation email, say), pass the locale explicitly as translate()'s third argument. See Translation.
Building localized URLs
There is one prefixing primitive, applyLocalePrefix, and everything else routes through it. It returns the URL unchanged when any of these hold:
- there is no locale context;
- the target route is an admin route (
route.isAdmin); - the current request is an admin request (
ctx.isAdmin); - the active locale is the default locale;
- the path is
/apior starts with/api/.
Otherwise it prepends /<locale>. The site root is normalized to /<locale> rather than /<locale>/, so the switcher and the route matcher agree on one canonical home path.
buildUrl — for route-based links
import { buildUrl, buildAbsoluteUrl } from '@evershop/evershop/lib/router';
// Params must match the route's `:placeholders`. `productView` is /product/:uuid,
// so it takes `uuid` — the pretty slug is applied later by `url_rewrite`, never here.
buildUrl('productView', { uuid: product.uuid });
// default locale → /product/<uuid>
// German request → /de/product/<uuid>
buildAbsoluteUrl('productView', { uuid: product.uuid });
// → https://yourstore.com/de/product/<uuid>
// Routes that genuinely take a url_key are the CMS and landing pages:
buildUrl('cmsPageView', { url_key: 'about-us' });
// → /page/about-us
buildUrl(routeId, params, query) builds the path, applies the locale prefix, then appends the query string. It is isomorphic — it reads the locale context through the isomorphic accessor, so the same call produces the same URL during SSR and after hydration. buildAbsoluteUrl(routeId, params) prepends the store base URL and takes no query argument.
Because buildUrl localizes for you, never hand-build a prefixed path. Writing `/${locale}/cart` breaks the moment the locale is the default one.
localizeUrl — for strings you already have
Some URLs are not built from a route — a url_rewrite path stored on a product or category, for instance. Those come out of the database canonical and unprefixed, and a GraphQL resolver localizes them explicitly:
import { localizeUrl } from '@evershop/evershop/lib/locale/localeContext';
url: (product) => localizeUrl(`/${product.url_key}`)
localizeUrl reads the request ALS context, which — unlike buildUrl's isomorphic context — is populated during GraphQL resolution. That is the whole reason both exist. Rule of thumb:
| You have | Use |
|---|---|
| A route ID and params | buildUrl(routeId, params, query) |
| An absolute URL from a route ID | buildAbsoluteUrl(routeId, params) |
| An already-built path, server-side (resolver, controller) | localizeUrl(path) |
Switching language
switchLocalePath is the pure, isomorphic helper behind language switching. It strips the current prefix to get the canonical path, then applies the target's:
import { switchLocalePath } from '@evershop/evershop/lib/locale/localeResolution';
switchLocalePath('/de/shoes', 'fr', 'en', ['en', 'de', 'fr']); // '/fr/shoes'
switchLocalePath('/de/shoes', 'en', 'en', ['en', 'de', 'fr']); // '/shoes'
switchLocalePath('/', 'de', 'en', ['en', 'de']); // '/de'
currentPath is a pathname only — no query string. Preserve the query yourself if you need it.
The LanguageSwitcher component
EverShop ships a switcher at @components/common/LanguageSwitcher. It takes no props, reads everything from the app state, and:
- returns
nullwhen fewer than two locales are enabled, so single-language stores render no extra markup; - labels each option with
Intl.DisplayNamesin that language's own script, falling back to the upper-cased code; - navigates to
switchLocalePath(...) + window.location.searchon change, preserving the query.
The markup is a plain <select className="language-switcher">, so a theme can restyle it with CSS alone. Core mounts it in the storefront header via a thin wrapper with layout = { areaId: 'headerMiddleRight', sortOrder: 1 }.
To place it somewhere else in your theme, mount the shared component with your own layout:
import LanguageSwitcher from '@components/common/LanguageSwitcher.js';
import React from 'react';
export default function FooterLanguageSwitcher() {
return <LanguageSwitcher />;
}
export const layout = {
areaId: 'footerMiddleLeft',
sortOrder: 50
};
To build a custom switcher — links instead of a <select>, or flags — read the locale fields from app state and call switchLocalePath yourself.
hreflang and x-default
buildHreflangAlternates(currentUrl, defaultLocale, enabled, baseUrl) produces the alternate set for a page: one absolute URL per enabled locale, plus an x-default entry pointing at the default-locale (unprefixed) URL. It returns [] when fewer than two locales are enabled, so a single-language store emits no hreflang markup at all.
The alternates surface as PageInfo.alternates in GraphQL and render in the document head:
<link rel="alternate" hrefLang="en" href="https://yourstore.com/shoes" />
<link rel="alternate" hrefLang="de" href="https://yourstore.com/de/shoes" />
<link rel="alternate" hrefLang="x-default" href="https://yourstore.com/shoes" />
The same list also drives og:locale:alternate meta tags (with x-default and the current locale filtered out).
Two details worth knowing:
- The query string is preserved on every alternate. A page at
?page=2produces alternates that also carry?page=2. Google discards ahreflangcluster whose self-reference disagrees with the page's canonical URL, and the canonical carries the query — so the alternates must too. - The set is self-referential. The current locale appears in its own alternate list, which is what search engines expect.
The sitemap emits the same alternates from the same enabled-languages list, via expandLocales and the shared applyLocalePrefix. Head tags and sitemap therefore cannot drift, and there is nothing locale-specific to configure in either — enabling a language wires up both. Sitemap hreflang output can be turned off with the sitemap.hreflang config flag.
What a theme can read: eContext
The render pipeline injects the locale payload into eContext, which reaches components through useAppState():
import { useAppState } from '@components/common/context/app';
function LocaleBadge() {
const { locale, defaultLocale, availableLocales } = useAppState();
if ((availableLocales ?? []).length < 2) {
return null;
}
return <span>{locale === defaultLocale ? 'default' : locale}</span>;
}
| Field | Type | Description |
|---|---|---|
locale | string? | The locale this page rendered in. |
defaultLocale | string? | The store's default locale, which is the one served unprefixed. |
availableLocales | string[]? | Every enabled locale, default first. Length < 2 means a single-language store. |
translations | Record<string, string>? | The dictionary for this page's locale. Read it through _(), not directly. |
All four are optional — guard with ?? rather than assuming they are present. The same payload is serialized to window.eContext, which is where the client-side _() helper and the X-Locale fetch wrapper read from after hydration.
The rendered <html> element also carries lang set to the active locale.
Checklist for extension and theme authors
- Never hard-code a locale prefix. Use
buildUrlfor route links andlocalizeUrlfor already-built paths. - Register one route, not one per language. Route matching sees the stripped path.
- Read the canonical path from
request.localePath ?? request.originalUrl.split('?')[0]in custom middleware. - Use
originalUrl, notlocalePath, for canonical/SEO output — it is the prefixed, user-visible URL. - Do not import
localeContextinto client code. It pullsnode:async_hooksinto the bundle. Client components use_(); server code usestranslate(). - Pass a locale explicitly off-request — cron jobs and subscribers have no ambient locale.
- Gate multi-language UI on
availableLocales.length >= 2so single-language stores stay clean. - Return canonical, unprefixed paths from sitemap collectors and similar extension points; the pipeline localizes them.
See also
- Translation — authoring the CSV dictionaries,
translate()and_() - Store Settings — the setting table behind
storeLanguage,storeLanguages, andadminLanguage - Sitemap & robots.txt — multi-language sitemap output and
hreflangalternates - The Routing System — how routes are declared and matched
- The Middleware System — where the locale middleware sits in the chain
Support us
EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.