Building Faceted Search in Craft CMS with Algolia and InstantSearch
Craft's built-in search is fine for a blog with a search box. But when you need real-time filtering across multiple facets with instant results, like a property listings page where users filter by state, acreage, price range, and land type all at once, you need something more.
I built exactly this for a client in the real estate space. They manage timberland and forest property listings across multiple regions, and their old search was a set of dropdowns that reloaded the entire page on every filter change. Users were bouncing. The listings page needed to feel fast and responsive, like the search experiences people are used to on sites like Zillow or Airbnb.
The solution was Algolia for the search backend and InstantSearch.js for the front-end UI. This post walks through the full implementation on Craft CMS.
Why Algolia Instead of Craft's Built-in Search
Craft's search works by querying the database. That's fine for simple keyword search, but it falls apart when you need:
- Faceted filtering. Show me all listings in Georgia, over 100 acres, under $500k, that are timberland. And update the result count on every facet as filters change.
- Instant results. Results updating as the user types or toggles a filter, without page reloads.
- Typo tolerance. A user searching for "Gorgia" should still find Georgia properties.
- Speed at scale. Hundreds or thousands of listings searched in under 50ms.
Algolia is a hosted search engine designed specifically for this. You push your data to their servers, and they handle the search, filtering, and ranking. The response times are typically under 20ms regardless of dataset size. Your Craft database is never hit during a search.
The key thing to notice: during search, the browser talks directly to Algolia's servers. Your Craft site is never involved in the search request. This is why it's so fast and why it doesn't add load to your server, even with thousands of concurrent searches.
Step 1: Install Scout
Scout is a Craft plugin by Rias that syncs your Craft entries to Algolia (and other search engines). When an editor saves, updates, or deletes a listing in Craft, Scout automatically pushes the change to the Algolia index.
composer require rias/craft-scout
php craft plugin/install scout
Add your Algolia credentials to .env:
ALGOLIA_APP_ID=your-app-id
ALGOLIA_API_KEY=your-admin-api-key
ALGOLIA_SEARCH_KEY=your-search-only-api-key
Step 2: Configure the Index
Scout's configuration defines what data gets pushed to Algolia and how it's structured. Create a config file:
<?php
use craft\elements\Entry;
use rias\scout\ScoutIndex;
return [
'application_id' => getenv('ALGOLIA_APP_ID'),
'admin_api_key' => getenv('ALGOLIA_API_KEY'),
'search_api_key' => getenv('ALGOLIA_SEARCH_KEY'),
'indices' => [
ScoutIndex::create('listings')
->elementType(Entry::class)
->criteria(function ($query) {
return $query
->section('listings')
->status('live');
})
->transformer(function (Entry $entry) {
$image = $entry->listingImage->one();
return [
'objectID' => $entry->id,
'title' => $entry->title,
'slug' => $entry->slug,
'url' => $entry->url,
'description' => strip_tags(
(string) ($entry->listingDescription ?? '')
),
// Facet fields (filterable)
'state' => $entry->state->one()?->title ?? '',
'region' => $entry->region->one()?->title ?? '',
'propertyType' => $entry->propertyType->label ?? '',
'status' => $entry->listingStatus->label ?? '',
// Numeric fields (range filters)
'acreage' => (float) ($entry->acreage ?? 0),
'price' => (float) ($entry->price ?? 0),
// Image for result cards
'image' => $image
? $image->getUrl(['width' => 600, 'height' => 400, 'mode' => 'crop'])
: null,
// Geolocation (for map-based search)
'_geoloc' => [
'lat' => (float) ($entry->latitude ?? 0),
'lng' => (float) ($entry->longitude ?? 0),
],
// Timestamp for sorting
'datePosted' => $entry->postDate?->getTimestamp() ?? 0,
];
}),
],
];
The transformer function is where you define the shape of each record in Algolia. Think carefully about what you include here. Every field you add increases the index size and Algolia usage. Only include data you'll actually display in results or use for filtering.
A few things to call out about this transformer:
- Facet fields like
state,region, andpropertyTypeare strings that users will filter on. These need to match exactly, so pull the title or label, not the ID. - Numeric fields like
acreageandpricesupport range filtering (e.g., "100 to 500 acres"). _geolocis a special Algolia field for geo-based search. If your listings have coordinates, you get radius search and map-based results for free.- The image URL is pre-transformed. You don't want InstantSearch making requests back to Craft for image transforms.
Step 3: Configure Algolia Settings
After your first index sync, go to the Algolia dashboard and configure the index settings. These control what's searchable, what's filterable, and how results are ranked.
// You can set these in the Algolia dashboard UI
// or programmatically via the API:
const settings = {
searchableAttributes: [
'title',
'description',
'state',
'region',
],
attributesForFaceting: [
'searchable(state)',
'searchable(region)',
'searchable(propertyType)',
'filterOnly(status)',
'price',
'acreage',
],
customRanking: [
'desc(datePosted)',
],
attributesToHighlight: [
'title',
'description',
],
}
searchableAttributes controls which fields are searched when a user types a keyword. Order matters here. Matches in title rank higher than matches in description.
attributesForFaceting defines which fields can be used as filters. The searchable() wrapper means users can also type to filter within a facet (useful when you have 50 states). filterOnly() means the field is only used for filtering, not displayed as a facet count.
Step 4: Push Your Data
Run the initial index sync from the command line:
# Import all entries to Algolia
php craft scout/index/import listings
After this, Scout keeps the index in sync automatically. Every time an editor saves, publishes, or deletes a listing entry, Scout pushes the change to Algolia within seconds.
Step 5: Build the Front End with InstantSearch.js
InstantSearch.js is Algolia's front-end library. It provides pre-built widgets (search box, filters, result list, pagination) that you wire together and style to match your site.
Install it via npm:
npm install algoliasearch instantsearch.js
Here's the JavaScript that powers the listings page. This is a simplified version of what I shipped in production:
import algoliasearch from 'algoliasearch/lite'
import instantsearch from 'instantsearch.js'
import {
searchBox,
hits,
refinementList,
rangeSlider,
pagination,
stats,
clearRefinements,
configure,
} from 'instantsearch.js/es/widgets'
const searchClient = algoliasearch(
import.meta.env.VITE_ALGOLIA_APP_ID,
import.meta.env.VITE_ALGOLIA_SEARCH_KEY
)
const search = instantsearch({
indexName: 'listings',
searchClient,
routing: true, // Sync filters to URL for shareable links
})
search.addWidgets([
// Only show active listings
configure({
filters: 'status:Active',
hitsPerPage: 12,
}),
// Search box
searchBox({
container: '#searchbox',
placeholder: 'Search by keyword, location, or property name...',
showReset: true,
showLoadingIndicator: true,
cssClasses: {
input: 'search-input',
},
}),
// Result count
stats({
container: '#stats',
templates: {
text: ({ nbHits }) =>
`${nbHits.toLocaleString()} ${nbHits === 1 ? 'property' : 'properties'} found`,
},
}),
// State filter
refinementList({
container: '#filter-state',
attribute: 'state',
sortBy: ['name:asc'],
showMore: true,
showMoreLimit: 50,
searchable: true,
searchablePlaceholder: 'Search states...',
cssClasses: {
checkbox: 'facet-checkbox',
label: 'facet-label',
count: 'facet-count',
},
}),
// Property type filter
refinementList({
container: '#filter-type',
attribute: 'propertyType',
sortBy: ['count:desc'],
}),
// Region filter
refinementList({
container: '#filter-region',
attribute: 'region',
sortBy: ['name:asc'],
searchable: true,
searchablePlaceholder: 'Search regions...',
}),
// Acreage range slider
rangeSlider({
container: '#filter-acreage',
attribute: 'acreage',
pips: false,
tooltips: {
format: (value) => `${Math.round(value).toLocaleString()} acres`,
},
}),
// Price range slider
rangeSlider({
container: '#filter-price',
attribute: 'price',
pips: false,
tooltips: {
format: (value) =>
`$${Math.round(value).toLocaleString()}`,
},
}),
// Clear all filters button
clearRefinements({
container: '#clear-filters',
templates: {
resetLabel: 'Clear all filters',
},
}),
// Results
hits({
container: '#hits',
templates: {
item: (hit, { html, components }) => html`
<a href="${hit.url}" class="listing-card">
${hit.image
? html`<img src="${hit.image}" alt="${hit.title}" loading="lazy">`
: ''
}
<div class="listing-card-body">
<h3>${components.Highlight({ hit, attribute: 'title' })}</h3>
<p class="listing-meta">
${hit.state} · ${hit.acreage.toLocaleString()} acres
</p>
${hit.price > 0
? html`<p class="listing-price">$${hit.price.toLocaleString()}</p>`
: html`<p class="listing-price">Contact for pricing</p>`
}
<span class="listing-type">${hit.propertyType}</span>
</div>
</a>
`,
empty: () => '<p class="no-results">No properties match your filters. Try broadening your search.</p>',
},
}),
// Pagination
pagination({
container: '#pagination',
padding: 2,
}),
])
search.start()
And the HTML structure in your Twig template:
{% extends "_layouts/base" %}
{% block content %}
<div class="listings-page">
<div class="listings-sidebar">
<div id="searchbox"></div>
<div id="clear-filters"></div>
<h4>State</h4>
<div id="filter-state"></div>
<h4>Property Type</h4>
<div id="filter-type"></div>
<h4>Region</h4>
<div id="filter-region"></div>
<h4>Acreage</h4>
<div id="filter-acreage"></div>
<h4>Price</h4>
<div id="filter-price"></div>
</div>
<div class="listings-results">
<div id="stats"></div>
<div id="hits"></div>
<div id="pagination"></div>
</div>
</div>
{% endblock %}
That's the core structure. InstantSearch takes those empty div containers and renders fully interactive widgets into them. The sidebar gets checkboxes and sliders, the results area gets listing cards, and everything updates instantly as the user interacts with any filter.
URL Routing: Shareable Search State
Notice the routing: true option in the InstantSearch config. This is important. It syncs the current search state (active filters, search query, current page) to the URL. So if a user filters by Georgia, over 200 acres, and timberland, the URL updates to something like:
https://example.com/listings?listings[refinementList][state][0]=Georgia&listings[refinementList][propertyType][0]=Timberland&listings[range][acreage][min]=200
This means users can bookmark filtered views, share them with colleagues, and the back button works as expected. Without routing, all filter state lives only in memory and disappears on page reload.
You can customize the URL format if you want cleaner URLs. InstantSearch supports custom routing functions that map the search state to whatever URL structure you want.
Handling Multi-Site
If your Craft installation serves multiple sites (which it did on this project, with listings split across different regional sites), you have a decision to make: one index or multiple indices?
I went with a single index and used a siteHandle attribute to filter by site. This way, the global listings page can show everything, and regional pages filter to their own listings:
// Add to the transformer return array
'siteHandle' => $entry->site->handle,
configure({
filters: 'status:Active AND siteHandle:southeast',
hitsPerPage: 12,
}),
This keeps everything in one Algolia index (one bill, simpler management) while still supporting per-site filtering.
Performance and Costs
A few things I learned about Algolia costs on this project:
- Records are cheap. A few hundred listings barely register on the Algolia bill. Even a few thousand is fine on the free or starter tiers.
- Search operations are what you pay for. Every keystroke, every filter toggle, every page change counts as a search operation. With
routing: trueand debounced input, a typical user session might generate 10-30 operations. - Keep your records small. Don't push full article bodies to Algolia. Push only what you need for display and filtering. Smaller records mean faster search and lower costs.
- The free tier is generous. Algolia's free tier gives you 10,000 search operations per month, which is enough for many small to medium sites.
The "Aha" Moment
The moment that sold this approach for the client was the first time they saw the result count update in real time as they toggled a filter. They clicked "Georgia" and instantly saw "47 properties found" update to "12 properties found." No page reload, no spinner, no waiting. Just instant feedback.
That's the experience that Craft's built-in search can't deliver, and it's what makes Algolia worth the investment for any site where search is a core feature, not just a nice-to-have.
Gotchas
Keeping the Index in Sync
Scout handles most of the sync automatically, but there are edge cases. If you bulk-update entries via a console command or direct database change, Scout won't know about it. Run php craft scout/index/import listings periodically via cron as a safety net.
Draft and Revision Noise
Make sure your Scout criteria filter to ->status('live') so drafts and disabled entries don't end up in the search index. I've seen this happen when the criteria isn't strict enough.
Image Transforms in the Index
Pre-generate your image transform URLs in the Scout transformer. If you push a transform URL that hasn't been generated yet, the first person to load that search result will trigger the transform on your server, which adds latency to the image load.
Algolia + InstantSearch is the combination I reach for whenever a Craft project needs search that goes beyond basic keyword matching. The initial setup takes a day or two, but the result is a search experience that feels like a modern web application, built on top of Craft's solid content management.
If you're building a listings site, directory, or any Craft project where search is central to the user experience, let's talk about how to set it up right.