Skip to main content

Search and Filtering

This guide covers implementing product search and filtering in your storefront.

Overview​

Unchained Engine provides a flexible search and filter system:

┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Storefront │────▶│ FilterDirector │────▶│ Filter Adapters │
│ (Search) │◀────│ (Aggregation) │◀────│ (Search Logic) │
└─────────────┘ └──────────────────┘ └─────────────────┘

Text Search Query​

query SearchProducts($query: String!) {
searchProducts(queryString: $query) {
filteredProductsCount
products {
_id
texts {
title
description
}
... on SimpleProduct {
simulatedPrice(currencyCode: "CHF") {
amount
currencyCode
}
}
media {
file {
url
}
}
}
}
}

Search with Pagination​

query SearchWithPagination($query: String, $limit: Int, $offset: Int) {
searchProducts(
queryString: $query,
) {
filteredProductsCount
products(limit: $limit, offset: $offset) {
_id
texts {
title
}
}
}
}

Filters​

Get Available Filters​

query GetFilters {
filters {
texts {
title
}
options {
texts {
title
}
}
}
}

Search with Filters​

query FilteredSearch($query: String, $filters: [FilterQueryInput!]) {
searchProducts(
queryString: $query
filterQuery: $filters
) {
filteredProductsCount
products {
_id
texts {
title
}
}
filters {
filteredProductsCount
isSelected
options {
filteredProductsCount
isSelected
}
}
}
}

Filter Query Input​

// Example filter queries
const filters = [
// Single value
{ key: 'category', value: 'electronics' },

// Multiple values (OR)
{ key: 'brand', value: 'apple' },
{ key: 'brand', value: 'samsung' },

// Range filter
{ key: 'price', value: '100-500' },
];

Filter Types​

TypeDescriptionExample
SINGLE_CHOICESelect one optionCategory
MULTI_CHOICESelect multiple optionsBrand, Color
RANGENumeric rangePrice, Weight
SWITCHBoolean toggleIn Stock

Creating Filters​

mutation CreateFilter {
createFilter(
filter: {
key: "brand"
type: MULTI_CHOICE
options: ["apple", "samsung"]
}
texts: [
{ locale: "en", title: "Brand" }
]
) {
_id
key
type
texts {
title
}
}
}

Note: Filter option texts are managed separately via updateFilterTexts.

Assigning Filters to Products​

Filters are typically assigned through the Assortment system. Products inherit filters from their assortments, and filter options are managed separately through filter configuration.

mutation LinkAssortmentFilter {
addAssortmentFilter(assortmentId: "product-assortment", filterId: "filter-id") {
_id
assortment {
_id
texts {
_id
title
}
}
filter {
_id
texts {
_id
title
}
}
}
}

mutation LinkAssortmentProduct {
addAssortmentProduct(assortmentId: "product-assortment", productId: "product-id") {
_id
assortment {
_id
texts {
_id
title
}
}
product {
_id
texts {
_id
title
}
}
}
}
query ProductFilters {
product(productId: "product-id") {
_id
texts {
title
}
assortmentPaths {
links {
assortmentId
}
}
}
}

Assortment-Based Filtering​

Filter products within an assortment (category):

query AssortmentProducts($assortmentId: ID!, $filters: [FilterQueryInput!]) {
assortment(assortmentId: $assortmentId) {
_id
texts {
title
}
searchProducts(filterQuery: $filters) {
filteredProductsCount
products {
_id
texts {
title
}
}
filters {
filteredProductsCount
isSelected
options {
filteredProductsCount
}
}
}
}
}

Frontend Implementation​

Search Component​

import { useState, useEffect } from 'react';
import { useLazyQuery } from '@apollo/client';
import { SEARCH_PRODUCTS } from './queries';
import { useDebounce } from './hooks';

function SearchBar() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);

const [search, { data, loading }] = useLazyQuery(SEARCH_PRODUCTS);

useEffect(() => {
if (debouncedQuery.length >= 2) {
search({ variables: { query: debouncedQuery } });
}
}, [debouncedQuery, search]);

return (
<div className="search-container">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>

{loading && <div>Searching...</div>}

{data?.searchProducts?.products && (
<div className="search-results">
{data.searchProducts.products.map((product) => (
<SearchResult key={product._id} product={product} />
))}
<div>
Found {data.searchProducts.filteredProductsCount} products
</div>
</div>
)}
</div>
);
}

Filter Sidebar​

import { useState } from 'react';

function FilterSidebar({ filters, selectedFilters, onFilterChange }) {
return (
<div className="filter-sidebar">
{filters.map((filter) => (
<FilterGroup
key={filter.definition._id}
filter={filter}
selected={selectedFilters.filter((f) => f.key === filter.definition._id)}
onChange={(values) => onFilterChange(filter.definition._id, values)}
/>
))}
</div>
);
}

function FilterGroup({ filter, selected, onChange }) {
const selectedValues = selected.map((s) => s.value);

const handleToggle = (value) => {
if (selectedValues.includes(value)) {
onChange(selectedValues.filter((v) => v !== value));
} else {
onChange([...selectedValues, value]);
}
};

return (
<div className="filter-group">
<h4>{filter.texts?.title}</h4>
{filter.options.map((option) => (
<label key={option._id} className="filter-option">
<input
type="checkbox"
checked={option.isSelected}
onChange={() => handleToggle(option._id)}
/>
<span>{option.texts?.title || option._id}</span>
{option.filteredProductsCount !== undefined && (
<span className="count">({option.filteredProductsCount})</span>
)}
</label>
))}
</div>
);
}

