> ## Documentation Index
> Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Custom Search Provider

> Step-by-step guide to building a custom search provider for Spree, integrating external search engines like Typesense, Algolia, or Elasticsearch.

## 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](/developer/core-concepts/search-filtering).

<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 app/models/my_app/search_provider/typesense.rb theme={"theme":"night-owl"}
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 app/models/my_app/search_provider/typesense.rb theme={"theme":"night-owl"}
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 app/models/my_app/search_provider/typesense.rb theme={"theme":"night-owl"}
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 app/models/my_app/search_provider/typesense.rb theme={"theme":"night-owl"}
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 config/initializers/spree.rb theme={"theme":"night-owl"}
Spree.search_provider = 'MyApp::SearchProvider::Typesense'
```

Then reindex:

```bash theme={"theme":"night-owl"}
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

```ruby theme={"theme":"night-owl"}
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 theme={"theme":"night-owl"}
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.

## Related Documentation

* [Search & Filtering](/developer/core-concepts/search-filtering) — Store API search reference
* [Meilisearch Integration](/integrations/search/meilisearch) — Built-in Meilisearch provider setup
