Session Context
Goal
Read and change the sales channel context — currency, language, country, active addresses, shipping and payment method, tax state. The important part is that PATCH /context does not return the new context, so every setter has to read it back, and that the shared context value is seeded at the application root instead of being fetched per component.
Shopware Flow
The sales channel context is the server-side state attached to the sw-context-token. It decides which currency prices are calculated in, whether prices are gross or net, which country the shipping location is in, and which customer is logged in.
PATCH /context takes a narrow body of ids and answers with the sw-context-token header; its JSON body holds only an optional redirectUrl, never the new context. That is the single fact that shapes this whole recipe: a setter that only sends the PATCH leaves every reactive value in the application stale. useSessionContext therefore awaits refreshSessionContext() after every write.
Step 1
Root: Seed the context once
The application root loads the context and passes it to useSessionContext(context). That argument seeds the shared value from the Store API, before any page renders.
- Code
useSessionContext(contextResponse.data)- State
- swSessionContext
- Types
- readContext response
Read the diagram from left to right:
- The application root calls
readContext get /contextand passes the result intouseSessionContext(context), which seeds the sharedswSessionContextvalue. Call it directly rather than throughuseAsyncData, for the reason in State And Session. - Components call
useSessionContext()without an argument and inject that same ref throughuseContext. - A UI action calls one setter, for example
setCurrency({ id })orsetCountry(countryId). - The setter sends
updateContext patch /contextwith a single id field. - The setter awaits
refreshSessionContext(), which callsreadContext get /contextand replaces the shared value. - The UI reads
currency,taxState,countryId,activeShippingAddressanduserFromContextfrom computed properties instead of keeping its own copy.
You do not need to call refreshSessionContext() after a setter from useSessionContext() — every one of them already does it. You also do not need to call it after login, registration or logout, because useUser() refreshes the context itself.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Load the context | refreshSessionContext() | GET /context | readContext response |
| Switch the currency | setCurrency({ id }) | PATCH /context | updateContext body |
| Switch the language | setLanguage({ id }) | PATCH /context | updateContext body |
| Switch the country | setCountry(countryId) | PATCH /context | updateContext body |
| Set the shipping address | setActiveShippingAddress({ id }) | PATCH /context | updateContext body |
| Set the shipping method | setShippingMethod({ id }) | PATCH /context | updateContext body |
| Seed the value locally | setContext(context) | none | SalesChannelContext |
| Run a context gateway | apiClient.invoke("contextGateway post /context/gateway", { body: { appName } }) | POST /context/gateway | contextGateway body |
A redirectUrl on the PATCH /context response comes from one place only. Core fills it in ContextSwitchRoute::checkNewDomain, which returns a URL when the body carries a languageId that differs from the current one and the sales channel has a domain bound to that language; a currency, country, address or method switch never produces one, and neither does a language that has no domain. The value is that domain's own url, which can be another host or the same host with a path prefix such as https://myshop.com/de, so navigate to it as given rather than deriving a host from it.
No setter on useSessionContext hands you that value: they all discard the response, and setLanguage() is declared Promise<void>. Reading it means calling useInternationalization().changeLanguage(id), which returns the body — see the Language and Currency Switch recipe.
setContext(context) is synchronous and sends no request. It only overwrites the shared value, which is what you want when a context arrives from somewhere other than a GET /context — an SSR payload or a cross-tab sync message.
contextGateway post /context/gateway has no composable wrapper. It lets an app manipulate the context server-side; the body requires appName and takes an optional data record, and the call does not type-check without it. Like a context patch it does not return a context, so follow it with refreshSessionContext(). Its response carries a redirectUrl of its own, which the language-domain rule above does not describe — it is a different route. The same operation exists as contextGatewayGet get /context/gateway, which takes appName as a query parameter.
Composables
Pick by scope — how much of the session the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useSessionContext | the whole sales channel context | reading or switching currency, language, country, addresses, shipping and payment method |
useContext | one named injection key | you need your own application-wide shared ref with the same provide/inject mechanics |
useInternationalization | the language switch and storefront URLs | switching the language as part of a navigation, or building a localized link |
useSessionContext is the one you reach for:
- Read —
sessionContext,currency,taxState,countryId,salesChannelCountryId,salesChannelLanguageId,currentLanguageId,currentLocaleCode,activeShippingAddress,activeBillingAddress,selectedShippingMethod,selectedPaymentMethod,userFromContext. - Write —
setCurrency,setLanguage,setCountry,setActiveShippingAddress,setActiveBillingAddress,setShippingMethod,setPaymentMethod. Each one patches a single field and then awaitsrefreshSessionContext(). - Local —
setContext(context)overwrites the shared value without a request,refreshSessionContext()reloads it fromGET /context.
Seven things the generated reference will not tell you:
useSessionContext()has to run insetupor inside an active effect scope.useContextcalls VueUse'sprovideLocalon every invocation, and that function throws"provideLocal must be called in setup"when there is neither a component instance nor a scope — so a call from an event handler or a plain module fails loudly instead of returning an empty context.- The setters do not fail the same way.
setShippingMethod,setActiveShippingAddressandsetActiveBillingAddresstake aPartial<>and throw at runtime when the id is missing;setPaymentMethodrequires{ id: string }, so the same mistake is a compile error rather than a throw;setLanguagereturns without a request;setCurrencylogs the problem withconsole.errorand then returns;setCountrytakes a plain string and validates nothing. setCurrencyandsetLanguagetake aPartial<Schemas["Currency"]>and aPartial<Schemas["Language"]>, so you can pass the whole entity you already rendered — onlyidis read from it.useInternationalization().changeLanguage(languageId)sends the sameupdateContext patch /context, returns the response body and deliberately does not refresh the shared context, because a language switch ends in a redirect.setLanguage()is the same patch with the response thrown away and a refresh added, so it can never show you aredirectUrl. If you callchangeLanguage()and stay on the page, follow it withrefreshSessionContext()yourself — the Language and Currency Switch recipe covers that flow.countryStateIdis part of the patch body but has no setter. Reaching it means callingapiClient.invoke("updateContext patch /context", { body: { countryStateId } })directly and refreshing afterwards.salesChannelLanguageIdandcurrentLanguageIdare the current names oflanguageIdandlanguageIdChain, which are deprecated aliases of the very same computed properties.currentLanguageIdreads the first entry ofcontext.languageIdChainand falls back to an empty string at runtime — but it is declaredComputedRef<string | undefined>, so you still have to narrow it before passing it somewhere that wants astring.setContextexists for state that arrives outside the request cycle. The deprecatedvue-demo-storereference template uses it to apply a context pushed over aBroadcastChannelfrom another tab.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type the context, a patch body, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type SessionContext = operations["readContext get /context"]["response"];
type UpdateContextBody = operations["updateContext patch /context"]["body"];
type SalesChannelContext = Schemas["SalesChannelContext"];
type Currency = Schemas["Currency"];
type CustomerAddress = Schemas["CustomerAddress"];UpdateContextBody is the honest description of what a context switch can change. Anything that is not a field on it cannot be switched through PATCH /context at all.
Minimal Vue Example
<script setup lang="ts">
const {
sessionContext,
currency,
taxState,
countryId,
activeShippingAddress,
userFromContext,
setCountry,
refreshSessionContext,
} = useSessionContext();
const isSwitching = ref(false);
const contextError = ref("");
const priceLabel = computed(() => {
if (taxState.value === "gross") return "including tax";
if (taxState.value === "net") return "excluding tax";
return "tax free";
});
const switchCountry = async (id?: string) => {
if (!id) return;
contextError.value = "";
isSwitching.value = true;
try {
await setCountry(id);
} catch {
contextError.value = "The country change could not be confirmed.";
await refreshSessionContext();
} finally {
isSwitching.value = false;
}
};
</script>
<template>
<p v-if="!sessionContext">Loading the session…</p>
<div v-else>
<p v-if="contextError">{{ contextError }}</p>
<dl>
<dt>Currency</dt>
<dd>
<ClientOnly>{{ currency?.isoCode }}</ClientOnly>
</dd>
<dt>Prices</dt>
<dd>{{ priceLabel }}</dd>
<dt>Shipping country</dt>
<dd>{{ sessionContext.shippingLocation?.country?.name }}</dd>
<dt>Shipping address</dt>
<dd v-if="activeShippingAddress">
{{ activeShippingAddress.street }}, {{ activeShippingAddress.city }}
</dd>
<dd v-else>not selected yet</dd>
<dt>Customer</dt>
<dd>
<ClientOnly>{{ userFromContext?.email ?? "guest" }}</ClientOnly>
</dd>
</dl>
<button
type="button"
:disabled="isSwitching || !countryId"
@click="switchCountry(countryId)"
>
{{ isSwitching ? "Switching…" : "Re-apply the current country" }}
</button>
</div>
</template>State And Session
The Store API identifies the session with the sw-context-token header. The context is the server's view of that token, and the frontend never owns it — it only mirrors the last GET /context response.
That mirror lives in the swSessionContext injection provided by useContext. Passing a context into useSessionContext(context) writes it; calling useSessionContext() with no argument injects it. Provide and inject travel down the component tree, so the seeding call has to run in the root component, above every consumer.
refreshSessionContext() rethrows after logging. That matters for flows that must not continue on a half-applied switch, such as logout or the last checkout step — treat a rejection there as a blocking error rather than a warning.
The token itself is handled one layer below the composables. @shopware/nuxt-module seeds the API client with the sw-context-token cookie and writes every new token back to that cookie from the API client's onContextChanged hook, so a switch survives a reload. That write goes through js-cookie and is therefore browser-only: a token minted during a server render is never persisted. The API client also ignores a token that arrives on a publicly cacheable response (Cache-Control: public), because a CDN hit can otherwise replay a stored guest token over a logged-in session.
Server-side rendering is where the "seeded once at the root" story needs one more fact. The module passes the cookie token into the server-side API client only when useUserContextInSSR is enabled, and that option defaults to false:
// nuxt.config.ts
runtimeConfig: {
public: {
shopware: {
useUserContextInSSR: true,
},
},
},With the default, the root's readContext get /context runs without a token during the server render, so the HTML is built from a fresh anonymous context: no customer, and the sales channel defaults for currency, language and country. On the client the plugin does read the cookie, the root call runs again and replaces the shared value — a logged-in customer with a non-default currency sees the guest version of the page until hydration finishes.
That second run is the only thing that personalises the page, and it happens only if the seeding call is not payload-cached. Call apiClient.invoke("readContext get /context") directly, the way the starter template's app.vue does. Wrapped in useAsyncData, the server result travels in the Nuxt payload instead, the client never refetches, and the anonymous context stays for the whole session. Render anything derived from userFromContext or currency behind <ClientOnly>, as the Minimal Vue Example does, or turn the option on.
Turning it on moves the problem to the cache. The server render then depends on the visitor's session, so a route served from isr or from a shared CDN entry would hand one visitor's context to everyone. Enable it only together with Cache-Control: private, no-store (or ssr: false) on every route that renders context-dependent data; the starter template's nuxt.config.ts carries both halves of that trade-off as comments next to routeRules.
Edge Cases
sessionContextisundefineduntil the root seeds it. Guard on it before reading nested fields during the first render.taxStatecomes fromcontext.taxStateand is typedstring | undefined, not a union of literals — the Store API constrains it nowhere. The cart's matching field,price.taxStatus, is documented asgross,netortax-free, so a branch that reads "not gross" as net is a guess about a value the schema does not promise.- Every formatted price on the page depends on
taxStateandcurrency, so a context switch invalidates cached price strings. countryIdis the shipping location country fromshippingLocation.country.id, whilesalesChannelCountryIdis the sales channel default. They differ as soon as the customer picks a different shipping country.activeShippingAddressfalls back toshippingLocation.addresswhen the customer has no active shipping address, butactiveBillingAddresshas no fallback and staysnullfor a guest.currentLocaleCodeis read fromlanguageInfo.localeCodeon the context, so detecting the active locale needs noreadLanguages post /languagerequest.- A currency or language switch changes prices and translated content. Anything already fetched — a listing, a cart summary, a product detail — is stale until it is refetched. The starter template's currency switcher awaits
setCurrency(), thenuseCart().refreshCart(), and reloads the page, because nothing else re-runs the already-resolved data fetches.
Common Mistakes
- Do not call
useSessionContext(context)with an argument outside the application root. The argument overwrites the injected ref's value, which replaces the context for the whole application, not only for the subtree below the call. - Do not send
updateContext patch /contextthroughapiClient.invokeand stop there. Without a followingrefreshSessionContext()the UI shows the old context. - Do not treat
useInternationalization().changeLanguage()as a context setter. It patches the context but leaves the shared value untouched on purpose. - Do not use
setContext()to apply a user-facing switch. It changes only local state and the server still has the previous context. - Do not keep a local copy of the currency, the tax state or the active address.
- Do not read
taxStateonce and cache the result. It is aComputedReffor a reason. - Do not treat a rejected
refreshSessionContext()as recoverable in checkout or logout. The context may be partially applied. - Do not enable
useUserContextInSSRwhile context-dependent routes stay in a shared HTML cache. The option is global, so one visitor's server-rendered context would be served to the next.
Testing Checklist
- The root seeds the context with
readContext get /contextbefore the first page renders. - A second
useSessionContext()call in a child component returns the same reactive context without issuing a request. setCountry(id)callsupdateContext patch /contextonce and thenreadContext get /contextonce.- After a successful switch,
countryIdandsessionContext.shippingLocation.countryreflect the new country. - A failing
PATCH /contextleaves the previously rendered context intact and surfaces a UI-level error. setCurrency({})without an id issues no request.setShippingMethod({})throws instead of issuing a request — its public signature takes aPartial<>, so the missing id is a runtime failure, not a compile error.setPaymentMethod({})is the opposite case and needs no test: that setter requires{ id: string }, so TypeScript rejects the call.activeBillingAddressisnullfor a guest session and set after login.- Switching to a language that has a sales channel domain answers with a
redirectUrl; switching the currency answers without one. - With
useUserContextInSSRleft at its default, the server-rendered HTML of a logged-in visitor contains no customer data.