Product List with Filters​

import { useState, useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { SEARCH_PRODUCTS } from './queries';

function ProductListPage({ initialQuery = '' }) {
const [query, setQuery] = useState(initialQuery);
const [selectedFilters, setSelectedFilters] = useState([]);
const [sortBy, setSortBy] = useState('relevance');

const { data, loading } = useQuery(SEARCH_PRODUCTS, {
variables: {
query,
filters: selectedFilters,
sortBy,
},
});

const handleFilterChange = (key, values) => {
// Remove old filters for this key
const otherFilters = selectedFilters.filter((f) => f.key !== key);

// Add new filters
const newFilters = values.map((value) => ({ key, value }));

setSelectedFilters([...otherFilters, ...newFilters]);
};

const clearFilters = () => {
setSelectedFilters([]);
};

return (
<div className="product-list-page">
<div className="search-header">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="relevance">Relevance</option>
<option value="price_asc">Price: Low to High</option>
<option value="price_desc">Price: High to Low</option>
<option value="newest">Newest</option>
</select>
</div>

<div className="content">
<FilterSidebar
filters={data?.searchProducts?.filters || []}
selectedFilters={selectedFilters}
onFilterChange={handleFilterChange}
/>

<div className="products">
{selectedFilters.length > 0 && (
<div className="active-filters">
{selectedFilters.map((filter, i) => (
<span key={i} className="filter-tag">
{filter.key}: {filter.value}
<button onClick={() => handleFilterChange(filter.key, [])}>
×
</button>
</span>
))}
<button onClick={clearFilters}>Clear all</button>
</div>
)}

{loading ? (
<div>Loading...</div>
) : (
<>
<div className="results-count">
{data?.searchProducts?.filteredProductsCount} products found
</div>
<div className="product-grid">
{data?.searchProducts?.products.map((product) => (
<ProductCard key={product._id} product={product} />
))}
</div>
</>
)}
</div>
</div>
</div>
);
}

URL-Based Filters​

Sync filters with URL for shareable links:

import { useRouter } from 'next/router';
import { useEffect, useMemo } from 'react';

function useUrlFilters() {
const router = useRouter();

// Parse filters from URL
const filters = useMemo(() => {
const result = [];
Object.entries(router.query).forEach(([key, value]) => {
if (key.startsWith('filter_')) {
const filterKey = key.replace('filter_', '');
const values = Array.isArray(value) ? value : [value];
values.forEach((v) => result.push({ key: filterKey, value: v }));
}
});
return result;
}, [router.query]);

// Update URL when filters change
const setFilters = (newFilters) => {
const query = { ...router.query };

// Remove old filter params
Object.keys(query).forEach((key) => {
if (key.startsWith('filter_')) delete query[key];
});

// Add new filter params
newFilters.forEach(({ key, value }) => {
const paramKey = `filter_${key}`;
if (query[paramKey]) {
query[paramKey] = Array.isArray(query[paramKey])
? [...query[paramKey], value]
: [query[paramKey], value];
} else {
query[paramKey] = value;
}
});

router.push({ query }, undefined, { shallow: true });
};

return { filters, setFilters };
}

Custom Filter Adapter​

Create a custom filter adapter for advanced search:

import { FilterDirector, type IFilterAdapter } from '@unchainedshop/core';

const ElasticsearchFilter: IFilterAdapter = {
key: 'shop.example.filter.elasticsearch',
label: 'Elasticsearch Filter',
version: '1.0.0',
orderIndex: 0,

actions(context) {
return {
async searchProducts(params, options) {
const { queryString, filterQuery } = params;

// Build Elasticsearch query
const esQuery = buildESQuery(queryString, filterQuery);

// Search Elasticsearch
const results = await elasticsearch.search({
index: 'products',
body: esQuery,
});

return {
productIds: results.hits.hits.map((hit) => hit._id),
totalCount: results.hits.total.value,
};
},

async searchAssortments(params, options) {
// Similar implementation for assortments
return { assortmentIds: [], totalCount: 0 };
},

async aggregateProductIds(params) {
// Return product IDs matching filter
return [];
},

transformProductSelector(selector, options) {
return selector;
},

transformFilterSelector(selector, options) {
return selector;
},

transformSortStage(sort, options) {
return sort;
},
};
},
};

FilterDirector.registerAdapter(ElasticsearchFilter);

Performance Tips​

1. Index Your Filters​

Ensure MongoDB indexes exist for filter fields:

// In your migration or setup
await db.collection('products').createIndex({ 'filterOptions.key': 1 });
await db.collection('products').createIndex({ 'filterOptions.value': 1 });
await db.collection('products').createIndex({ status: 1, 'texts.title': 'text' });

2. Cache Filter Aggregations​

Use Apollo cache or server-side caching:

const { data } = useQuery(SEARCH_PRODUCTS, {
variables: { query, filters },
fetchPolicy: 'cache-and-network',
});

3. Debounce Search Input​

function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);

return debouncedValue;
}