Collection Filters
Once you have a collection page, the next thing shoppers usually want is a way to narrow it down. The Storefront API can do the filtering for you. It tells you which filters a collection supports, and it applies the ones the shopper picks. This recipe builds a filter sidebar with a small component for each kind of filter, including a price range.









<script setup lang="ts">
import type { ProductFilter } from '#shopify/storefront'
const route = useRoute()
const router = useRouter()
const applied = computed<ProductFilter[]>({
get: () => route.query.filters ? JSON.parse(route.query.filters as string) : [],
set: filters => router.push({ query: filters.length ? { filters: JSON.stringify(filters) } : {} }),
})
const { data: collection } = await useStorefrontData(`collection-${route.params.handle}`, `#graphql
query GetCollectionFilters($handle: String!, $first: Int, $filters: [ProductFilter!]) {
collection(handle: $handle) {
title
products(first: $first, filters: $filters) {
filters {
...FilterFields
}
nodes {
...ProductFields
}
}
}
}
${FILTER_FRAGMENT}
${PRODUCT_FRAGMENT}
`, {
variables: computed(() => ({
handle: route.params.handle,
first: 12,
filters: applied.value,
})),
transform: data => data.collection,
watch: [() => route.query.filters],
})
const facets = computed(() => collection.value?.products.filters ?? [])
const products = computed(() => collection.value?.products.nodes ?? [])
</script>
<template>
<div class="grid gap-8 md:grid-cols-4">
<aside class="space-y-4">
<div class="flex items-center justify-between">
<p class="font-bold">
Filters
</p>
<UButton
v-if="applied.length"
variant="link"
size="xs"
@click="applied = []"
>
Clear
</UButton>
</div>
<template
v-for="facet in facets"
:key="facet.id"
>
<FilterPrice
v-if="facet.type === 'PRICE_RANGE'"
v-model="applied"
:facet="facet"
/>
<FilterList
v-else
v-model="applied"
:facet="facet"
/>
</template>
</aside>
<div class="md:col-span-3">
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
</div>
</div>
</div>
</template>
This recipe reuses the ProductCard component and the PRODUCT_FRAGMENT from the
collection page recipe, so it can stay focused on the filtering itself.
You also need a collection with a few products, and filters turned on for it in Shopify's Search & Discovery app.
Asking Shopify for the filters
You don't hardcode the list of filters.
The products connection has a filters field that returns every filter the collection supports, along with its
type, its values, and how many products match each one:
products(first: $first, filters: $filters) {
filters {
...FilterFields
}
nodes {
...ProductFields
}
}
We pull the filter fields into a fragment of their own.
That keeps the query tidy, and it gives us a FilterFieldsFragment type to hand to the filter components later:
export const FILTER_FRAGMENT = `#graphql
fragment FilterFields on Filter {
id
label
type
values {
id
label
count
input
}
}
`
The filters argument on that same products field is what actually narrows the results.
It takes an array of ProductFilter objects, so an empty array returns everything and a filled one applies each filter
on top of the last.
Holding the applied filters
Every value comes with an input field, which is a small JSON string that already is a valid ProductFilter.
The list filters hand that straight back, and the price filter builds one itself.
Either way we end up with ProductFilter objects, and we keep them in the URL so a filtered view is shareable and
survives a refresh, the same way the collection page keeps its pagination cursor there.
We read the list out of the query string and write it back on every change with a single writable computed:
const applied = computed<ProductFilter[]>({
get: () => route.query.filters ? JSON.parse(route.query.filters as string) : [],
set: filters => router.push({ query: filters.length ? { filters: JSON.stringify(filters) } : {} }),
})
Because it's writable, the filter components can keep using v-model without knowing the state lives in the URL.
Setting it pushes a new query, and watch on that query param refetches whenever it changes:
const { data: collection } = await useStorefrontData(key, query, {
variables: computed(() => ({
handle: route.params.handle,
first: 12,
filters: applied.value,
})),
transform: data => data.collection,
watch: [() => route.query.filters],
})
A component per filter type
Each facet carries a type, so instead of one big block we render a component per type and let it own its own bit of
UI.
Both components share the same applied list through v-model, so any of them can add or remove its own filters:
<template
v-for="facet in facets"
:key="facet.id"
>
<FilterPrice
v-if="facet.type === 'PRICE_RANGE'"
v-model="applied"
:facet="facet"
/>
<FilterList
v-else
v-model="applied"
:facet="facet"
/>
</template>
Supporting another type later is just one more branch and one more small component.
The list filter
LIST filters (availability, tags, and the like) are the checkbox kind.
Each value's input is the exact ProductFilter to apply, so ticking a box adds that object to the list and unticking
it takes it back out:
<script setup lang="ts">
import type { FilterFieldsFragment, ProductFilter } from '#shopify/storefront'
const props = defineProps<{
facet: FilterFieldsFragment
}>()
const applied = defineModel<ProductFilter[]>({ required: true })
const keyOf = (filter: ProductFilter) => JSON.stringify(filter)
const isActive = (input: string) => applied.value.some(filter => keyOf(filter) === keyOf(JSON.parse(input)))
const toggle = (input: string) => {
const filter = JSON.parse(input) as ProductFilter
applied.value = isActive(input)
? applied.value.filter(active => keyOf(active) !== keyOf(filter))
: [...applied.value, filter]
}
</script>
<template>
<div>
<p class="mb-2 text-sm font-semibold text-muted">
{{ props.facet.label }}
</p>
<div class="space-y-1">
<UCheckbox
v-for="value in props.facet.values"
:key="value.id"
:model-value="isActive(value.input)"
:label="`${value.label} (${value.count})`"
@update:model-value="toggle(value.input)"
/>
</div>
</div>
</template>
The price filter
PRICE_RANGE filters are different.
They come back as a single value whose input holds the available range, something like {"price":{"min":0,"max":1000}}.
That is no use as a checkbox, so we read those numbers as the bounds and show a from and to input instead.
When the shopper applies a range we build the ProductFilter ourselves, drop any empty side, and swap out whatever
price filter was there before:
<script setup lang="ts">
import type { FilterFieldsFragment, ProductFilter } from '#shopify/storefront'
const props = defineProps<{
facet: FilterFieldsFragment
}>()
const applied = defineModel<ProductFilter[]>({ required: true })
const bounds = computed(() => JSON.parse(props.facet.values[0]?.input ?? '{}').price ?? {})
const from = ref('')
const to = ref('')
const apply = () => {
const min = from.value === '' ? undefined : Number(from.value)
const max = to.value === '' ? undefined : Number(to.value)
const others = applied.value.filter(filter => !filter.price)
applied.value = min === undefined && max === undefined
? others
: [...others, { price: { min, max } }]
}
watch(applied, (filters) => {
const price = filters.find(filter => filter.price)?.price
from.value = price?.min?.toString() ?? ''
to.value = price?.max?.toString() ?? ''
}, { immediate: true })
</script>
<template>
<div>
<p class="mb-2 text-sm font-semibold text-muted">
{{ props.facet.label }}
</p>
<div class="flex items-center gap-2">
<UInput
v-model="from"
type="number"
:placeholder="`${bounds.min ?? 0}`"
class="w-full"
@keyup.enter="apply"
/>
<span class="text-muted">-</span>
<UInput
v-model="to"
type="number"
:placeholder="`${bounds.max ?? ''}`"
class="w-full"
@keyup.enter="apply"
/>
<UButton
icon="i-lucide-arrow-right"
color="neutral"
aria-label="Apply price range"
@click="apply"
/>
</div>
</div>
</template>
Because every component reads and writes the one applied list, a price range and a handful of tags all stack together
into a single query.
Showing the products
The products are the nodes of the connection, so they drop straight into the ProductCard from the collection page
recipe:
<div class="grid gap-4 sm:grid-cols-2">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
</div>
And that's the whole thing. Shopify tells you which filters exist and what type each one is, every component turns that into the right piece of UI, and because the selection lives in the URL, the query re-runs whenever it changes and the filtered view is shareable.
