Recipes

Collection Filters

How to create collection filters for your Shopify store in Nuxt

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.

Hoodie
This hoodie is the perfect choice for comfort and warmth. Meticulously crafted from 100% cotton, the hoodie features a soft, plush fleece interior and a unisex sizing design. Soft and lightweight, it's sure to be your go-to for chilly days.
from CA$90.00
Men's T-shirt
Crafted from organic cotton, this classic T-shirt features a relaxed fit, crew neckline and timeless look. Enjoy the breathable comfort of 100% organic cotton.
from CA$40.00
Men's Crewneck
This high-quality crewneck is perfect for your everyday look. Made with 100% cotton, it's soft, comfortable, and undeniably stylish. Full sleeved for a classic look and effortlessly versatile, this cotton crewneck is a must-have in any wardrobe.
from CA$120.00
Sweatpants
Soft and comfortable sweatpants in stylish shades. They are perfect for lounging with their cozy stretch fabric that offers just the right amount of warmth. Enjoy the ultimate relaxation experience!
from CA$35.00
Shorts
These shorts are designed to help you reach peak performance. Constructed with high performance nylon fabric in a variety of shades, they are built to last and provide maximum comfort.
from CA$45.00
High Top Sneakers
These stylish and durable high top sneakers are perfect for any casual look, offering superior comfort and protection with their foam cushioning and reinforced heel support.
from CA$180.00
White Leather Sneakers
from CA$90.00
Gray Leather Sneakers
These gray leather sneakers combine comfort and style for the perfect professional look. The breathable leather material ensures breathability and provides a comfortable fit, perfect for the office and other formal occasions. The handmade design is stylish and guaranteed to last.
from CA$1,000.00
Gray Runners
These gray runners are the perfect choice for running enthusiasts. These shoes provide superior breathability and comfort, so you can run longer with less fatigue. The lightweight design and airy mesh material make these shoes durable and lightweight, giving you the support you need for peak performance.
from CA$30.00
Canvas Sneakers
These high-quality canvas sneakers offer a comfortable fit and superior breathability, thanks to their cushioning midsoles and durable construction. An array of stylish colors adds to the appeal, making them perfect for casual wear. Slip them on and enjoy reliable performance and style that lasts.
from CA$40.00

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:

graphql/filter.ts
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:

app/components/FilterList.vue
<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:

app/components/FilterPrice.vue
<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.

Copyright © 2026