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;
  domain: RenderMarketDomain;
  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.
domainRenderMarketDomainThe specific domain this request resolved to — its host, optional path prefix, and language. Use domain.host for the request's canonical host.
custom`Tundefined`

market, language, and domain 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 default hostname.
domain.hostwww.shop.chThe host this request resolved to — may differ from market.defaultDomain.host when a market spans several domains. Prefer it for the request's canonical URL.
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 };
});

WireClientEnv — what the browser sends

type WireClientEnv = {
  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.
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.

  • Content Preview — what isPreview unlocks.
  • Wire Format — the JSON clientEnv travels in.
  • Page Index — its cache keys and platform reads are scoped by the resolved clientEnv.
  • MiddlewareextendRequest receives clientEnv.
  • Hooks — shaping the wire env from the client.
Copyright © 2026 Laioutr GmbH