Implementing Faceted Search
In this tutorial, you will build a complete faceted search system from scratch. Faceted search is what powers the filters on sites like Amazon, Airbnb, and most e-commerce platforms, allowing users to narrow down results by multiple criteria while seeing how many items match each option.
Time required: 15-20 minutes
What you will build: A faceted search interface for a real estate listings app with filters for property type, price range, bedrooms, amenities, and location.
Prerequisites
Before starting, make sure you have:
- Completed the Build Your First dcupl Application tutorial
- Node.js 18 or higher installed
- Basic TypeScript/JavaScript knowledge
What is Faceted Search?
Faceted search combines full-text search with a multi-dimensional filtering system. The key features are:
- Dynamic counts - Each filter option shows how many results it would return
- Multi-select - Users can select multiple values within a facet
- Cross-facet filtering - Selecting a filter in one facet updates counts in other facets
- No dead ends - Users always see options that have results
Step 1: Set Up the Project
Create a new project and install dependencies:
mkdir faceted-search
cd faceted-search
npm init -y
npm install @dcupl/coreCreate the main application file:
import { Dcupl } from '@dcupl/core';
async function main() {
const dcupl = new Dcupl();
// We will build the faceted search here
}
main();Step 2: Define the Property Model
Real estate listings have many filterable attributes. Define a model that captures them:
import { Dcupl } from '@dcupl/core';
async function main() {
const dcupl = new Dcupl();
// Define the Property listing model
dcupl.models.set({
key: 'Property',
properties: [
{ key: 'title', type: 'string' },
{ key: 'description', type: 'string' },
{ key: 'propertyType', type: 'string' },
{ key: 'price', type: 'int' },
{ key: 'bedrooms', type: 'int' },
{ key: 'bathrooms', type: 'int' },
{ key: 'sqft', type: 'int' },
{ key: 'city', type: 'string' },
{ key: 'neighborhood', type: 'string' },
{ key: 'amenities', type: 'Array<string>' },
{ key: 'yearBuilt', type: 'int' },
{ key: 'parking', type: 'boolean' },
{ key: 'petFriendly', type: 'boolean' },
],
});
console.log('Property model defined');
}
main();Step 3: Load Sample Property Data
Add a realistic dataset with varied properties:
// ... after dcupl.models.set() ...
dcupl.data.set(
[
{
key: 'p1',
title: 'Modern Downtown Loft',
description: 'Stunning loft with city views',
propertyType: 'Apartment',
price: 450000,
bedrooms: 1,
bathrooms: 1,
sqft: 850,
city: 'San Francisco',
neighborhood: 'SoMa',
amenities: ['gym', 'rooftop', 'doorman'],
yearBuilt: 2018,
parking: true,
petFriendly: true,
},
{
key: 'p2',
title: 'Cozy Family Home',
description: 'Perfect for families',
propertyType: 'House',
price: 750000,
bedrooms: 4,
bathrooms: 2,
sqft: 2200,
city: 'San Francisco',
neighborhood: 'Sunset',
amenities: ['garage', 'backyard', 'fireplace'],
yearBuilt: 1965,
parking: true,
petFriendly: true,
},
{
key: 'p3',
title: 'Luxury Penthouse',
description: 'Top floor with panoramic views',
propertyType: 'Apartment',
price: 1200000,
bedrooms: 3,
bathrooms: 2,
sqft: 1800,
city: 'San Francisco',
neighborhood: 'Pacific Heights',
amenities: ['gym', 'rooftop', 'doorman', 'pool', 'concierge'],
yearBuilt: 2020,
parking: true,
petFriendly: false,
},
{
key: 'p4',
title: 'Victorian Charm',
description: 'Historic home with modern updates',
propertyType: 'House',
price: 980000,
bedrooms: 3,
bathrooms: 2,
sqft: 1950,
city: 'San Francisco',
neighborhood: 'Haight',
amenities: ['backyard', 'fireplace'],
yearBuilt: 1905,
parking: false,
petFriendly: true,
},
{
key: 'p5',
title: 'Starter Condo',
description: 'Great investment opportunity',
propertyType: 'Condo',
price: 320000,
bedrooms: 1,
bathrooms: 1,
sqft: 650,
city: 'Oakland',
neighborhood: 'Lake Merritt',
amenities: ['gym', 'laundry'],
yearBuilt: 2010,
parking: true,
petFriendly: false,
},
{
key: 'p6',
title: 'Spacious Townhouse',
description: 'Multi-level living',
propertyType: 'Townhouse',
price: 680000,
bedrooms: 3,
bathrooms: 2,
sqft: 1600,
city: 'Oakland',
neighborhood: 'Rockridge',
amenities: ['garage', 'patio'],
yearBuilt: 1995,
parking: true,
petFriendly: true,
},
{
key: 'p7',
title: 'Urban Studio',
description: 'Efficient city living',
propertyType: 'Apartment',
price: 280000,
bedrooms: 0,
bathrooms: 1,
sqft: 450,
city: 'Oakland',
neighborhood: 'Downtown',
amenities: ['gym', 'rooftop'],
yearBuilt: 2015,
parking: false,
petFriendly: false,
},
{
key: 'p8',
title: 'Family Estate',
description: 'Expansive property with pool',
propertyType: 'House',
price: 1500000,
bedrooms: 5,
bathrooms: 4,
sqft: 3500,
city: 'San Francisco',
neighborhood: 'Sea Cliff',
amenities: ['pool', 'backyard', 'garage', 'fireplace'],
yearBuilt: 1985,
parking: true,
petFriendly: true,
},
{
key: 'p9',
title: 'Modern Condo',
description: 'Sleek and stylish',
propertyType: 'Condo',
price: 520000,
bedrooms: 2,
bathrooms: 1,
sqft: 950,
city: 'San Francisco',
neighborhood: 'Mission',
amenities: ['gym', 'rooftop', 'bike-storage'],
yearBuilt: 2019,
parking: false,
petFriendly: true,
},
{
key: 'p10',
title: 'Charming Bungalow',
description: 'Craftsman details throughout',
propertyType: 'House',
price: 620000,
bedrooms: 2,
bathrooms: 1,
sqft: 1100,
city: 'Oakland',
neighborhood: 'Temescal',
amenities: ['backyard', 'fireplace'],
yearBuilt: 1925,
parking: true,
petFriendly: true,
},
],
{ model: 'Property' }
);
await dcupl.init();
console.log('Loaded 10 properties');Step 4: Create the Property List
Create a list for querying properties:
// ... after dcupl.init() ...
const propertyList = dcupl.lists.create({ modelKey: 'Property' });
console.log('\n=== Real Estate Faceted Search ===');
console.log('Total listings:', propertyList.catalog.query.count());Step 5: Build the Facet System
Now create a function that generates facets for all filterable attributes:
// Define which attributes are facetable
const facetableAttributes = [
'propertyType',
'city',
'neighborhood',
'bedrooms',
'parking',
'petFriendly',
'amenities',
];
function displayFacets() {
console.log('\n--- Available Filters ---\n');
// Property Type facet
console.log('Property Type:');
propertyList.catalog.fn.facets({ attribute: 'propertyType' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.count})`);
});
// City facet
console.log('\nCity:');
propertyList.catalog.fn.facets({ attribute: 'city' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.count})`);
});
// Bedrooms facet
console.log('\nBedrooms:');
propertyList.catalog.fn.facets({ attribute: 'bedrooms' }).forEach((f) => {
const label = f.value === 0 ? 'Studio' : `${f.value} bed`;
console.log(` [ ] ${label} (${f.count})`);
});
// Amenities facet (array type)
console.log('\nAmenities:');
propertyList.catalog.fn.facets({ attribute: 'amenities' }).forEach((f) => {
if (f.value) {
console.log(` [ ] ${f.value} (${f.count})`);
}
});
// Boolean facets
console.log('\nParking:');
propertyList.catalog.fn.facets({ attribute: 'parking' }).forEach((f) => {
const label = f.value ? 'Has Parking' : 'No Parking';
console.log(` [ ] ${label} (${f.count})`);
});
console.log('\nPet Friendly:');
propertyList.catalog.fn.facets({ attribute: 'petFriendly' }).forEach((f) => {
const label = f.value ? 'Pet Friendly' : 'No Pets';
console.log(` [ ] ${label} (${f.count})`);
});
}
displayFacets();Run the app to see all facets:
npx tsx search.tsStep 6: Implement Single-Select Filtering
Simulate a user selecting "House" as the property type:
console.log('\n\n========================================');
console.log('User selects: Property Type = House');
console.log('========================================');
propertyList.catalog.query.addCondition({
attribute: 'propertyType',
operator: 'eq',
value: 'House',
});
// Show filtered results
console.log(`\nMatching properties: ${propertyList.catalog.query.count()}`);
propertyList.catalog.query.items().forEach((p) => {
console.log(` - ${p.title} | ${p.city} | ${p.price.toLocaleString()}`);
});
// Show updated facets - notice how counts change
console.log('\n--- Updated Facets After Filter ---');
console.log('\nCity (within Houses):');
propertyList.catalog.fn.facets({ attribute: 'city' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.count})`);
});
console.log('\nBedrooms (within Houses):');
propertyList.catalog.fn.facets({ attribute: 'bedrooms' }).forEach((f) => {
const label = f.value === 0 ? 'Studio' : `${f.value} bed`;
console.log(` [ ] ${label} (${f.count})`);
});Notice how the facet counts update to reflect only houses.
Step 7: Implement Multi-Select Filtering
Allow users to select multiple values. First, clear the filters and try multi-select:
propertyList.catalog.query.clear();
console.log('\n\n========================================');
console.log('User selects: City = San Francisco OR Oakland');
console.log('========================================');
// Multi-select using 'in' operator - this is OR logic within the facet
propertyList.catalog.query.addCondition({
attribute: 'city',
operator: 'in',
value: ['San Francisco', 'Oakland'],
});
console.log(`\nMatching properties: ${propertyList.catalog.query.count()}`);Step 8: Implement Price Range Filtering
Add a price range facet using comparison operators:
console.log('\n\n========================================');
console.log('User adds: Price $400,000 - $800,000');
console.log('========================================');
// Using gte (greater than or equal) and lte (less than or equal)
propertyList.catalog.query.addCondition({
attribute: 'price',
operator: 'gte',
value: 400000,
});
propertyList.catalog.query.addCondition({
attribute: 'price',
operator: 'lte',
value: 800000,
});
console.log(`\nMatching properties: ${propertyList.catalog.query.count()}`);
propertyList.catalog.query.items().forEach((p) => {
console.log(
` - ${p.title} | ${p.propertyType} | ${p.price.toLocaleString()}`
);
});
// Show price statistics for filtered results
const priceStats = propertyList.catalog.fn.aggregate({ attribute: 'price' });
console.log(`\nPrice range in results: ${priceStats.min.toLocaleString()} - ${priceStats.max.toLocaleString()}`);Step 9: Filter by Array Properties (Amenities)
Filter properties that have specific amenities:
propertyList.catalog.query.clear();
console.log('\n\n========================================');
console.log('User selects: Must have "gym" amenity');
console.log('========================================');
// Use 'find' operator to search within arrays
propertyList.catalog.query.addCondition({
attribute: 'amenities',
operator: 'find',
value: 'gym',
});
console.log(`\nProperties with gym: ${propertyList.catalog.query.count()}`);
propertyList.catalog.query.items().forEach((p) => {
console.log(` - ${p.title} | Amenities: ${p.amenities.join(', ')}`);
});
// Add another amenity requirement (AND logic)
console.log('\n--- Adding: Must also have "rooftop" ---');
propertyList.catalog.query.addCondition({
attribute: 'amenities',
operator: 'find',
value: 'rooftop',
});
console.log(`\nProperties with gym AND rooftop: ${propertyList.catalog.query.count()}`);
propertyList.catalog.query.items().forEach((p) => {
console.log(` - ${p.title}`);
});Step 10: Implement Boolean Filters
Filter by boolean attributes like parking and pet-friendly:
propertyList.catalog.query.clear();
console.log('\n\n========================================');
console.log('User selects: Pet Friendly = true, Parking = true');
console.log('========================================');
// Use isTruthy operator for boolean values
propertyList.catalog.query.addCondition({
attribute: 'petFriendly',
operator: 'isTruthy',
value: true,
});
propertyList.catalog.query.addCondition({
attribute: 'parking',
operator: 'isTruthy',
value: true,
});
console.log(`\nPet-friendly properties with parking: ${propertyList.catalog.query.count()}`);
propertyList.catalog.query.items().forEach((p) => {
console.log(` - ${p.title} | ${p.propertyType}`);
});Step 11: Combine Multiple Facets
Build a realistic search combining multiple facet selections:
propertyList.catalog.query.clear();
console.log('\n\n========================================');
console.log('Complex Search:');
console.log(' - Property Type: House OR Townhouse');
console.log(' - Bedrooms: 3+');
console.log(' - Price: Under $1,000,000');
console.log(' - Pet Friendly: Yes');
console.log('========================================');
// Property type (multi-select)
propertyList.catalog.query.addCondition({
attribute: 'propertyType',
operator: 'in',
value: ['House', 'Townhouse'],
});
// Bedrooms (using gte - greater than or equal)
propertyList.catalog.query.addCondition({
attribute: 'bedrooms',
operator: 'gte',
value: 3,
});
// Price cap (using lt - less than)
propertyList.catalog.query.addCondition({
attribute: 'price',
operator: 'lt',
value: 1000000,
});
// Pet friendly (using eq for exact boolean match)
propertyList.catalog.query.addCondition({
attribute: 'petFriendly',
operator: 'eq',
value: true,
});
const results = propertyList.catalog.query.items({
sort: { attributes: ['price'], order: ['asc'] },
});
console.log(`\n${results.length} properties found:\n`);
results.forEach((p) => {
console.log(`${p.title}`);
console.log(` ${p.propertyType} | ${p.bedrooms} bed | ${p.bathrooms} bath | ${p.sqft} sqft`);
console.log(` ${p.neighborhood}, ${p.city}`);
console.log(` ${p.price.toLocaleString()}`);
console.log(` Amenities: ${p.amenities.join(', ') || 'None listed'}`);
console.log('');
});
// Show what facets are still available for further refinement
console.log('--- Refine Your Search ---');
console.log('\nNeighborhoods available:');
propertyList.catalog.fn.facets({ attribute: 'neighborhood' }).forEach((f) => {
console.log(` [ ] ${f.value} (${f.count})`);
});Step 12: Build a Reusable Facet Manager
Create a class to manage faceted search state:
interface FacetSelection {
attribute: string;
values: any[];
operator: string;
}
class FacetedSearch {
private list: any;
private selections: Map<string, FacetSelection> = new Map();
constructor(list: any) {
this.list = list;
}
// Add or update a facet selection
setFacet(attribute: string, values: any[], operator: string = 'in') {
if (values.length === 0) {
this.selections.delete(attribute);
} else {
this.selections.set(attribute, { attribute, values, operator });
}
this.applyFilters();
}
// Set a range filter (min/max)
setRange(attribute: string, min?: number, max?: number) {
// Remove existing range filters for this attribute
this.list.catalog.query.clear();
if (min !== undefined) {
this.setFacet(`${attribute}_min`, [min], 'gte');
}
if (max !== undefined) {
this.setFacet(`${attribute}_max`, [max], 'lte');
}
}
// Clear a specific facet
clearFacet(attribute: string) {
this.selections.delete(attribute);
this.selections.delete(`${attribute}_min`);
this.selections.delete(`${attribute}_max`);
this.applyFilters();
}
// Clear all facets
clearAll() {
this.selections.clear();
this.list.catalog.query.clear();
}
// Apply all active filters
private applyFilters() {
this.list.catalog.query.clear();
for (const [key, selection] of this.selections) {
if (selection.values.length === 1) {
// Single value - use eq or the specified operator
const op = selection.operator === 'in' ? 'eq' : selection.operator;
this.list.catalog.query.addCondition({
attribute: selection.attribute,
operator: op,
value: selection.values[0],
});
} else {
// Multiple values - use in operator
this.list.catalog.query.addCondition({
attribute: selection.attribute,
operator: 'in',
value: selection.values,
});
}
}
}
// Get current results
getResults(options?: { sort?: any; limit?: number }) {
return this.list.catalog.query.items(options);
}
// Get facet counts for an attribute
getFacets(attribute: string) {
return this.list.catalog.fn.facets({ attribute });
}
// Get result count
getCount() {
return this.list.catalog.query.count();
}
// Get active filters summary
getActiveFilters() {
const active: string[] = [];
for (const [key, selection] of this.selections) {
active.push(`${selection.attribute}: ${selection.values.join(', ')}`);
}
return active;
}
}Use the FacetedSearch class:
// Reset and demonstrate the reusable class
propertyList.catalog.query.clear();
console.log('\n\n========================================');
console.log('Using FacetedSearch Class');
console.log('========================================');
const search = new FacetedSearch(propertyList);
// User selects apartments
search.setFacet('propertyType', ['Apartment']);
console.log(`\nAfter selecting Apartment: ${search.getCount()} results`);
// User adds San Francisco
search.setFacet('city', ['San Francisco']);
console.log(`After adding San Francisco: ${search.getCount()} results`);
// Show active filters
console.log('\nActive filters:', search.getActiveFilters());
// Show remaining options
console.log('\nBedroom options:');
search.getFacets('bedrooms').forEach((f) => {
console.log(` ${f.value} bed: ${f.count} properties`);
});
// Clear and start over
search.clearAll();
console.log(`\nAfter clearing all: ${search.getCount()} results`);Complete Code
Here is the complete faceted search implementation:
import { Dcupl } from '@dcupl/core';
async function main() {
const dcupl = new Dcupl();
// Define Property model
dcupl.models.set({
key: 'Property',
properties: [
{ key: 'title', type: 'string' },
{ key: 'description', type: 'string' },
{ key: 'propertyType', type: 'string' },
{ key: 'price', type: 'int' },
{ key: 'bedrooms', type: 'int' },
{ key: 'bathrooms', type: 'int' },
{ key: 'sqft', type: 'int' },
{ key: 'city', type: 'string' },
{ key: 'neighborhood', type: 'string' },
{ key: 'amenities', type: 'Array<string>' },
{ key: 'yearBuilt', type: 'int' },
{ key: 'parking', type: 'boolean' },
{ key: 'petFriendly', type: 'boolean' },
],
});
// Load sample data
dcupl.data.set(
[
{ key: 'p1', title: 'Modern Downtown Loft', propertyType: 'Apartment', price: 450000, bedrooms: 1, bathrooms: 1, sqft: 850, city: 'San Francisco', neighborhood: 'SoMa', amenities: ['gym', 'rooftop', 'doorman'], yearBuilt: 2018, parking: true, petFriendly: true },
{ key: 'p2', title: 'Cozy Family Home', propertyType: 'House', price: 750000, bedrooms: 4, bathrooms: 2, sqft: 2200, city: 'San Francisco', neighborhood: 'Sunset', amenities: ['garage', 'backyard', 'fireplace'], yearBuilt: 1965, parking: true, petFriendly: true },
{ key: 'p3', title: 'Luxury Penthouse', propertyType: 'Apartment', price: 1200000, bedrooms: 3, bathrooms: 2, sqft: 1800, city: 'San Francisco', neighborhood: 'Pacific Heights', amenities: ['gym', 'rooftop', 'doorman', 'pool', 'concierge'], yearBuilt: 2020, parking: true, petFriendly: false },
{ key: 'p4', title: 'Victorian Charm', propertyType: 'House', price: 980000, bedrooms: 3, bathrooms: 2, sqft: 1950, city: 'San Francisco', neighborhood: 'Haight', amenities: ['backyard', 'fireplace'], yearBuilt: 1905, parking: false, petFriendly: true },
{ key: 'p5', title: 'Starter Condo', propertyType: 'Condo', price: 320000, bedrooms: 1, bathrooms: 1, sqft: 650, city: 'Oakland', neighborhood: 'Lake Merritt', amenities: ['gym', 'laundry'], yearBuilt: 2010, parking: true, petFriendly: false },
{ key: 'p6', title: 'Spacious Townhouse', propertyType: 'Townhouse', price: 680000, bedrooms: 3, bathrooms: 2, sqft: 1600, city: 'Oakland', neighborhood: 'Rockridge', amenities: ['garage', 'patio'], yearBuilt: 1995, parking: true, petFriendly: true },
{ key: 'p7', title: 'Urban Studio', propertyType: 'Apartment', price: 280000, bedrooms: 0, bathrooms: 1, sqft: 450, city: 'Oakland', neighborhood: 'Downtown', amenities: ['gym', 'rooftop'], yearBuilt: 2015, parking: false, petFriendly: false },
{ key: 'p8', title: 'Family Estate', propertyType: 'House', price: 1500000, bedrooms: 5, bathrooms: 4, sqft: 3500, city: 'San Francisco', neighborhood: 'Sea Cliff', amenities: ['pool', 'backyard', 'garage', 'fireplace'], yearBuilt: 1985, parking: true, petFriendly: true },
{ key: 'p9', title: 'Modern Condo', propertyType: 'Condo', price: 520000, bedrooms: 2, bathrooms: 1, sqft: 950, city: 'San Francisco', neighborhood: 'Mission', amenities: ['gym', 'rooftop', 'bike-storage'], yearBuilt: 2019, parking: false, petFriendly: true },
{ key: 'p10', title: 'Charming Bungalow', propertyType: 'House', price: 620000, bedrooms: 2, bathrooms: 1, sqft: 1100, city: 'Oakland', neighborhood: 'Temescal', amenities: ['backyard', 'fireplace'], yearBuilt: 1925, parking: true, petFriendly: true },
],
{ model: 'Property' }
);
await dcupl.init();
const propertyList = dcupl.lists.create({ modelKey: 'Property' });
// Display facets
console.log('=== Real Estate Faceted Search ===');
console.log(`Total: ${propertyList.catalog.query.count()} properties\n`);
console.log('Property Types:');
propertyList.catalog.fn.facets({ attribute: 'propertyType' }).forEach((f) => {
console.log(` ${f.value}: ${f.count}`);
});
console.log('\nCities:');
propertyList.catalog.fn.facets({ attribute: 'city' }).forEach((f) => {
console.log(` ${f.value}: ${f.count}`);
});
// Apply filters
console.log('\n--- Filtering: Houses under $1M ---\n');
propertyList.catalog.query.addCondition({ attribute: 'propertyType', operator: 'eq', value: 'House' });
propertyList.catalog.query.addCondition({ attribute: 'price', operator: 'lt', value: 1000000 });
propertyList.catalog.query.items().forEach((p) => {
console.log(`${p.title} - ${p.price.toLocaleString()}`);
});
}
main();What You Learned
In this tutorial, you learned how to:
- Build faceted navigation with dynamic counts using
catalog.fn.facets() - Implement single-select filtering with the
eqoperator - Implement multi-select filtering with the
inoperator - Create price range filters using
gteandlteoperators - Filter array properties (amenities) using the
findoperator - Filter boolean properties with
isTruthyandeqoperators - Combine multiple facets for complex searches
- Build a reusable FacetedSearch class
Key Operators Used
| Operator | Purpose | Example |
|---|---|---|
eq |
Exact match | { attribute: 'city', operator: 'eq', value: 'Oakland' } |
in |
Match any value in array | { attribute: 'type', operator: 'in', value: ['House', 'Condo'] } |
gte |
Greater than or equal | { attribute: 'price', operator: 'gte', value: 500000 } |
lte |
Less than or equal | { attribute: 'price', operator: 'lte', value: 1000000 } |
gt |
Greater than | { attribute: 'bedrooms', operator: 'gt', value: 2 } |
lt |
Less than | { attribute: 'yearBuilt', operator: 'lt', value: 2000 } |
find |
Find value in array property | { attribute: 'amenities', operator: 'find', value: 'pool' } |
isTruthy |
Check truthiness | { attribute: 'parking', operator: 'isTruthy', value: true } |
Next Steps
- Build a Product Catalog - Apply faceted search to e-commerce
- Real-time Data Updates - Update facets when data changes
- Querying: Filtering - All available query operators
Common Issues
Facet counts don't update
Make sure you are calling facets() after applying filter conditions. Facets automatically reflect the current query state.
Multi-select not working
Use the in operator for multi-select, not multiple eq conditions:
// Correct - OR logic within facet
{ attribute: 'type', operator: 'in', value: ['A', 'B'] }
// Wrong - second condition overwrites first
{ attribute: 'type', operator: 'eq', value: 'A' }
{ attribute: 'type', operator: 'eq', value: 'B' }Array property filtering issues
Use find for checking if an array contains a value:
// For property amenities: ['gym', 'pool']
{ attribute: 'amenities', operator: 'find', value: 'gym' } // Matches!