Overview
This guide walks you through building a custom search provider for Spree. By the end, you’ll have a fully functional search integration that:
- Powers product search, filtering, sorting, and faceted navigation
- Handles multi-locale and multi-currency indexing automatically
- Integrates with the Store API without any frontend changes
- Supports background indexing and bulk reindex
Before starting, make sure you understand how search and filtering works in Spree.
Spree ships with a built-in Meilisearch provider. If Meilisearch fits your needs, you don’t need to build a custom provider — just configure it.
Architecture
Store API Request (locale=de, currency=EUR)
│
├─ AR Scope (security + visibility)
│ store.products.active(currency).accessible_by(ability)
│
└─ Search Provider (search + filter + facets)
├─ Database (default): ILIKE + Ransack + FiltersAggregator
├─ Meilisearch (built-in): one API call with locale/currency filtering
└─ Your Provider: implements the same interface
The controller builds a base ActiveRecord scope for security and visibility, then delegates everything else to your search provider.
Step 1: Create the Provider Class
Create a class that inherits from Spree::SearchProvider::Base and implements search_and_filter:
app/models/my_app/search_provider/typesense.rb
module MyApp
module SearchProvider
class Typesense < Spree::SearchProvider::Base
# Enable background indexing jobs
def self.indexing_required?
true
end
def initialize(store)
super
require 'typesense'
rescue LoadError
raise LoadError, "Add `gem 'typesense'` to your Gemfile"
end
end
end
end
indexing_required? returning true tells the SearchIndexable concern to enqueue background jobs when products are created, updated, or destroyed.
Step 2: Implement search_and_filter
This is the core method. It receives a base AR scope (already filtered for security) and must return a SearchResult:
app/models/my_app/search_provider/typesense.rb
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
page = [page.to_i, 1].max
limit = limit.to_i.clamp(1, 100)
# 1. Query your search engine with locale/currency filtering
results = client.collections[index_name].documents.search({
q: query || '*',
query_by: 'name,description,sku,option_values,category_names,tags',
filter_by: build_filters(filters),
sort_by: build_sort(sort),
page: page,
per_page: limit,
facet_by: 'in_stock,price,category_ids,option_value_ids'
})
# 2. Extract product IDs (documents have composite IDs, extract product_id)
product_ids = results['hits'].map { |h| h['document']['product_id'] }.uniq
raw_ids = product_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
# 3. Intersect with AR scope (safety net for authorization)
products = raw_ids.any? ? scope.where(id: raw_ids).reorder(nil) : scope.none
# 4. Build Pagy for pagination metadata
require 'pagy'
pagy = Pagy::Offset.new(count: results['found'], page: page, limit: limit)
# 5. Return a SearchResult
Spree::SearchProvider::SearchResult.new(
products: products,
filters: build_facet_response(results['facet_counts']),
sort_options: %w[price -price name -name best_selling -available_on].map { |id| { id: id } },
default_sort: 'manual',
total_count: results['found'],
pagy: pagy
)
end
Always filter by locale, currency, store_ids, status='active', and discontinue_on in your search engine — not just in the AR scope. This ensures pagination counts are accurate. The AR scope is a safety net, not the primary filter.
Step 3: Implement Indexing
Products are indexed as one document per market × locale combination. The ProductPresenter handles this automatically:
app/models/my_app/search_provider/typesense.rb
def index(product)
documents = Spree::SearchProvider::ProductPresenter.new(product, store).call
documents.each do |doc|
client.collections[index_name].documents.upsert(doc)
end
end
def remove(product)
remove_by_id(product.prefixed_id)
end
def remove_by_id(prefixed_id)
# Delete all locale/currency variants of this product
client.collections[index_name].documents.delete(
filter_by: "product_id:=#{prefixed_id}"
)
end
The ProductPresenter returns an array of documents. For a store with US (USD/English) and EU (EUR/German+French) markets, one product produces 3 documents — each with flat name, price, locale, currency fields.
Step 4: Implement Bulk Reindex
Use ProductPresenter::REQUIRED_PRELOADS to avoid N+1 queries:
app/models/my_app/search_provider/typesense.rb
def reindex(scope = nil)
scope ||= store.products
ensure_index_settings!
scope.reorder(id: :asc)
.preload(*Spree::SearchProvider::ProductPresenter::REQUIRED_PRELOADS)
.find_in_batches(batch_size: 500) do |batch|
documents = batch.flat_map { |p| Spree::SearchProvider::ProductPresenter.new(p, store).call }
index_batch(documents)
end
end
def index_batch(documents)
client.collections[index_name].documents.import(documents, action: 'upsert')
end
def ensure_index_settings!
# Create/update your collection schema here
end
Use flat_map (not map) because ProductPresenter#call returns an array of documents per product.
Step 5: Register the Provider
config/initializers/spree.rb
Spree.search_provider = 'MyApp::SearchProvider::Typesense'
Then reindex:
rake spree:search:reindex
Provider Contract Reference
| Method | Required | Description |
|---|
search_and_filter(scope:, query:, filters:, sort:, page:, limit:) | Yes | Search, filter, sort, paginate, and return facets. Must return a SearchResult. |
self.indexing_required? | Yes | Return true to enable background indexing jobs. |
index(product) | Yes | Index a single product. ProductPresenter#call returns an array of documents. |
remove(product) | Yes | Remove all locale/currency variants from the index. |
remove_by_id(prefixed_id) | Yes | Remove by prefixed product ID (product may already be deleted). |
reindex(scope) | Yes | Bulk reindex with ensure_index_settings! + batch indexing. |
index_batch(documents) | Yes | Index a batch of pre-serialized documents. |
ensure_index_settings! | No | Configure index schema. Called by reindex and rake task. |
SearchResult
Spree::SearchProvider::SearchResult.new(
products: ar_relation, # ActiveRecord::Relation
filters: [...], # Array of facet hashes
sort_options: [{ id: 'price' }, ...], # Array of sort option objects
default_sort: 'manual', # Default sort string
total_count: 150, # Total before pagination
pagy: pagy_object # Pagy::Offset or Pagy::Meilisearch
)
ProductPresenter
documents = Spree::SearchProvider::ProductPresenter.new(product, store).call
# => [
# { prefixed_id: "prod_abc_en_USD", product_id: "prod_abc", locale: "en",
# currency: "USD", name: "Blue Shirt", price: 29.99, ... },
# { prefixed_id: "prod_abc_de_EUR", product_id: "prod_abc", locale: "de",
# currency: "EUR", name: "Blaues Hemd", price: 27.50, ... }
# ]
Indexing Lifecycle
The Spree::SearchIndexable concern on Product provides:
| Method | Description |
|---|
product.add_to_search_index | Index synchronously (inline) |
product.remove_from_search_index | Remove synchronously (inline) |
product.search_presentation | Preview the documents that would be indexed |
rake spree:search:reindex | Bulk reindex all products |
Background jobs (IndexJob, RemoveJob) fire on after_commit when indexing_required? is true.
Important: Prefixed IDs
Always use prefixed IDs (ctg_abc, prod_xyz, optval_abc) when indexing. Never use raw database IDs — Spree supports UUID primary keys.