Prices and Tax State
Goal
Render product prices correctly: the right price for a product with tiers or variants, a strikethrough for a reduction, and a formatted string in the current currency. The important part is that no price arithmetic belongs in the frontend — the Store API sends numbers already calculated for the current tax state, and the frontend only picks one and formats it.
Shopware Flow
Every product price in a Store API response is a CalculatedPrice computed for the session's context. The context's taxState says whether those numbers include tax, so the same product returns different values for a gross and a net context. The frontend never adds or removes a tax rate itself: taxState follows the customer group and the context, so it changes as a side effect of patching something else — a country, a customer — and every price then has to be refetched.
On top of that, one product can carry several prices. calculatedPrice is the plain one, calculatedPrices is the tier list, and calculatedCheapestPrice is the cheapest across a variant range. useProductPrice exists to answer "which of these do I show", and usePrice exists to turn the chosen number into a string.
Step 1
Store API: Calculate on the server
Prices arrive already calculated for the current context. taxState decides whether the numbers are gross or net, and nothing in the frontend converts between them.
- Code
GET /store-api/context- State
- sw-context-token
- Types
- readContext response
Read the diagram from left to right:
- The Store API returns prices already calculated for the context, gross or net according to
taxState. usePriceis shared on the client: the first component to call it creates the one instance the browser session reuses. On the server there is no sharing, and each call site formats from its own state.- That instance watches
sessionContext.currencywithimmediate: true, so it seeds and updates its own currency code from the context. useProductPrice(toRef(() => product))picks the price to display fromcalculatedPrice,calculatedPricesand the tier list.hasListPriceandprice.listPricetell you whether to render a reduction.getFormattedPrice(value)formats that number withIntl.NumberFormat.
You do not need to compute a gross price from a net one, or a discount percentage from two prices. Both come from the server — listPrice.percentage is the reduction. The UI reads every one of these from composables instead of keeping its own copy.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Read the tax state | taxState | GET /context | readContext response |
| Read the currency | currency | GET /context | Currency |
| Switch the currency | setCurrency({ id }) | PATCH /context then GET /context | updateContext body |
| Pick the price | useProductPrice(toRef(() => product)).price | none | CalculatedPrice |
| Read the reduction | price?.listPrice | none | CartListPrice |
| Read the reference | referencePrice | none | CartPriceReference |
| Format for display | getFormattedPrice(unitPrice) | none | none — Intl.NumberFormat output |
Only setCurrency issues requests, and it issues two: the PATCH and the refreshSessionContext() that follows it. The first two rows read the context that GET /context already populated, and every row below reads data the product response already carried — which is why displaying a price needs no request of its own.
Composables
Pick by scope — how much of the price problem the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useSessionContext | the whole session | reading taxState or currency, or switching currency |
useProductPrice | one product | deciding which of a product's prices to show |
usePrice | one number | turning any value into a formatted string |
useShopwareContext | the application | reading browserLocale, the seed for the number format |
useProductPrice is the one you reach for most. It takes a Ref<Product | undefined> and derives everything else from it:
- The price —
price(the whole chosenCalculatedPrice),unitPrice,totalPrice. - Ranges —
displayFrom,displayFromVariants,tierPrices. - Extras —
referencePrice,regulationPrice,hasListPrice, and its deprecated aliasisListPrice.
Four things the generated reference will not tell you:
usePriceis wrapped increateSharedComposablefrom@vueuse/core, and that wrapper is client-only — its source beginsif (!isClient) return composable. During SSR every call site gets its own instance and reads its own arguments; only in the browser is there a single instance to share.- That shared instance is reference-counted, not permanent. When the last component holding it unmounts, its effect scope is stopped and the next call builds a fresh one — so nothing you pass it survives a navigation through a page that renders no prices.
usePrice's parameter object requirescurrencyCodewhenever it is passed at all, solocaleCodecannot be supplied on its own.update()has the same shape.useProductPricetakes aRef, not a product. It readsproduct.valuein every computed, so passing the object directly yields empty strings rather than an error.
useSessionContext is the source of truth for taxState and currency. setCurrency({ id }) changes the currency and calls refreshSessionContext() itself; taxState is read-only here — the PATCH /context body accepts no tax field, so it only ever moves as a consequence of another context change.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type prices, currencies, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type CalculatedPrice = Schemas["CalculatedPrice"];
type ListPrice = Schemas["CartListPrice"];
type CartPrice = Schemas["CartPrice"];
type ReferencePrice = Schemas["CartPriceReference"];
type Currency = Schemas["Currency"];
type SessionContext = operations["readContext get /context"]["response"];CalculatedPrice is the type to open first. Alongside unitPrice and totalPrice it carries quantity, calculatedTaxes, listPrice, referencePrice and regulationPrice, plus taxRules just past the tooltip's field limit.
Two fields people expect to find there are not on CalculatedPrice. netPrice and taxStatus belong to CartPrice — the cart's total, not a product's price — where taxStatus is enumerated gross | net | tax-free. A product price carries its tax breakdown in calculatedTaxes and taxRules instead, and the gross-or-net question is answered by the context's taxState, not by the price object.
Minimal Vue Example
<script setup lang="ts">
import type { Schemas } from "#shopware";
const { product } = defineProps<{ product: Schemas["Product"] }>();
const { getFormattedPrice } = usePrice();
const { taxState } = useSessionContext();
const {
price,
unitPrice,
displayFrom,
displayFromVariants,
tierPrices,
referencePrice,
hasListPrice,
regulationPrice,
} = useProductPrice(toRef(() => product));
// displayFromVariants is number | false | undefined. A free variant is 0,
// which truthiness would throw away, so narrow on the numeric case.
const variantsFrom = computed(() =>
typeof displayFromVariants.value === "number"
? displayFromVariants.value
: undefined,
);
const showFrom = computed(
() => displayFrom.value || variantsFrom.value !== undefined,
);
const displayedPrice = computed(() => variantsFrom.value ?? unitPrice.value);
const taxNote = computed(() => {
if (taxState.value === "tax-free") return "tax free";
if (taxState.value === "gross") return "incl. tax";
return taxState.value === "net" ? "excl. tax" : "";
});
</script>
<template>
<div aria-live="polite">
<p>
<span v-if="showFrom">from </span>
<span class="sr-only">Current price</span>
<strong>{{ getFormattedPrice(displayedPrice) }}</strong>
<small> {{ taxNote }}</small>
</p>
<p v-if="hasListPrice && price?.listPrice">
<span class="sr-only">Previous price</span>
<del>{{ getFormattedPrice(price.listPrice.price) }}</del>
<span> Save {{ price.listPrice.percentage }}%</span>
</p>
<p v-if="regulationPrice">
Lowest price in the last 30 days
{{ getFormattedPrice(regulationPrice) }}
</p>
<p v-if="referencePrice">
{{ getFormattedPrice(referencePrice.price) }} per
{{ referencePrice.referenceUnit }} {{ referencePrice.unitName }}
</p>
<table v-if="tierPrices.length > 1">
<caption>
Quantity discounts
</caption>
<thead>
<tr>
<th scope="col">Quantity</th>
<th scope="col">Unit price</th>
</tr>
</thead>
<tbody>
<tr v-for="tier in tierPrices" :key="tier.quantity">
<th scope="row">{{ tier.label }}</th>
<td>{{ getFormattedPrice(tier.unitPrice) }}</td>
</tr>
</tbody>
</table>
</div>
</template>usePrice() is called without arguments here on purpose, and the example carries no loading branch. vue-starter-template awaits GET /context in app.vue before any page component's setup runs, and usePrice's watcher is immediate, so currencyCode already holds the ISO code by the time this component renders. The aria-live="polite" wrapper is what covers the case the guard looks like it covers: a price that reformats in place after a currency switch is otherwise never announced.
The English strings here are literals to keep the example short. Route them through your translation layer in a localized storefront, or a German page will have a German screen reader pronouncing "incl. tax".
State And Session
usePrice is shared on the client, and its state is exactly two refs: currencyCode and currencyLocale. The currency code follows the context — the composable watches sessionContext.currency with immediate: true and calls update() whenever it changes. Because that watcher is immediate, the formatter seeds itself from the session context; vue-starter-template never calls usePrice with arguments anywhere, and prices still format correctly.
The sharing is narrower than it looks, in two directions. createSharedComposable starts with if (!isClient) return composable, so during SSR usePrice is the plain composable: every call site builds its own refs and honours its own arguments. And on the client the shared instance is reference-counted — when the last component holding it unmounts, its scope is stopped and the next caller creates a new one. Neither of those matters while every call is argument-free, which is why the starter never trips over them.
The locale does not follow the context. That update() call passes only currencyCode, and update keeps the previous locale when none is given. The initial locale is localeCode if the caller that created the instance passed one, and otherwise browserLocale from useShopwareContext() — navigator.language on the client, the first accept-language entry on the server, and "en-US" only when neither is available. It is read once when the Nuxt plugin runs, so a language switch changes translations but not the number formatting.
taxState lives on the context under context.taxState and is typed as a plain, optional string in the schema. It is not something the frontend sets directly: it follows the customer group and the context, and a change to it invalidates every price currently rendered.
Edge Cases
getFormattedPrice(undefined)returns an empty string, and with nocurrencyCodeorcurrencyLocaleset it returns the raw value as a string. In practicecurrencyLocaleis never empty, so the unformatted case only happens before the session context has a currency.createSharedComposableis client-only. On the serverusePriceis the unwrapped composable, so ausePrice({ currencyCode, localeCode })that looks inert because "only the first call counts" does take effect in that component's SSR output. Whether it survives hydration depends on call order: if the same component is also the first to callusePriceon the client, it seeds the shared instance and the two sides agree. If any other component got there first, the arguments are dropped and you get the format mismatch described below. In practice that only biteslocaleCode, since the immediate watcher reclaimscurrencyCodefrom the context on both sides.- On the client the shared instance is destroyed once its last subscriber unmounts. A
localeCodeset on a product page does not survive a visit to a page that renders no prices; the next instance falls back tobrowserLocale. If you need a session-wide locale, subscribe from somewhere that never unmounts. usePrice's parameter object typescurrencyCodeas required, sousePrice({ localeCode })does not compile. You have to pass acurrencyCodetoo, and animmediatewatcher runs its callback synchronously while the composable is still initialising — so if the context already carries a currency, the code you passed is overwritten beforeusePrice()even returns. Only when the context has not loaded yet does it survive, and then only until the first currency arrives.useProductPricereadscalculatedPrices[0]when that array is non-empty and falls back tocalculatedPrice. A tiered product's "normal" price is the first tier, notcalculatedPrice.displayFromistruewhen there is more than one entry incalculatedPrices, and the displayed price then switches to the cheapest tier rather than the first one. The second guard in the source,getProductTierPrices(product).length > 1, is redundant: the helper from@shopware/helpersmapscalculatedPricesone-to-one, so it can never disagree withdisplayFrom.displayFromVariantsreturnsnumber | false | undefined, not a boolean. It is the cheapest variant unit price when the product has aparentId,calculatedCheapestPrice.hasRangeis true, and that unit price differs fromcalculatedPrices[0](orcalculatedPrice) — note the comparison is against that price, not against the possibly-cheaper tierpriceresolved to.hasListPricereadslistPrice.percentage. A list price present with a zero or missing percentage counts as no reduction, so no strikethrough is rendered even thoughlistPriceexists.isListPriceis a deprecated alias ofhasListPrice. Both point at the same computed.regulationPricealways readscalculatedPrice.regulationPrice.price, never the tier price thatpriceresolved to. For a tiered product the two describe different prices.referencePricehas the same mismatch: it is read fromcalculatedPrices[0](orcalculatedPrice), not from the cheapest tier thatpriceresolved to. On a tiered product the "per unit" figure therefore belongs to a different tier than the price next to it.tierPriceslabels the last entryfrom {quantity}and every earlier oneto {quantity}, in English, from the helper. Translate the labels yourself if the storefront is localized.CalculatedPricehas notaxStatusand nonetPrice. Both live onCartPrice, wheretaxStatusisgross | net | tax-free— all three,grossincluded. For a product price, read the context'staxStateinstead.browserLocaleis resolved from theaccept-languageheader on the server and fromnavigator.languageon the client, so SSR output and hydration can format the same price differently. The starter makes this the common case rather than a rare one: itsrouteRulesputisron/**, so one visitor'saccept-languageproduces HTML replayed to everyone for 24 hours. Derive the formatting locale from the URL — the i18n prefix — or keep priced routes out ofisr.- A currency switch changes the formatter immediately but leaves already-fetched prices as they were. Refetch listings and carts after
setCurrency().
Common Mistakes
- Do not add or subtract tax in the frontend. Patch the context and refetch.
- Do not reach for
calculatedPrice.taxStatusorcalculatedPrice.netPrice. Neither field exists onCalculatedPrice. - Do not compute a discount percentage from two prices. Use
listPrice.percentage. - Do not read
product.calculatedPrice.unitPricedirectly on a tiered product. UseuseProductPrice. - Do not pass a product to
useProductPrice. It wants aRef, and a plain object renders empty strings instead of raising. - Do not assume
usePricearguments are inert. They are ignored only on the client, and only after an instance already exists. - Do not treat
displayFromVariantsas a boolean. A variant that is free resolves to0, so!!and a bare ternary both discard it and fall back to the current variant's dearer price. Narrow withtypeof … === "number". - Do not expect the number format to change with the language. Only the currency follows the context.
- Do not render a strikethrough whenever
listPriceexists. CheckhasListPrice. - Do not show
tierPriceslabels untranslated in a localized storefront. - Do not keep formatted price strings in state across a currency switch.
Testing Checklist
currencyCodeis non-empty by the time the first price renders, with no explicit initialization anywhere.getFormattedPrice(undefined)renders nothing rather thanNaNorundefined.- A product with a single price shows
calculatedPrice.unitPrice, formatted in the context currency. - A product with tiers shows the cheapest tier and a "from" prefix.
- A variant with a cheapest-price range shows the cheapest variant price and a "from" prefix.
- A reduced product renders the list price struck through with
listPrice.percentage, and a screen reader announces which of the two figures is current. - A product with a
listPricebut no percentage renders no strikethrough. - Switching the currency reformats every rendered price without a reload.
- Switching the currency and refetching a listing returns different price values.
- A gross and a net context produce different numbers for the same product.
- The SSR output and the hydrated DOM format the same price identically on an
isrroute.