Back to Spree

Build a Custom Search Provider

docs/developer/how-to/custom-search-provider.mdx

5.4.28.3 KB
Original Source

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.

<Info> Spree ships with a built-in [Meilisearch provider](/integrations/search/meilisearch). If Meilisearch fits your needs, you don't need to build a custom provider — just configure it. </Info>

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:

ruby
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:

ruby
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
<Warning> 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. </Warning>

Step 3: Implement Indexing

Products are indexed as one document per market × locale combination. The ProductPresenter handles this automatically:

ruby
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 preload_associations_lazily to avoid N+1 queries:

ruby
def reindex(scope = nil)
  scope ||= store.products
  ensure_index_settings!

  scope.reorder(id: :asc)
       .preload_associations_lazily
       .find_in_batches(batch_size: 500) do |batch|
    documents = batch.flat_map { |p| presenter_class.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
<Info> Use `flat_map` (not `map`) because `ProductPresenter#call` returns an array of documents per product. </Info>

Step 5: Register the Provider

ruby
Spree.search_provider = 'MyApp::SearchProvider::Typesense'

Then reindex:

bash
rake spree:search:reindex

Provider Contract Reference

MethodRequiredDescription
search_and_filter(scope:, query:, filters:, sort:, page:, limit:)YesSearch, filter, sort, paginate, and return facets. Must return a SearchResult.
self.indexing_required?YesReturn true to enable background indexing jobs.
index(product)YesIndex a single product. ProductPresenter#call returns an array of documents.
remove(product)YesRemove all locale/currency variants from the index.
remove_by_id(prefixed_id)YesRemove by prefixed product ID (product may already be deleted).
reindex(scope)YesBulk reindex with ensure_index_settings! + batch indexing.
index_batch(documents)YesIndex a batch of pre-serialized documents.
ensure_index_settings!NoConfigure index schema. Called by reindex and rake task.

SearchResult

ruby
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

ruby
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:

MethodDescription
product.add_to_search_indexIndex synchronously (inline)
product.remove_from_search_indexRemove synchronously (inline)
product.search_presentationPreview the documents that would be indexed
rake spree:search:reindexBulk 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.