Product Listing and Filters
Goal
Build a product listing with filters, sorting, pagination and an infinite-scroll variant. The important part is that a listing response already contains everything the UI needs — elements, totals, available sortings, current filters and the aggregations the filter panel is built from — so the page is one request, not one per concern.
Shopware Flow
useListing wraps two different operations behind one API. With listingType: "categoryListing" it calls readProductListing post /product-listing/{categoryId}; with listingType: "productSearchListing" it calls searchPage post /search. Both send sw-include-seo-urls: true, so the products come back with the URLs a listing needs for its links.
The composable holds two listings rather than one. An initial listing is what the page was rendered with — typically the listing embedded in a category's CMS payload — and an applied listing is the result of the last search. getCurrentListing is the applied one falling back to the initial one, which is why the first render needs no request and a reset is a search rather than a rollback.
Step 1
Page: Seed from the CMS payload
A category page already carries a listing in its CMS data. setInitialListing stores it, so the first render needs no request of its own.
- Code
setInitialListing(cmsListingElement.data.listing)- State
- useListingInitial-categoryListing
- Types
- ProductListingResult
Read the diagram from left to right:
- The page seeds the initial listing from its CMS payload with
setInitialListing(). - A filter, a sorting select or a pagination control produces new criteria.
search()deep-merges those criteria over thedefaultSearchCriteriapassed touseListingand issues one request.- The Store API returns one page as a
ProductListingResult. - That result becomes the applied listing, shadowing the initial one.
getElements,getTotal,getCurrentPage,getSortingOrders,getCurrentFiltersandgetAvailableFiltersall read from it.loadMore()appends the next page's elements instead of replacing them — passed the same criteria, or the appended page ignores them.
You do not need a separate request for the filter options. They are the aggregations on the same response, converted into a filter model by the getListingFilters helper.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Seed from the CMS | setInitialListing(listing) | none | ProductListingResult |
| Search a category | search({ limit: 24 }) | POST /product-listing/{categoryId} | readProductListing body |
| Search by term | search({ search: term }) | POST /search | searchPage body |
| Read the page | getElements, getTotal | either | readProductListing response |
| Apply filters | setCurrentFilters([{ code, value }]) | either | ProductListingCriteria |
| Change the sorting | changeCurrentSortingOrder("name-asc") | either | ProductListingCriteria |
| Change the page | changeCurrentPage(2) | either | Criteria |
| Append the next page | loadMore({ ...criteria, p }) | either | searchPage response |
The two pagination rows do not use the same field. changeCurrentPage(page) sends page, which comes from the base Criteria, while loadMore() sends p, the listing-specific page parameter. Hover both chips to see where each is declared.
changeCurrentPage and changeCurrentSortingOrder both take an optional second query argument, applied as Object.assign({ page }, query) and Object.assign({ order }, query). It is merged last, so anything it carries wins over the field the method itself set. That argument is how a listing rebuilds its whole criteria while changing one thing — CmsElementProductListing in cms-base-layer uses it to rebuild a whitelisted criteria from the URL query on every navigation, coercing the strings the router hands it into the numbers and booleans the criteria declares.
Composables
Pick by scope — how much of the listing the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useListing | one listing | building any listing, filter panel, sorting or pagination |
useCategory | the category in context | you need the category itself — useListing consults it for you |
createCategoryListingContext | one shared category listing | a filter panel and a product grid are separate components |
useProductSearchListing | the shared search listing | building a search results page |
useListing is the one you reach for most:
- Read —
getElements,getTotal,getTotalPagesCount,getLimit,getCurrentPage,getCurrentSortingOrder,getSortingOrders. - Filter —
getInitialFilters,getAvailableFilters,getCurrentFilters,setCurrentFilters,resetFilters,filtersToQuery. - Act —
search,loadMore,changeCurrentPage,changeCurrentSortingOrder,setInitialListing. - State —
loading,loadingMore,getCurrentListing,getInitialListing.
Six things the generated reference will not tell you:
initSearch()is deprecated and is notsearch()with a return value. It runs the request and hands you the result without storing it, so the UI never updates. It is named here only so you recognise it in older code.search()deep-merges your criteria over thedefaultSearchCriteriayou passed touseListing, then calls the operation the listing type selected.loadMore()merges the same defaults, but it does not carry over the criteria of the lastsearch(). With no argument it sendsdefaultSearchCriteriaplus{ p }, so on a filtered or sorted listing you have to pass the filters and the sorting again.setCurrentFilters()narrows the products but not the filter options. A source comment states it outright: the aggregations are not reduced by the filter, so the option lists do not shrink as the customer selects. Sendreduce-aggregationswhen you do want them narrowed.loadingandloadingMoreare plain refs created per composable call, not injected like the listings themselves. Two components that share the same listing state still track their own loading flags.- Nothing here carries a request deadline, a cancellation signal or a sequence guard. Two overlapping searches are stored in the order their responses arrive, not the order they were sent. A deadline is available one layer down:
runtimeConfig.apiClientConfig.timeoutapplies to every call, andisTimeoutErrorfrom@shopware/api-clienttells a timeout apart from any other rejection. useCategoryis consulted automatically whenlistingTypeiscategoryListingand nocategoryIdwas passed. The category id comes fromcategory.value?.id, anduseCategory()throws when no category is in context, so the listing hard-depends on a category being resolved.
Two wrappers exist for sharing one listing across components. createCategoryListingContext(initialListing) must be called on a parent before any child calls useCategoryListing(), which throws otherwise. useProductSearchListing() is a shared composable and needs no setup. Both are documented in the source as temporary.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type criteria, results, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type CategoryListingBody =
operations["readProductListing post /product-listing/{categoryId}"]["body"];
type SearchBody = operations["searchPage post /search"]["body"];
type ProductListingResult = Schemas["ProductListingResult"];
type ProductListingCriteria = Schemas["ProductListingCriteria"];
type Product = Schemas["Product"];ProductListingCriteria is what setCurrentFilters keys its codes on. It extends the base Criteria, so filter, sort, page and the rest are accepted too; its own listing-specific codes are order, limit, p, manufacturer, min-price, max-price, rating, shipping-free, properties, property-whitelist, reduce-aggregations and the *-filter toggles. SearchBody adds search on top of it, which is the one field a category listing does not declare. Both bodies additionally intersect ProductListingFlags, which contributes the no-aggregations and only-aggregations flags.
Minimal Vue Example
<script setup lang="ts">
import { getTranslatedProperty } from "@shopware/helpers";
import type { LocationQuery } from "vue-router";
import type { Schemas } from "#shopware";
const props = defineProps<{
// the listing the category page was rendered with
initialListing: Schemas["ProductListingResult"];
}>();
const LIMIT = 24;
const {
getElements,
getTotal,
getCurrentPage,
getTotalPagesCount,
getSortingOrders,
getCurrentSortingOrder,
getInitialFilters,
loading,
loadingMore,
search,
loadMore,
setInitialListing,
} = useListing({ listingType: "categoryListing" });
const route = useRoute();
const router = useRouter();
const listingError = ref("");
// Renders before any request is sent: the CMS payload already carries a page.
setInitialListing(props.initialListing);
// getInitialFilters returns one entry per aggregation, except `properties`,
// which yields one entry per property group. Only these two are option lists;
// price, rating and shipping-free need their own controls, and categories is a
// search-listing filter a category listing cannot apply.
const optionFilters = computed(() =>
getInitialFilters.value.filter(
(filter) => filter.code === "manufacturer" || filter.code === "properties",
),
);
// The composable types availableSortings as a union that includes a plain
// { key, label }[], so narrowing it locally is what makes `translated` readable.
type SortOption = {
key: string;
label: string | null;
translated?: { label: string };
};
const sortOptions = computed<SortOption[]>(() => getSortingOrders.value ?? []);
// getListingFilters types its options far more loosely than the payload is:
// a property group carries PropertyGroupOption[] under `options`, a manufacturer
// aggregation carries ProductManufacturer[] under `entities`. Both have a name.
type FilterOption =
Schemas["PropertyGroupOption"] | Schemas["ProductManufacturer"];
const optionsOf = (filter: { options?: unknown; entities?: unknown }) =>
(filter.options ?? filter.entities ?? []) as FilterOption[];
// A repeated param (?manufacturer=a&manufacturer=b) arrives as an array.
// String() would join it with a comma and the API would read one unknown id.
const firstValue = (value: LocationQuery[string] | undefined) =>
(Array.isArray(value) ? value[0] : value) ?? "";
// The criteria expects pipe-joined ids, which is exactly what the URL holds.
const selectedIds = (code: string) =>
new Set(firstValue(route.query[code]).split("|").filter(Boolean));
const isSelected = (code: string, id: string) => selectedIds(code).has(id);
const toggleOption = (code: string, id: string) => {
const selected = selectedIds(code);
selected.has(id) ? selected.delete(id) : selected.add(id);
const query = { ...route.query, [code]: [...selected].join("|") };
// An empty value would be sent as "" and come back as [""].
if (!query[code]) delete query[code];
return router.push({ query });
};
const changeSorting = (order: string) =>
router.push({ query: { ...route.query, order } });
// Filters only. The sorting is not a filter, so it survives the reset — the
// same distinction resetFilters() draws when it keeps the search term.
const clearFilters = () =>
router.push({ query: route.query.order ? { order: route.query.order } : {} });
function buildCriteria(query: LocationQuery) {
const criteria: Schemas["ProductListingCriteria"] = { limit: LIMIT };
const manufacturer = firstValue(query.manufacturer);
if (manufacturer) criteria.manufacturer = manufacturer;
const properties = firstValue(query.properties);
if (properties) criteria.properties = properties;
const order = firstValue(query.order);
if (order) criteria.order = order;
return criteria;
}
// useListing stores whatever response lands last, so back/forward or a quick
// second toggle can leave a stale listing on screen. The token cannot unsend the
// request or reorder the writes — it only keeps a stale failure from replacing
// the error state of the request that superseded it.
let requestId = 0;
const searchFromQuery = async (query: LocationQuery) => {
const id = ++requestId;
listingError.value = "";
try {
await search(buildCriteria(query));
} catch (error) {
// The raw ApiClientError carries status and details.errors — log it rather
// than rendering it, and show the customer one sentence.
console.error(error);
if (id === requestId)
listingError.value = "The products could not be loaded.";
}
};
const loadNext = async () => {
if (loadingMore.value || !hasMore.value) return;
listingError.value = "";
try {
// loadMore() does not reuse the criteria of the last search, so the filters,
// the sorting and the limit have to be passed again or the page ignores them.
await loadMore({
...buildCriteria(route.query),
p: getCurrentPage.value + 1,
});
} catch (error) {
console.error(error);
listingError.value = "More products could not be loaded.";
}
};
// The only place that fetches, so back, forward and a refresh all agree.
watch(() => route.query, searchFromQuery, { deep: true });
// A deep link arrives with filters the seeded listing does not reflect.
// Client-only on purpose: useListing keeps its listings in plain refs rather
// than useState, so a server-side search is never serialized into the payload.
// Running it during SSR would spend a request whose result is discarded and
// render the loading state into the cached HTML.
onMounted(() => {
if (Object.keys(route.query).length) searchFromQuery(route.query);
});
const hasMore = computed(() => getCurrentPage.value < getTotalPagesCount.value);
// Announces the count after a filter change and the new total after an
// append, so "Show more" is not silent for a screen reader.
const statusMessage = computed(() =>
loading.value
? "Loading products…"
: `${getElements.value.length} of ${getTotal.value} products`,
);
</script>
<template>
<!-- Rendered unconditionally so the text is announced when it is written. -->
<p role="alert">{{ listingError }}</p>
<aside aria-labelledby="filters-heading">
<h2 id="filters-heading">Filters</h2>
<!-- every property group shares code "properties", so key on the id -->
<fieldset v-for="filter in optionFilters" :key="filter.id ?? filter.code">
<legend>{{ filter.label }}</legend>
<!-- Not disabled while loading: disabling the control being operated
blurs it, and focus drops to the document body. -->
<label v-for="option in optionsOf(filter)" :key="option.id">
<input
type="checkbox"
:checked="isSelected(filter.code, option.id)"
@change="toggleOption(filter.code, option.id)"
/>
{{ getTranslatedProperty(option, "name") }}
</label>
</fieldset>
<button type="button" @click="clearFilters()">Clear all filters</button>
</aside>
<section aria-labelledby="results-heading" :aria-busy="loading">
<h2 id="results-heading">Products</h2>
<label>
Sort by
<select
:value="getCurrentSortingOrder"
@change="changeSorting(($event.target as HTMLSelectElement).value)"
>
<option
v-for="sorting in sortOptions"
:key="sorting.key"
:value="sorting.key"
>
{{ sorting.translated?.label ?? sorting.label }}
</option>
</select>
</label>
<!-- One live region for the count and the loading state, so a filter
change is announced instead of silently swapping the list. -->
<p role="status">{{ statusMessage }}</p>
<p v-if="!loading && !getElements.length">
No products match these filters.
</p>
<!-- The previous page stays on screen while the next one loads;
aria-busy is what tells assistive tech to hold off. -->
<ul v-else>
<li v-for="product in getElements" :key="product.id">
{{ getTranslatedProperty(product, "name") }}
</li>
</ul>
<!-- Always mounted and aria-disabled rather than disabled: removing or
disabling the focused button drops focus to the document body.
loadNext() guards the click. -->
<button
type="button"
:aria-disabled="loadingMore || !hasMore"
@click="loadNext()"
>
{{
loadingMore ? "Loading…" : hasMore ? "Show more" : "All products loaded"
}}
</button>
</section>
</template>The example is URL-first: a control writes its selection to the query, and one watcher turns the query into a single search(). That is what makes the back button, a refresh and a shared link land on the same listing, and it is the pattern useListingFilters uses in cms-base-layer. setCurrentFilters() is the shorter alternative when the listing state does not have to survive a reload — it takes the shortcut filter params directly and searches for you. filtersToQuery() is the piece for the other direction: it turns a ProductListingCriteria into a query object, joining array values with |. It gates on truthiness rather than on emptiness, so shipping-free: false, rating: 0 and min-price: 0 are dropped along with the genuinely empty values.
Everything the listing needs from the URL goes through buildCriteria, including the page size and the sorting. That matters most for loadMore(): called with no argument it sends { p } and nothing else, so the appended page would ignore the filters and come back at the shop's default page size rather than at LIMIT. cms-base-layer solves the same problem the same way, in CmsElementProductListing, by passing a rebuilt criteria as the second argument to changeCurrentPage.
The filter panel reads getInitialFilters rather than getAvailableFilters. Both come from getListingFilters, but the initial one is computed over the listing the page was rendered with, so the options do not reshuffle while the customer is selecting.
It also narrows that list. getListingFilters returns one entry per aggregation, except properties, which expands into one entry per property group; the options and categories-counts aggregations are dropped entirely. A category listing carries price, rating and shipping-free alongside manufacturer and properties. Those three hold no options to tick — they are labelled with the raw aggregation name and need a range input and toggles of their own. A minimal example drops them; cms-base-layer instead routes each code to its own component in SwProductListingFilter, and drops only the categories filter, which is search-only, in getVisibleListingFilters.
Each remaining filter carries its options under options for a property group and under entities for manufacturers and categories, which is why optionsOf falls back from one to the other. It also asserts the element type: getListingFilters declares those arrays as little more than { id, translated }, so getTranslatedProperty(option, "name") does not typecheck against them even though every real payload has a name. SwFilterProperties in cms-base-layer solves the same mismatch with a generic that pins options to PropertyGroupOption[] and entities to ProductManufacturer[].
The list key is filter.id ?? filter.code. Property-group entries carry an id, and so does the categories filter; manufacturer, price, rating and shipping-free do not. The fallback is what keeps those four apart, and the id is what keeps the property groups apart — every property group is emitted as its own filter sharing the single properties code.
State And Session
The two listings live in the useListingInitial-<listingKey> and useListingApplied-<listingKey> injections, keyed by listing type. That keying is what lets a category listing and a search listing coexist on one page without overwriting each other — and it is also why a useListing() call in a child component picks up the listing a parent already provided. Siblings do not share: inject resolves through the parent chain, so two calls that are not in an ancestor relationship each keep their own state.
A listing is context-dependent. Prices come back calculated in the current currency and tax state, and availability depends on the customer's rules, so a currency or a language switch invalidates everything currently rendered. Nothing in useListing reacts to that — refetch with search() after a context change.
loading and loadingMore are separate on purpose. search() toggles loading and replaces the elements; loadMore() toggles loadingMore and appends to them. Binding a skeleton to loading therefore does not blank the list during an infinite-scroll fetch.
Edge Cases
loadMore()with no argument sends onlyp. On a filtered or sorted listing you have to pass your criteria again, or the appended page ignores the filters and comes back at the shop's default page size instead of yours.loadMore()copies onlypageandelementsfrom the response and keeps everything else from the previous listing.getTotalandgetAvailableFiltersstay at the values from the earlier request.search(),loadMore()andsetCurrentFilters()reject on API failure and reset their loading flag in afinally. A rejected call looks exactly like a completed one unless you catch it.useListinghas no request cancellation and no sequence guard. Two overlapping searches are stored in the order their responses arrive, not the order they were sent, so a slow first request can overwrite a fast second one. Add your own token when a control can be operated twice before the first response lands.- Nothing in the composables carries a request deadline. Configure
apiClientConfig.timeoutand branch onisTimeoutError, or a request that never settles leaves the listing on its loading state with no error and no way out. initSearch()is deprecated and behaves differently fromsearch(): it returns the result and does not store it, so the UI does not update. Usesearch()unless you specifically want the raw result.setCurrentFiltersnarrows the products but not the filter options. A source comment states this explicitly: the aggregations are not reduced by the filter, so the option lists do not shrink as the customer selects. Sendreduce-aggregationsin the criteria when you do want them narrowed to the values that would still return products.setCurrentFiltersrewrites the current filters before searching: aquerykey is added holding the search term —searchis kept alongside it, so both go out in the body — andmanufacturerandpropertiesare joined with|. Passing an array where the criteria expects a pipe-joined string sends the wrong shape.- An emptied selection round-trips through
join("|")andsplit("|")and comes back as[""], a one-element array holding an empty string. That write-back only happens once an applied listing exists, and it does not need you to send""— an already-empty array joins to""on its own. resetFilters()always sends asearchkey. That field exists onsearchPage post /searchbut not onreadProductListing post /product-listing/{categoryId}, so on a category listing it is a key the operation does not declare.resetFilters()deliberately keeps the search term. It clears filters, not the query.- With
listingType: "categoryListing"and nocategoryId, the composable reads the id fromuseCategory(), which throws aContextErrorwhen no category is in context. The failure happens at theuseListing()call, not as a request withundefinedin the path. getInitialFiltersandgetAvailableFiltersreturn one entry per aggregation, exceptproperties, which expands into one entry per property group; theoptionsandcategories-countsaggregations are dropped. Not every entry has selectable options.- Property-group entries carry an
id, and so does thecategoriesfilter;manufacturer,price,ratingandshipping-freedo not. Key a filter list onfilter.id ?? filter.code— every property group is a separate filter sharing thepropertiescode. - A search listing can also carry a
categoriesfilter. Its code is not part ofProductListingCriteria; it is applied as a post filter, andcms-base-layerhides it on a category listing because it would otherwise render a checkbox the next query sync resets and that never changes the result set. getLimitfalls back to thelimitfromdefaultSearchCriteriaand then to10. It is not the shop's configured products-per-page until a response has arrived.getTotalPagesCountis derived fromgetTotal / getLimit, so it is0until a listing exists. A page seeded withsetInitialListing()has one on the first render; a page that fetches on mount does not, so a "last page" check there needs a guard.getAvailableFiltersreads the applied listing's aggregations and falls back to the current listing's. SincegetCurrentListingis the applied listing or the initial one, that fallback only ever resolves to the initial listing.getInitialFiltersalways reads the initial listing — useful for a filter panel that must not reshuffle as the customer filters.changeCurrentSortingOrdersendsorder, which sorts by one ofavailableSortings. The criteria also has asortfield, and the schema warns against using both together.
Common Mistakes
- Do not request the filter options separately. They are the aggregations on the listing response.
- Do not call
loadMore()with no criteria on a filtered listing. It sends onlyp. - Do not use
initSearch()to load a listing. It does not update the state. - Do not expect the option lists to shrink as filters are applied. They only do when you send
reduce-aggregations. - Do not render every entry of
getInitialFiltersas a list of checkboxes.price,ratingandshipping-freecarry no options. - Do not key a filter list on
filter.code. Every property group sharesproperties. - Do not pass arrays for
manufacturerorproperties. The criteria expects a|-joined string. - Do not read a repeated query parameter with
String(). The router hands you an array, andString()joins it with a comma into one id the API cannot resolve. - Do not bind an infinite-scroll spinner to
loading. UseloadingMore. - Do not disable the control a customer is operating while its request is in flight. The browser blurs a disabled element and focus falls to the document body.
- Do not swallow the rejection.
ApiClientErrorcarries the status and the backend's own messages — log it, and show the customer one sentence. - Do not call
useCategoryListing()withoutcreateCategoryListingContext()on a parent — it throws by design. - Do not send both
orderandsort. The schema documents that as unpredictable. - Do not keep a local copy of the elements, the total or the current page.
- Do not assume the listing survives a currency or language switch. Refetch after a context change.
Testing Checklist
- A page seeded with
setInitialListing()renders products before any request is sent. search({ limit: 24 })calls the operation matching the listing type exactly once.- Both listing requests carry the
sw-include-seo-urlsheader. - Applying a manufacturer filter issues one request and updates
getElementsandgetTotal. - A deep link that already carries filter params renders the filtered listing, not the seeded one.
- A URL with a repeated filter param renders the same listing as the single-value form.
- Applying a filter leaves the
getAvailableFiltersoption lists unchanged, andreduce-aggregationsnarrows them. resetFilters()clears the filters but keeps the search term.changeCurrentSortingOrder(key)sendsorderand re-renders in the new order.changeCurrentPage(2)replaces the elements, whileloadMore()appends to them.loadMore()on a filtered listing appends products that still match the filter, at the same page size.loadingistrueduring asearch()andfalseduring aloadMore().- A failed
search()shows an error, keeps the previous products on screen, and lets the customer retry. - A failed
loadMore()leaves the already-loaded products in place and says that more could not be loaded. - Toggling three filters quickly leaves whichever response arrives last on screen.
useListinghas no sequence guard, so a delayed first response replaces a newer one — assert that behaviour rather than a newest-wins one. - A superseded request that fails does not overwrite the error state of the request that replaced it.
- The result count, the loading state and the error are announced to a screen reader.
- An empty result renders an empty state rather than a stale page.