Stores and markets
One Evolve backend can serve several stores, countries, languages, and currencies. Every request names the one it is for in three values, the store context:
| Value | What it selects | Example |
|---|---|---|
storeKey |
The store in the commerce engine: its assortment, channels, carts, and orders | evolve |
locale |
The language, and through its region the country | fr-BE |
currency |
The prices and the cart’s currency | EUR |
A market in this page is a country you sell in. A market can share a store
with other markets, as all seven locales of the reference sites share the
store evolve, or have a store of its own.
sequenceDiagram participant B as Browser participant S as Storefront (Next.js) participant G as Gateway (Hive Router) participant SG as Subgraphs participant V as commercetools / CMS B->>S: GET /be/fr/panier S->>S: proxy.ts reads the prefix /be/fr as locale fr-BE S->>S: The layout takes the store key and currency from the store config S->>G: GraphQL request with X-StoreContext-StoreKey = evolve,<br/>X-StoreContext-Locale = fr-BE, X-StoreContext-Currency = EUR G->>SG: The same three headers, unchanged SG->>SG: readStoreContextFromRequest() SG->>V: In store evolve, locale fr-BE (fallback fr),<br/>prices for BE in EUR V-->>SG: Store-scoped, localized data SG-->>G: Answer G-->>S: Merged answer S-->>B: Page in French, prices in euros
The store context on a request
Section titled “The store context on a request”The storefront sends the store context as three headers on every GraphQL request, from the server and from the browser:
X-StoreContext-StoreKey the storeX-StoreContext-Locale the locale, such as fr-BEX-StoreContext-Currency the currency, such as EURIn the gateway
Section titled “In the gateway”The gateway passes the three headers on to every subgraph as they are. In the
reference sites that is a propagate rule in the router configuration:
headers: all: request: - propagate: named: - X-StoreContext-StoreKey - X-StoreContext-Locale - X-StoreContext-CurrencyThe gateway does not check the values and fills in no defaults. A request without the headers reaches the subgraphs without them.
In a service
Section titled “In a service”The service’s context factory reads the headers with
readStoreContextFromRequest,
which returns a plain
StoreContext:
{ storeKey, locale, currency }. A service that needs more, such as a sales
channel, passes a reader per extra header:
import { readStoreContextFromRequest } from "@evolve-framework/core/service/graphql";
const storeContext = readStoreContextFromRequest(request, { channel: { header: "X-StoreContext-Channel", read: (value) => value ?? "web", },});// { storeKey, locale, currency, channel }The engine package then wraps the values in its own StoreContext, which
resolvers read as context.storeContext. How the context factory is put
together is on Authentication and sessions.
When a header is missing
Section titled “When a header is missing”readStoreContextFromRequest throws Store context not found in HTTP headers
when one of the three is missing or empty, so the whole request fails. The
subgraphs answer nothing without a store context. Send all three when you call the gateway or a subgraph by hand, from a script,
or from a smoke test:
curl http://localhost:4000/graphql \ -H 'Content-Type: application/json' \ -H 'X-StoreContext-StoreKey: evolve' \ -H 'X-StoreContext-Locale: nl-NL' \ -H 'X-StoreContext-Currency: EUR' \ -d '{"query":"{ health { discover { ok } purchase { ok } } }"}'This is the reference sites’ federated health query, which reaches every
subgraph. Without the headers it fails with that error before any health
resolver runs.
How the storefront picks the store context
Section titled “How the storefront picks the store context”In the reference sites, the three values come from two places:
- The locale comes from the URL. Every page lives under
app/[locale]/.src/proxy.tsruns the next-intl middleware, which reads the URL prefix (/be/fr) and rewrites the request to the full locale (fr-BE). - The store key and the currency come from the store config. The
storefront loads one store config at startup, chosen by the
STORE_KEYenvironment variable, fromfrontend/site/src/lib/store-config/.
The [locale] layout puts them together:
const storeConfig = await getStoreConfig();const locale = getLocaleFromParams(params.locale, storeConfig.defaultLocale);
const storeContext = { storeKey: storeConfig.storeKey, currency: storeConfig.currency, locale,};So one storefront deployment serves one store key and one currency, in as many
locales as its routing has. Another store or another currency is another
deployment of the same storefront, with another STORE_KEY, against the same
backend.
flowchart TB nl["Storefront, STORE_KEY=evolve<br/>/nl, /be/nl, /be/fr"] fr["Storefront for another store<br/>its own STORE_KEY and domain"] gateway[Gateway] services[One set of services] nl -- "GraphQL, store context headers" --> gateway fr -- "GraphQL, store context headers" --> gateway gateway --> services
The site-config package
Section titled “The site-config package”The routing lives in packages/site-config, a package of its own because the
backend reads it too:
| Export | What it holds | Who reads it |
|---|---|---|
locales, defaultLocale |
Every locale the site routes | The next-intl middleware and navigation, next-rewrites.ts, evolve.config.ts |
localePrefixes |
The URL prefix per locale: /nl, /be/nl, /be/fr |
The middleware, the CMS preview route, the notify service for email links |
pathnames |
Localized paths per locale: /winkelwagen, /panier, /warenkorb |
The middleware, and getPathname() for the product sitemaps |
staticSiteConfigs |
One config per country, with its languages | The discover service, for the Storyblok module’s translated paths |
The prefix mode is always, so every URL carries its locale. The account
pages keep the same path in every locale on purpose.
Each country config also has a storeKey and a currency. The storefront does
not read those: the request’s store key and currency come from the store
config.
Two lists of locales
Section titled “Two lists of locales”The routing in site-config decides which URLs resolve. The store config’s
locales decide which locales the language switch and the hreflang
alternates offer. In the reference sites the store evolve offers all seven.
A smaller store config, such as example-nl.ts with only nl-NL, still
answers every routed URL in that URL’s locale, and only leaves the other
locales out of the language switch. Keep the two in step for each store you
deploy.
B2C and B2B
Section titled “B2C and B2B”Both reference sites pick the store context the same way, from the same
site-config and store config shape. The B2B site has country configs for
NL, BE, FR, and DE, and localized /checkout paths.
The difference is in the backend. For a signed-in business user, the commerce package takes the store from the active business unit instead of from the header:
clientContext.storeKeyreturns the unit’s store, which carts, orders, and payments use.- Product queries ask for the unit’s store, and select prices for the unit’s channel and the customer’s group.
The storefront keeps sending its own store key. Switching business units changes the store from the next request on. See Business context.
What differs per store
Section titled “What differs per store”With commercetools, most of what makes a market different is data in the
project, which the reference sites keep in Terraform under
configuration/commercetools. The engine’s
StoreContext turns
the three values into the parameters commercetools needs.
| What | Selected by | Configure it in |
|---|---|---|
| Assortment | The store key, as storeProjection on every product query |
The store’s product selections in commercetools (stores.tf) |
| Product texts | The locale, with its language as fallback (fr-BE, then fr) |
The project’s and the store’s languages (main.tf, stores.tf) |
| Prices | Currency, country, and for a signed-in shopper the customer group | Prices in commercetools, and the project’s currencies and countries (main.tf) |
| Distribution and supply channel | The store’s first distribution channel and first supply channel, on every line item | distribution_channels and supply_channels on the store, the channels in channels.tf |
| Carts, orders, shopping lists, sign-in | The store key, through the in-store endpoints. A new cart gets the store’s currency and the locale’s country | The store |
| Shipping methods | The cart’s shipping address, against your shipping zones | shipping.tf |
| Shipping estimate before checkout | The default shipping method’s rate for the locale’s country | shipping.tf |
| Tax | The cart’s country, through commercetools’ tax rates | Tax rates per country in tax_categories.tf |
| Payment methods | The payment provider, asked with the locale, currency, and billing country | The provider’s own account |
| Search results | An index per store and language by default | See Stores, locales and index names |
| CMS content | The locale. With Payload, also the store | See CMS content per locale |
| Email links | The notification’s locale, through localePrefixes |
site-config |
| Product sitemaps | One sitemap index per store, on that store’s domain | STORE_CONFIG of the discover service |
A few things to know about these:
- The country comes from the locale.
fr-BEselects Belgian prices, creates carts for Belgium, and estimates Belgian shipping. A locale without a region falls back toNL. - Product queries do not pass the store’s distribution channel. Outside a business context, product prices are selected by currency, country, and customer group only, while a line item carries the store’s channel. Keep that in mind before you give a store channel-specific prices.
- One channel per role. A store with more than one distribution or supply channel gets the first one, and the service logs a warning.
- The store record is read once per process. Its channels are cached for the life of the service, so restart the services after you change a store’s channels. See What the engines cache.
- Payment methods are not configured per store in Evolve. The cart’s
availablePaymentMethodslists what each payment provider returns for the locale, currency, and billing country. The reference sites’ store config has anavailablePaymentMethodsfield, but nothing reads it.
Cache keys
Section titled “Cache keys”Every cached answer that depends on the store context must name it in its key,
or one store’s answer serves another. The commercetools and Contentful store
contexts give cacheKeyPrefix, <storeKey>-<locale>-<currency>, and the
catalog loaders use it. Use it in your own loaders too. Products for a
signed-in shopper are not cached at all, because their prices also depend on
the customer. See Caching and performance.
CMS content per locale
Section titled “CMS content per locale”Each CMS package hands the locale to the CMS in its own way:
| CMS | Locale sent | Per store |
|---|---|---|
| Contentful | The locale as is, so the space needs a locale with exactly the code fr-BE |
No. The store key is part of the cache keys only |
| Storyblok | The locale as the language, with its language part (fr) as fallback |
No. Pass siteConfigs so pages get their paths in the other locales |
| Payload | The language part (fr) by default, or the code you map it to with the module’s locales option |
Yes, with multiTenant, which is on by default: a tenant’s slug is a store key |
So with Contentful or Storyblok, two stores that share a locale show the same content. With Payload, each store has its own pages, header, footer, and navigation.
Other engines
Section titled “Other engines”The Medusa package checks the store context before any resolver runs. Its
StoreContext.resolve()
refuses a store key that is not in the client factory’s stores option and a
locale the store does not serve, and it picks the Medusa region by currency.
The commercetools package keeps no list of stores of its own: it passes the
store key on to commercetools.
Add a store or a market
Section titled “Add a store or a market”What you change depends on what is new:
| What is new | What to do |
|---|---|
A language in a country you already sell in, such as fr-NL |
Add a language |
| A country, in a currency and store you already have | Add a language for its locales, then steps 1 and 2 below |
| A currency | Steps 1, 2, and 4 below |
| An assortment, a channel, or content of its own | All steps below |
Adding a language is covered on its own page: the routing, the message files,
the CMS locale, and the commercetools languages. A new market goes further.
- Open the country in commercetools. Add the country to
countries, and a new currency tocurrencies, inconfiguration/commercetools/main.tf. The store instores.tftakes its languages and countries from there. - Give the country prices, tax, and shipping. Add prices for the country
and currency to your products. Add a tax rate for the country in
tax_categories.tf, and a shipping zone with a rate in the store’s currency inshipping.tf. Without a zone, a cart to that country has no shipping method. - Create a store, for an assortment of its own. Add a
commercetools_storetostores.tfwith its own key, product selections, and distribution and supply channels fromchannels.tf. - Add a store config. Add a file next to
example-nl.tsinfrontend/site/src/lib/store-config/, with the store key, the currency, the locales the store offers, and its default locale. Add it tostoreConfigsinindex.ts, and extendStoreKeyand, for a new currency,Currencyincontext/types.ts. - Deploy the storefront for it. Run another instance of the storefront
with
STORE_KEYset to the new key, on its own domain. - Tell the backend about the domain. Add the store key and its domain to
STORE_CONFIGof the discover service, so its products get a sitemap. The notify service builds email links on oneSTOREFRONT_URL, so a store on its own domain needs that service to pick the URL by store key. - Fill the search index. The indexer reads one scope per store, so a new store gets indices of its own. Run a full reindex. See Rebuild the index.
- Give the store its content. With Payload, add a tenant whose slug is the store key. With Contentful or Storyblok, content follows the locale, so a store with a new locale needs that locale in the space.
- Restart the services after you change an existing store’s channels.
flowchart LR market((New market)) market --> ct["commercetools, in Terraform<br/>countries, currencies, store,<br/>channels, tax, shipping"] market --> site["Storefront<br/>routing, store config,<br/>a deployment per STORE_KEY"] market --> cms["CMS<br/>locale or tenant"] market --> search["Search<br/>full reindex"]
What Evolve does not do
Section titled “What Evolve does not do”- No currency switch for the shopper. A storefront deployment has one currency. Several currencies are several store configs and deployments.
- No store choice per request in the storefront. The reference storefront takes the store key from its deployment, never from the URL or a cookie. A storefront that should choose the store per request, by domain for example, needs its own selection where the layout builds the store context.
- No separate content per store in Contentful or Storyblok. Several brands on one backend share the CMS content of a locale, unless you use Payload’s tenants or run a CMS service per brand.
- No country apart from the locale. Prices, a new cart’s country, and the shipping estimate follow the locale’s region, so each country you sell in needs a locale that names it.

