Product Variants
Goal
Build a variant selector on a product detail page: render the option groups, track the selection, resolve the matching variant and show it. The important part is that resolving a variant is a filtered product search returning a deliberately trimmed product, which is why the default behaviour is to navigate to it rather than merge it into the page.
Shopware Flow
The Store API has an operation built for this — searchProductVariantIds post /product/{productId}/find-variant — and Shopware Frontends does not use it. findVariantForSelectedOptions instead searches the product list with an equals filter on parentId and one equals filter per selected option id, limited to one result.
That search carries an includes clause restricting the product to id, translated, productNumber and seoUrls, plus a seoUrls association and a seo_url include for seoPathInfo. The result is therefore a link target, not a product you can render. Which is exactly why the shipped configurator pushes the router to the variant's URL and lets the page reload the real product.
The request is readProduct post /product by default. With shopware: { cacheableReads: true } — which vue-starter-template ships with — the composable sends the identical criteria to readProductGet get /product in the _criteria query parameter instead, so the storefront can cache it. Filter, includes and result are the same either way; only the transport changes. See Caching best practices.
Step 1
Page: Provide the product
useProduct(product, configurator) seeds the product and configurator context. useProductConfigurator takes no arguments and reads both from there, so the context has to exist before it runs.
- Code
useProduct(product, configurator)- State
- product, configurator
- Types
- ProductDetailResponse
Read the diagram from left to right:
- A parent component calls
useProduct(product, configurator)to provide both contexts. useProductConfigurator()builds the initial selection fromproduct.optionIds, keyed by translated group name.- Choosing an option calls
handleChange(groupName, optionId, onChangeHandled?), which updates the selection and awaits the callback if you passed one. findVariantForSelectedOptions()searches the product list — from that callback, or from an explicit action in your own UI.- The response is one product carrying only the fields the
includesclause allowed. - The UI reads the selection from
getSelectedOptionsinstead of keeping its own copy, and either navigates to the resolved variant or emits it intochangeVariant().
You do not get a request from handleChange itself. The composable separates "the customer changed the selection" from "resolve what that selection means", and the second half is yours to trigger.
cms-base-layer ships that second half: SwVariantConfigurator renders the groups and resolves the variant, with an allowRedirect prop that is true by default. vue-starter-template has no selector of its own — it extends @shopware/cms-base-layer, so a CMS-rendered product page gets that one as-is. Read it before writing your own, and read it knowing what it does with a miss.
At the default it navigates on every change, including the ones that resolve nothing. getProductRoute(undefined) carries path: "/", and buildUrlPrefix always returns an object — { path: "" } at worst — so the component's allowRedirect && selectedOptionsVariantPath guard never blocks anything. Despite its name, that variable holds the route object passed to router.push, and an object is always truthy. An unavailable combination therefore sends the customer to the home page, or to /<prefix>/ on a localised storefront. Pass :allow-redirect="false" and handle the miss yourself if that is not what you want.
The same default makes the change event unreachable. CmsElementBuyBox wires it to changeVariant, but only a project that renders SwVariantConfigurator itself with :allow-redirect="false" ever receives it. On a CMS-rendered page that means overriding CmsElementBuyBox, and CMS elements resolve through resolveComponent, so the override only takes effect from a directory registered global: true — app/components/cms/ in vue-starter-template. Anywhere else the base layer's component keeps rendering, with no error and no warning; see Overwriting CMS components. The try/catch around the component's router.push is synchronous around a promise, so nothing a navigation produces lands in it either.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Load the product page | useProductSearch().search(productId) | POST /product/{productId} (GET with cacheableReads) | readProductDetail response |
| Read the option groups | getOptionGroups | none | PropertyGroup |
| Change one option | handleChange(groupName, optionId, callback) | none | PropertyGroupOption |
| Resolve the variant | findVariantForSelectedOptions() | POST /product (GET with cacheableReads) | readProduct body |
| Read the trimmed variant | response.data.elements?.[0] | same request | readProduct response |
| Merge without navigating | changeVariant(variant) | none | Product |
| Use the dedicated route | invoke("searchProductVariantIds post /product/{productId}/find-variant") | POST /product/{productId}/find-variant | searchProductVariantIds body |
The last row has no composable. It takes the selected options — as an array of option ids, or as a map keyed by group id, not by group name — and returns the found combination with the variant id, so a custom selector that only needs an id can avoid the product search entirely. The generated type does not encode that key: options arrives as string[] | { [key: string]: string }, because the groupId => optionId wording sits on the schema's oneOf branches and only property-level descriptions survive into the .d.ts — switchedGroup keeps its comment, options does not. Read the key convention from the schema, not from the type.
Its response type is the one place on this page where the generated contract does not match the route. FindProductVariantRouteResponse nests the payload under an optional foundCombination object, while the route answers with the FoundCombination struct flat — variantId, options and apiAlias at the root — on the POST operation and on searchProductVariantIdsGet alike. Read variantId from the response root and type it locally; typed access through the generated response points one level too deep and reads undefined.
Composables
Pick by scope — how much of the product page the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useProductSearch | one product, by id | loading the detail page that the selector lives on |
useProduct | the shared product context | reading the current product or merging a resolved variant |
useProductConfigurator | the option groups and choice | rendering the selector and resolving the selected combination |
useProductConfigurator is the one this recipe is about:
- Read —
getOptionGroups(the configurator from the product context),getSelectedOptions(a group name to option id map),isLoadingOptions. - Act —
handleChange(group, option, onChangeHandled?)records a choice,findVariantForSelectedOptions(options?)resolves it.
Six things the generated reference will not tell you:
getSelectedOptionsis keyed by the translated group name, andhandleChangeexpects that same key. A group id in that position silently creates a second entry for the group, and the search then filters on two options of one group and matches nothing.handleChangesends no request. It writes the selection and awaitsonChangeHandled, so without that callback the selection changes and nothing else happens.findVariantForSelectedOptions(options?)readsObject.values()of the map you pass, so only the option ids matter — the keys of an override map are ignored.isLoadingOptionsis initialised fromproduct.options?.lengthand is never written again by the composable. Own the loading flag in your component.useProduct(product, configurator)copies the values it is given (ref(unref(context))), so the context is a snapshot taken during setup, not a live link to the ref you passed.changeVariant(variant)is the supported way to update it.useProduct()with no arguments injects, and injection only works while a component is setting up. Calling it from an event handler throwsinjectLocal must be called in setup. Destructure what you need —changeVariant,product— from theuseProduct(...)call insetupinstead.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type the configurator, the variant search, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type ProductDetailResponse = Schemas["ProductDetailResponse"];
type PropertyGroup = Schemas["PropertyGroup"];
type PropertyGroupOption = Schemas["PropertyGroupOption"];
type FindVariantBody =
operations["searchProductVariantIds post /product/{productId}/find-variant"]["body"];
type Product = Schemas["Product"];
// what the find-variant route actually answers with
type FoundCombination = {
variantId?: string;
options?: string[];
};ProductDetailResponse is where the configurator comes from, and only product is required on it. configurator?: PropertyGroup[] is the optional half — the same array getOptionGroups returns — so a product that is not configurable answers without it and the selector has nothing to render.
FoundCombination is written by hand for the mismatch above: the generated response type puts those two fields inside a foundCombination object that the route does not send.
Minimal Vue Example
<script setup lang="ts">
import {
buildUrlPrefix,
getProductRoute,
getTranslatedProperty,
} from "@shopware/helpers";
import type { Schemas } from "#shopware";
const { product, configurator } = defineProps<{
product: Schemas["Product"];
// optional on ProductDetailResponse, so optional here
configurator?: Schemas["PropertyGroup"][];
}>();
useProduct(product, configurator);
const {
getOptionGroups,
getSelectedOptions,
handleChange,
findVariantForSelectedOptions,
} = useProductConfigurator();
const router = useRouter();
const { getUrlPrefix } = useUrlResolver();
const isResolving = ref(false);
const resolveMessage = ref("");
const isOptionSelected = (optionId: string) =>
Object.values(getSelectedOptions.value).includes(optionId);
const selectOption = (group: Schemas["PropertyGroup"], optionId: string) =>
handleChange(getTranslatedProperty(group, "name"), optionId);
const resolveVariant = async () => {
if (isResolving.value) return;
resolveMessage.value = "";
isResolving.value = true;
try {
const variant = await findVariantForSelectedOptions();
if (!variant) {
resolveMessage.value = "We could not load that combination. Try again.";
return;
}
const failure = await router.push(
buildUrlPrefix(getProductRoute(variant), getUrlPrefix()),
);
if (failure) resolveMessage.value = "We could not open that variant.";
} catch {
resolveMessage.value = "We could not open that variant.";
} finally {
isResolving.value = false;
}
};
</script>
<template>
<form @submit.prevent="resolveVariant">
<p v-if="resolveMessage" role="alert">{{ resolveMessage }}</p>
<fieldset
v-for="group in getOptionGroups"
:key="group.id"
:aria-busy="isResolving"
>
<legend>{{ getTranslatedProperty(group, "name") }}</legend>
<label v-for="option in group.options ?? []" :key="option.id">
<input
type="radio"
:name="group.id"
:value="option.id"
:checked="isOptionSelected(option.id)"
@change="selectOption(group, option.id)"
/>
{{ getTranslatedProperty(option, "name") }}
</label>
</fieldset>
<button type="submit" :aria-disabled="isResolving">
Show this variant
</button>
<p v-if="isResolving" role="status">Resolving the selected variant…</p>
</form>
</template>The sample resolves from a submit button rather than from handleChange's callback, and that is the one deliberate departure from SwVariantConfigurator. A callback that navigates turns a radio group into a trap: arrow keys move and check, so every option a keyboard customer passes over fires change and routes them away before they reach the one they wanted. Pass the callback when the outcome stays on the page; resolve from an explicit action when it is a route change.
To keep the customer on the page instead, destructure changeVariant from the useProduct(product, configurator) call in setup and use it in place of the router.push — not useProduct().changeVariant(variant), which injects and throws outside setup. The cost is described below: the merged product only carries the fields the variant search asked for.
State And Session
The product and the configurator live in the product and configurator injections that useProduct provides. useProductConfigurator takes no arguments and reads both from there, so it works in the component that called useProduct(product, configurator) and in anything below it — otherwise useProduct() throws a ContextError.
The selection itself is local to the useProductConfigurator() instance, not shared. Two selectors on one page each keep their own map, but both read the same product context.
changeVariant(variant) writes into the shared product context with Object.assign({}, current, variant). Because it merges rather than replaces, every field absent from the partial keeps the previous variant's value — which is the whole reason the shipped configurator prefers a route change.
Edge Cases
- The initial map is built once during setup and never rebuilt. An
optionIdwhose option is in no configurator group is skipped, because the group lookup returns an empty name. getSelectedOptionsis keyed by the translated group name, with a fallback to the untranslatedname. A language switch normally redirects or reloads, so the map is rebuilt — see the language and currency recipe. Switch in place and it is not:handleChangewrites the new translation as an additional key, the stale one stays, and two options of the same group go into the filter and match nothing.findVariantForSelectedOptionscatches its own errors, logs them, and returnsundefined. A failed request and an unavailable combination are indistinguishable from the outside.- It accepts no abort signal, and no timeout is armed unless you set
runtimeConfig.apiClientConfig.timeoutin milliseconds. Without one, a request that hangs never settles and nothing clears your pending flag. router.pushresolves with aNavigationFailureinstead of throwing when a guard aborts the navigation or the target is the current route. Check the resolved value; only a guard that throws reaches acatch.- The variant search restricts the product to
id,translated,productNumberandseoUrls. Passing that intochangeVariantleaves price, stock, cover and every other field at the previous variant's values. - The search filters on
parentId, so it only makes sense for a variant of a configurable product. A simple product carriesparentId: null, andequalsonnullmatches every parentless product in the catalog. It also has nooptionIdsand an empty configurator, so a selector built on it renders no groups — but callfindVariantForSelectedOptions()yourself with an empty selection and nothing narrows the filter: the search answers200with the first parentless product it finds, and you navigate the customer to a product nobody picked. Checkproduct.parentIdbefore you resolve. A response that omitsparentIdentirely drops the key from the filter instead, and that is what earns a400 FRAMEWORK__INVALID_FILTER_QUERY. - The selection is committed before your callback runs and is never rolled back. After a combination that resolves to nothing,
getSelectedOptionsstill holds the option that failed, so every later pick in another group carries it into the filter and also matches nothing. Restore the previous value yourself if the customer needs a way back. useProductConfigurator()readsproduct.value.optionsduring setup without a guard. An empty product context throws fromuseProductfirst, so provide it before mounting the selector.getOptionGroupsis empty for a product without a configurator, which is the correct signal not to render a selector at all.- A combination the catalog does not stock returns no element. Handle
undefinedas "unavailable", not as an error.
Common Mistakes
- Do not key the selection by group id.
handleChangeexpects the translated group name. - Do not expect
handleChangeto resolve the variant. Trigger the search yourself — from itsonChangeHandledcallback, or from an action of your own. - Do not render the resolved variant directly. It has almost no fields.
- Do not use
changeVariantwith the search result unless you also refetch the full product. - Do not rely on
isLoadingOptionsfor the spinner. - Do not call
useProductConfigurator()withoutuseProduct(product, configurator)in the same component or above it. - Do not call
useProduct()from an event handler. It injects, which only works duringsetup. - Do not resolve the variant from the radio's
changeevent when the outcome is a route change. Arrow keys check every option they pass over. - Do not disable the option groups while resolving. A disabled control cannot hold focus, so a keyboard customer is thrown back to the top of the document mid-selection — use
aria-busyand guard the handler. - Do not push
getProductRoute(variant)unprefixed. Wrap it inbuildUrlPrefixwithuseUrlResolver().getUrlPrefix(), or a localised storefront silently drops its language prefix. - Do not treat
undefinedfromfindVariantForSelectedOptionsas a bug — it also means the request failed silently. - Do not assert the POST route in tests or proxies without checking
cacheableReads. With the flag on, the same search goes out asGET /product. - Do not build the variant URL by hand.
getProductRouteuses theseoUrlsthe search asked for.
Testing Checklist
- A product without a configurator renders no option groups.
- The initial selection matches
product.optionIds, one option per group, and drops ids that belong to no group. - Choosing an option updates
getSelectedOptionsand issues no request. - Submitting issues exactly one variant search —
POST /product, orGET /productwhencacheableReadsis enabled. - That request filters on
parentIdand on oneoptionIdsvalue per selected option. - A resolvable combination navigates to the variant's SEO URL, keeping the active language prefix.
- An unavailable combination renders a message, stays on the page, and can be submitted again.
- A failing request is reported the same way, because the composable swallows it.
- Arrowing through a group changes the selection without navigating.
- The option groups are
aria-busywhile a variant is being resolved and stay focusable.