Context
Product and recipe catalogs are only useful if people can find what they're looking for. For a set of food-service clients, the existing approach either relied on server-side filtering, where every filter selection triggered a full page reload, or on apps with limited functionality, such as a single filtering option. The goal was live, client-side filtering: accurate, usable, and fast, without the page ever refreshing.
Role
Process
- 01
Building for reuse
Although the initial build was a one-off, it soon became clear that a reusable version would be valuable, as the same problem kept recurring across new projects. The final app was architected as a base that could be adapted across sites with different product catalogs and visual designs. I kept the filtering logic separate from the presentation layer so each client could apply their design without touching the core functionality.
- 02
From GraphQL to a server-side JS object
The original version pulled product data from Craft CMS via its GraphQL API, which worked well. When the app was adapted for WordPress, WPGraphQL was the natural equivalent. However, it didn't perform well enough under real conditions. The solution was to shift data loading to a server-side-built JavaScript object, constructed at render time and cached with typical WordPress caching strategies. This kept the client-side experience fast while avoiding the overhead of a runtime API call.
- 03
Dynamic filter generation
When the app loads, it reads all product data and dynamically generates the filtering menu from each product's tags. New filter options appear automatically as the product catalog changes, with no manual menu maintenance required.
- 04
The filtering logic
The core of the app is a computed function that handles a nuanced filtering pattern: within a group, selected filters are OR (show me items matching any of these), but between groups they are AND (show me items matching at least one from each group). This mirrors how people actually think about filtering: โshow me chicken or beef dishes, but only the ones that are also gluten-free.'
- 05
URL parameter sync
Filter state and pagination are reflected in the URL as query parameters, which unlocks a few things that users take for granted but some implementations sometimes skip: the browser back and forward buttons work as expected, filtered views can be bookmarked or shared as direct links, and pre-filtered URLs can be constructed and linked to from other pages.
- 06
Search with Fuse.js
Basic string searching is handled by Fuse.js, giving users a way to search across the catalog without a server round-trip. Fuse operates against the already-loaded data, keeping the experience consistent with the live filtering approach.
Visuals
// matching IDs accumulate per group, then reduce to IDs present in every group
let filteredItems = computed(() => {
if (activeFilters.value.length === 0) {
return items;
} else {
let itemIDAccumulator: Array<any> = [];
filterGroups.value.forEach((group: FilterGroup) => {
let groupitemIds: Array<number> = [];
group.filters.forEach((filter) => {
if (filter.selected) {
groupitemIds = groupitemIds.concat(filter.itemIds);
}
});
if (groupitemIds.length > 0) {
itemIDAccumulator.push(groupitemIds);
}
});
let itemIds = itemIDAccumulator.reduce((acc, val) => {
return acc.filter((value: any) => val.includes(value));
});
return items.filter((item) => {
return itemIds.includes(item.id);
});
}
});
Outcome
The app is currently live on several client sites across different brands and product catalogs, handling both product and recipe filtering. The shift from WPGraphQL to a server-side JS object resolved the performance issues and became the standard data approach for all subsequent WordPress implementations.
Reflections
In hindsight, the number of WordPress implementations makes a strong case for packaging this as a proper plugin with an admin interface for configuring filters, rather than setting it up manually. It's the natural next step for something that's become a recurring solution.