Cross-Selling
Goal
Render the cross-selling groups of a product — "customers also bought", accessories, a related stream. The important part is what this operation does not accept: it declares no request body, so the criteria argument the composable advertises has nowhere to go, and the association context it takes is never used.
Shopware Flow
readProductCrossSellings post /product/{productId}/cross-selling is a POST with no request body. Its entire input is the product id in the path plus two optional headers, sw-language-id and sw-include-seo-urls. There is nothing to filter, sort or paginate.
The Store API also exposes readProductCrossSellingsGet get /product/{productId}/cross-selling, and it takes exactly the same three inputs — no _criteria query parameter. useProductAssociations always calls the POST variant and does not branch on cacheableReads, so these reads never become HTTP-cacheable the way the read composables listed under Caching do.
The response is a bare array — CrossSellingElementCollection is CrossSellingElement[], not an object wrapping one. Each CrossSellingElement carries its crossSelling configuration, its products, and its own total, so a page with three cross-selling groups gets all three, fully populated, in one request.
Step 1
Composable: Provide a product
useProductAssociations takes a ComputedRef of the product and throws immediately if it is empty. The product has to be resolved before the composable is created.
- Code
useProductAssociations(product, { associationContext: 'cross-selling' })- State
- product ref
- Types
- Product
Read the diagram from left to right:
- A resolved product ref is passed to
useProductAssociations(product, options), which throws if the ref is empty. loadAssociations({ searchParams: {} })is called explicitly — the composable loads nothing on mount.- The request carries the product id and, when
includeSeoUrlsis set, thesw-include-seo-urlsheader. - The response array becomes
productAssociations. - The UI filters out groups whose
productsarray is empty before rendering. - A failed request is caught and logged, leaving
productAssociationsat its previous value. - The UI reads the groups from
productAssociationsinstead of keeping its own copy.
You do not need this composable on a CMS-driven product page whose layout contains a cross-selling CMS element (CmsElementCrossSelling). The backend resolves that element's groups into the CMS page payload, so the request is only needed where you build the detail page yourself.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Resolve the product | useProductSearch().search(productId) | POST /product/{productId} | readProductDetail response |
| Load the groups | loadAssociations({ searchParams: {} }) | POST /product/{productId}/cross-selling | none — the operation declares no request body |
| Read the groups | productAssociations | POST /product/{productId}/cross-selling | readProductCrossSellings response |
| Read one group | group.crossSelling, group.products | none | CrossSellingElement |
| Read its configuration | group.crossSelling.position, .type | none | ProductCrossSelling |
The load row's Type cell is empty because there is nothing to type. That is the fact this whole recipe hangs on.
The first row assumes the default configuration. With shopware: { cacheableReads: true } in nuxt.config, useProductSearch switches to readProductDetailGet get /product/{productId} and moves the criteria into a _criteria query parameter. The cross-selling rows do not change: the composable never calls the GET variant, and neither variant accepts criteria.
Composables
Pick by scope — where the product ref comes from, then what you do with it:
| Composable | Scope | Reach for it when |
|---|---|---|
useProductSearch | one product, fetched by id | you build the detail page yourself and have to resolve the product first |
useProduct | the product a parent already provided | you are inside a detail page that injected the product |
useProductAssociations | the cross-selling groups of one product | rendering "customers also bought", accessories, or a related stream |
useInternationalization | link prefixing for the current locale | linking to the cross-sold products from a multi-language storefront |
useProductAssociations is the one this recipe is about:
- Create —
useProductAssociations(product, options).productis aComputedRef<Product>, not an id;optionsis{ associationContext, includeSeoUrls? }. - Read —
productAssociationsandisLoading, bothComputedRef. - Write —
loadAssociations({ searchParams: {} }), the only thing that fetches anything.
Seven things the generated reference will not tell you:
loadAssociationsdeclares aparamsargument withmethodandsearchParams, and the implementation takes no parameters at all. Both are discarded — but the argument is still required by the type, so a bareloadAssociations()fails to compile withTS2554. Pass{ searchParams: {} }and expect it to be thrown away.examples/product-detail-pagepasses a fullassociationsobject insidesearchParamsthat goes nowhere — treat it as a warning, not a pattern.options.associationContextaccepts"cross-selling" | "reviews", but the implementation always calls the cross-selling operation. Passing"reviews"fetches cross-sellings. UseuseProductReviewsinstead — see the Product Reviews recipe.- The composable throws
[useProductAssociations]: Product is not provided.during setup when the product ref is empty. It is athrowin the composable body, not a rejected promise, so it takes the whole component down — and on a server render it propagates to Nitro as a 500 for the entire route unless a<NuxtErrorBoundary>catches it. Resolve the product first. - Errors from the request are caught, logged to the console and swallowed. A resolved
loadAssociations()is not proof that anything was fetched. productAssociationsis backed by a plainref([])created per call. Nothing is provided or shared, so two components calling it for the same product each issue their own request.- It does not branch on
cacheableReads, unlikeuseProductSearchand the other composables listed under Caching. Every call is aPOST. includeSeoUrlsis the only option that changes the request. It addssw-include-seo-urls: true; without it the cross-sold products come back with noseoUrls.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type the response, one group, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type CrossSellingResponse =
operations["readProductCrossSellings post /product/{productId}/cross-selling"]["response"];
type CrossSellingCollection = Schemas["CrossSellingElementCollection"];
type CrossSellingElement = Schemas["CrossSellingElement"];
type CrossSellingConfig = Schemas["ProductCrossSelling"];CrossSellingCollection is declared as an array in the schema, so productAssociations is iterable directly. Most Store API list responses wrap their rows in an elements key; this one, like readNavigation and readBreadcrumb, does not.
Minimal Vue Example
<script setup lang="ts">
import { getProductRoute, getTranslatedProperty } from "@shopware/helpers";
import type { Schemas } from "#shopware";
const { product } = defineProps<{ product: Schemas["Product"] }>();
const localePath = (path: string) => path;
const { formatLink } = useInternationalization(localePath);
const { productAssociations, isLoading, loadAssociations } =
useProductAssociations(
computed(() => product),
{ associationContext: "cross-selling", includeSeoUrls: true },
);
const groups = computed(() =>
productAssociations.value.filter((group) => group.products.length > 0),
);
watch(
() => product.id,
() => loadAssociations({ searchParams: {} }),
{ immediate: import.meta.client },
);
</script>
<template>
<p role="status">{{ isLoading ? "Loading recommendations…" : "" }}</p>
<section v-for="group in groups" :key="group.crossSelling.id">
<h2>{{ getTranslatedProperty(group.crossSelling, "name") }}</h2>
<ul>
<li v-for="crossSellProduct in group.products" :key="crossSellProduct.id">
<NuxtLink :to="formatLink(getProductRoute(crossSellProduct))">
{{ getTranslatedProperty(crossSellProduct, "name") }}
</NuxtLink>
</li>
</ul>
<p v-if="group.total > group.products.length">
Showing {{ group.products.length }} of {{ group.total }}
</p>
</section>
</template>The groups are rendered stacked, each under its own h2, rather than as tabs. A tab strip needs the full tablist/tab/tabpanel pattern with roving focus to be reachable by keyboard, and none of that is about cross-selling — stacked headings are navigable out of the box and cannot strand the reader on a panel that no longer exists.
formatLink wraps getProductRoute because the helper returns an unprefixed route. Without the wrapper a customer browsing /de-DE lands on the default-locale URL. The resolver is what makes it work: formatLink returns the link untouched unless useInternationalization was created with one, so the path resolver from Nuxt i18n has to be resolved first and passed in — two lines, never one. The example above stands in an identity function for it, because the project these snippets compile against does not install @nuxtjs/i18n; in your own storefront that line is const localePath = useLocalePath();.
buildUrlPrefix from @shopware/helpers prefixes a route too, and you will see it in cms-base-layer components such as SwProductCard. Reach for it there, not here: that layer has no dependency on @nuxtjs/i18n, so it takes the prefix from an injected urlPrefix string instead. In template code the i18n resolver is available, and formatLink delegates to it — which is what honours a strategy such as prefix_except_default, where the default locale is supposed to carry no prefix at all. vue-starter-template calls formatLink at every one of its own link sites and buildUrlPrefix at none.
crossSelling.limit is configured in the Admin and caps how many products a group returns, so group.total can be the larger number. The operation takes no limit and no page of its own, so there is no way to load the remainder — render the count as information, not as a control.
State And Session
productAssociations is a local ref([]) inside each useProductAssociations() call. Nothing is provided or shared, so two instances for the same product each issue their own request.
The watcher is guarded with immediate: import.meta.client, so the load is deliberately client-only. That keeps session-dependent prices and rule-based exclusion out of the server-rendered response, which matters because catalog routes are ISR-cached — the cost is that the block is absent from the cached HTML and appears after hydration.
The request carries the sw-context-token like any other Store API call, and the response depends on that session. Prices on the cross-sold products are calculated for the current currency and tax state, and a product excluded by the customer's rules does not appear. Switching currency or language leaves the already-loaded groups in place, stale, until you call loadAssociations({ searchParams: {} }) again — the composable watches nothing itself, which is why the example wires its own watcher.
includeSeoUrls is the one option that has an effect. Setting it adds sw-include-seo-urls: true, which is what getProductRoute needs to build a link — without it the cross-sold products come back with no SEO URLs.
Edge Cases
- The operation declares no request body, which is the underlying reason
searchParamscannot work. There is nothing to send, and the GET variant has no_criteriaparameter either. CrossSellingElementCollectionis an array, not an object withelements. Iterate it directly.- A configured cross-selling group can return zero products — a stream that currently matches nothing, or products hidden by the customer's rules. Filter on
products.length. group.totalcan exceedgroup.products.lengthbecausecrossSelling.limitcaps how many products the group returns. There is no way to fetch the rest through this operation.- Without
includeSeoUrls: truethe returned products carry noseoUrls, sogetProductRoutefalls back to/detail/{id}. getProductRoutereturns an unprefixed route either way. On a localised storefront it has to be wrapped informatLinkfromuseInternationalization, as every call site invue-starter-templatedoes.formatLinksilently does nothing whenuseInternationalizationwas created without a path resolver, so the resolver has to be resolved into a local first. Nuxt i18n auto-imports the composable that returns it, not the resolver itself — a bare identifier is aReferenceError.- A failed first load renders nothing and is indistinguishable from a product that has no cross-selling at all:
productAssociationsstays[],isLoadingreturns tofalse, and no error is observable from outside the composable. Nothing the consumer writes can tell the two apart. - A failed reload looks identical to a successful one that changed nothing, because the errors are swallowed and the previous groups stay rendered.
loadAssociationshas no in-flight guard, no sequence token and no cancellation. Two overlapping calls both write toproductAssociationsand the last response to arrive wins regardless of the order they were issued, whileisLoadingflips back tofalseas soon as the first one settles. Guard the caller if you wire a reload to a control a customer can activate twice.- The composable passes no
fetchOptions, so there is no per-request timeout orAbortSignal. A request that never settles leavesisLoadingattruefor the life of the page — setruntimeConfig.apiClientConfig.timeoutif that matters. - Prices, availability and group membership all follow the session context, so the loaded groups go stale on a currency, language or login change and nothing reloads them for you.
- On a CMS-driven product page whose layout contains a cross-selling CMS element, the groups already arrive in the page payload as
content.data.crossSellings. Calling this composable there re-fetches data the page already has.
Common Mistakes
- Do not pass criteria to
loadAssociations. They are discarded — but the argument itself is required, so pass{ searchParams: {} }. - Do not use
associationContext: "reviews"and expect reviews. - Do not expect
loadAssociationsto run on mount. - Do not treat a resolved
loadAssociations()as proof the request succeeded. Errors are swallowed. - Do not read
productAssociations.elements. It is an array. - Do not render a group without checking
products.length. - Do not offer a "show more" control per group. The operation cannot page.
- Do not omit
includeSeoUrlswhen the groups link to product pages. - Do not link with a bare
getProductRoute. Wrap it informatLinkor a localised storefront drops the prefix. - Do not call
useInternationalization()with no argument and expectformatLinkto prefix anything. It returns the link untouched. - Do not reach for
buildUrlPrefixin template code because acms-base-layercomponent uses it. That helper is the fallback for a layer that cannot see Nuxt i18n. - Do not call this composable on a CMS product page that already has the data.
- Do not expect
cacheableReadsto make these requests cacheable. This composable always sends aPOST. - Do not keep an index into the rendered groups without clamping it. A reload can return fewer groups than are on screen.
Testing Checklist
- Creating the composable with an empty product ref throws during setup.
loadAssociations({ searchParams: {} })issues exactly onereadProductCrossSellings post /product/{productId}/cross-sellingrequest.- The request carries no body.
- With
includeSeoUrls: truethe request carries thesw-include-seo-urlsheader, and the rendered links use the SEO path. - Groups with an empty
productsarray are not rendered. - For a request that settles,
isLoadingistrueduring it andfalseafterwards on both the success and the failure path, because the composable usesfinally. A request that never settles leaves ittrue. - A failed reload leaves the previously rendered groups in place; a failed first load renders nothing.
- A group whose
totalexceeds itsproductslength renders the count without a paging control.