Skip to main content

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

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

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