Skip to content

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 storefront sends the store context as three headers on every GraphQL request, from the server and from the browser:

X-StoreContext-StoreKey the store
X-StoreContext-Locale the locale, such as fr-BE
X-StoreContext-Currency the currency, such as EUR

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-Currency

The gateway does not check the values and fills in no defaults. A request without the headers reaches the subgraphs without them.

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.

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.ts runs 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_KEY environment variable, from frontend/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 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.

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.

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.storeKey returns 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.

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-BE selects Belgian prices, creates carts for Belgium, and estimates Belgian shipping. A locale without a region falls back to NL.
  • 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 availablePaymentMethods lists what each payment provider returns for the locale, currency, and billing country. The reference sites’ store config has an availablePaymentMethods field, but nothing reads it.

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.

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.

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.

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.

  1. Open the country in commercetools. Add the country to countries, and a new currency to currencies, in configuration/commercetools/main.tf. The store in stores.tf takes its languages and countries from there.
  2. 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 in shipping.tf. Without a zone, a cart to that country has no shipping method.
  3. Create a store, for an assortment of its own. Add a commercetools_store to stores.tf with its own key, product selections, and distribution and supply channels from channels.tf.
  4. Add a store config. Add a file next to example-nl.ts in frontend/site/src/lib/store-config/, with the store key, the currency, the locales the store offers, and its default locale. Add it to storeConfigs in index.ts, and extend StoreKey and, for a new currency, Currency in context/types.ts.
  5. Deploy the storefront for it. Run another instance of the storefront with STORE_KEY set to the new key, on its own domain.
  6. Tell the backend about the domain. Add the store key and its domain to STORE_CONFIG of the discover service, so its products get a sitemap. The notify service builds email links on one STOREFRONT_URL, so a store on its own domain needs that service to pick the URL by store key.
  7. 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.
  8. 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.
  9. 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"]
  • 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.