Backend for Frontend

Client Environment

The clientEnv object every handler receives — what the browser sends, what the server resolves it into, and why the two are different types.

Every query, link, action, and component resolver receives a clientEnv argument describing the request's environment: which language, which market, which currency, and whether this is a content preview.

There are two types, and the difference between them is a trust boundary:

TypeWho produces itTrustedWhere you see it
WireClientEnvThe browserNoThe orchestr:client-env:modify hook, the wire format
ClientEnvThe server, per requestYesEvery handler, extendRequest, getKeySuffix

The browser sends a WireClientEnv. The server runs it through resolveClientEnv(), which validates each field against the project's own configuration and returns a ClientEnv. Handlers only ever see the resolved type.

ClientEnv — what handlers receive

type ClientEnv<T = Record<string, any> | undefined> = {
  isPreview: boolean;
  market: RenderMarket;
  language: RenderLanguage;
  custom?: T;
};
FieldTypeDescription
isPreviewbooleantrue only when the client asked for content preview and the server verified the token.
marketRenderMarketThe full resolved market — id, slug, currency, region codes, domains.
languageRenderLanguageThe full resolved language — id, BCP 47 code, fallback chain, direction.
customT | undefinedWhatever your own plugin put on the wire env. Passed through untouched, and therefore untrusted.

market and language are always populated. Read them directly; do not write fallback defaults like clientEnv.market.currency ?? 'USD'.

The fields most handlers reach for:

PathExampleDescription
market.idmkt_123RC entity id of the market.
market.slugswitzerlandDeveloper-friendly alias — the usual key for a sales channel.
market.currencyCHFISO 4217 currency code.
market.regionCodes['CH']ISO 3166 region codes the market covers.
market.defaultDomain.hostwww.shop.chThe market's canonical hostname.
language.idlng_abcRC entity id of the language.
language.codede-CHBCP 47 tag — the request's locale.
language.languageCodedeISO 639 subtag, for backends that key on language alone.
language.localeChain['de-CH', 'de-DE']Ordered fallback chain for content resolution.
language.directionltrText direction.
server/orchestr/catalog/products.ts
export default defineMyQuery(ProductsQuery, async ({ context, clientEnv }) => {
  const products = await context.api.search({
    locale: clientEnv.language.code,
    currency: clientEnv.market.currency,
    channel: clientEnv.market.slug,
    includeDrafts: clientEnv.isPreview,
  });

  return { ids: products.map((p) => p.id), total: products.length };
});
ClientEnv also carries locale and currency at the top level. Both are deprecated flat copies of language.code and market.currency. Use the resolved objects: they cannot disagree with the market a handler reads, and they carry the region codes, fallback chain, and domains the flat strings drop.
market and language reference each other (RenderMarketDomain.languageRenderLanguage.marketDomains). Traversing the graph is fine, but JSON.stringify(clientEnv)throws on the cycle. Never serialize the whole object — pick the scalars you need.

WireClientEnv — what the browser sends

type WireClientEnv = {
  locale: string;
  currency: string;
  isPreview: boolean;
  previewToken?: string;
  marketId?: string;
  languageId?: string;
  custom?: Record<string, any>;
};
FieldDescription
marketIdThe market the client believes it is on. Looked up in the project's i18n config; an unknown id falls back to the default market.
languageIdLikewise, falling back to the market's default domain language.
isPreviewA request for preview content, not a grant.
previewTokenThe presented content-preview token. Verified and then stripped — it never reaches a handler.
localeVestigial. The server does not read it; it derives locale from the resolved language. Some client-side code still reads it locally.
currencyVestigial, for the same reason.
customYour own arbitrary data, passed through to ClientEnv.custom unchanged.

Nothing here is trusted. The server treats every field as a claim to be checked against configuration it already holds.

Adding your own data

Shape the wire env from a Nuxt plugin with the orchestr:client-env:modify hook. frontend-core already sets marketId, languageId, and the preview fields — put anything of your own under custom:

app/plugins/client-env.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('orchestr:client-env:modify', ({ clientEnv }) => {
    clientEnv.custom = { ...clientEnv.custom, abVariant: useCookie('ab-variant').value };
  });
});

Read it back on the server as clientEnv.custom?.abVariant — and validate it there, because a shopper can set it to anything.

Resolution

resolveClientEnv(event, raw) is the single entry point. It parses the raw body value, applies the preview gate, resolves the ids, and builds the result field by field — which is why previewToken cannot survive into it.

const clientEnv = resolveClientEnv(event, rawClientEnvFromRequest);
await runQuery(Token, args, clientEnv, event);

Every orchestr endpoint does this before dispatching. If you write your own endpoint that runs orchestr handlers, you must too — passing a hand-built object skips the gate.

The market and language ids are resolved against the project's i18n config, and isPreview is granted only when the presented token verifies. Both are decided server-side, from configuration the browser has no access to.

Cache keys

Orchestr's default cache key includes the language code, the currency, and the preview stage — nothing else. It deliberately does not widen as ClientEnv grows.

If your handler's output varies by market, append your own scalar in getKeySuffix:

cache: {
  ttl: '10 minutes',
  getKeySuffix: (clientEnv) => clientEnv.market.slug,
}

Return a scalar, never the object — see the cycle caveat above. Full details in Caching.

Copyright © 2026 Laioutr GmbH