Typesense vs Meilisearch for an E-commerce Catalog

You are indexing a product catalog with variants, multi-currency prices, faceted filters across brand, size, and color, and per-store isolation for a multi-tenant marketplace. Both engines will return results in single-digit milliseconds, so latency is not the deciding factor — the decision turns on faceting cardinality, how variant and currency data maps onto a flat document model, geo support for store locators, and whether scoped API keys can enforce tenant isolation at the engine boundary. This guide resolves that choice requirement by requirement, building on the broader Meilisearch vs Typesense architecture guide and the engine-selection frameworks in Search Engine Selection & Architecture. For the wider buy-vs-build question, the search engine selection for SaaS walkthrough covers tenancy economics.

Prerequisites

  1. Meilisearch v1.4+ or Typesense v0.25+ running locally (localhost:7700 / localhost:8108).
  2. A denormalized product feed in NDJSON or JSON, one document per sellable unit.
  3. Decided granularity: index the product or the variant as the primary document.
  4. A master/admin API key per engine for settings and key derivation.

Diagnosis: Why the Catalog Data Model Decides Everything

E-commerce relevance problems are usually data-modeling problems in disguise. A flat search document cannot natively represent “one shirt, five sizes, three colors, two currencies.” You either index one document per variant (faceting on color is exact, but the result list shows the same product five times) or one document per product (the result list is clean, but size:M AND color:blue can match a product where size M exists in red and blue exists in size S — a false positive). Both engines force this choice; neither has true nested-object faceting like Elasticsearch.

A concrete symptom of getting it wrong:

// One product doc with parallel arrays — DON'T do this
{
  "id": "shirt-42",
  "color": ["blue", "red"],
  "size": ["S", "M"],
  "price_eur": [29.0, 31.0]
}
// Query color=blue AND size=M matches even if blue only exists in size S.

The fix is to index at the variant grain and deduplicate the result list in the UI, or use Typesense’s group_by. Currency is the second trap: store every active currency as its own sortable numeric field (price_eur, price_usd) rather than a single price you convert at query time — neither engine can sort on a runtime-computed expression.

The per-requirement decisions fall out cleanly once the data model is settled. For faceting and filtering performance, both engines hold facets in memory and answer in single-digit milliseconds, but Typesense returns facet counts on every query for free while Meilisearch returns them only when you ask, so Typesense edges ahead for dense filter UIs. For typo tolerance, Typesense lets you set num_typos per query (off for SKUs, on for prose) whereas Meilisearch configures it per attribute globally — pick Typesense if the same field must behave differently across pages. For variant deduplication, Typesense’s group_by is decisive; Meilisearch forces application-layer dedup. For multi-currency, both are equal — the work is in the data model, not the engine. For geo (store locators), both support a radius filter on a geopoint, so it is a wash. For synonyms, both load bidirectional sets, but Typesense scopes them per collection as a managed sub-resource, which is cleaner for per-store merchandising. For multi-tenant isolation, both sign scoped tokens; the choice is stylistic. Net: Typesense wins faceting, per-query typo control, and grouping; Meilisearch wins out-of-the-box relevance for natural-language queries and a lighter RAM footprint.

Solution Steps

1. Model variants and currencies as flat sortable fields

Index one document per variant. Carry a product_id for grouping and one numeric field per currency.

// product variant document (Typesense + Meilisearch compatible)
{
  "id": "shirt-42-blue-m",
  "product_id": "shirt-42",
  "title": "Oxford Shirt",
  "brand": "Acme",
  "color": "blue",
  "size": "M",
  "price_eur": 29.0,
  "price_usd": 31.5,
  "in_stock": true,
  "_geo": { "lat": 48.85, "lng": 2.35 }   // nearest store, for locators
}

2. Configure faceting and filtering

Typesense requires facet: true in the schema and exposes facet counts on every query. Declare numeric and string facets explicitly:

# Typesense: create collection with faceted + sortable fields
curl -X POST "http://localhost:8108/collections" \
  -H "X-TYPESENSE-API-KEY: ${TS_ADMIN_KEY}" \
  -d '{
    "name": "variants",
    "fields": [
      {"name": "product_id", "type": "string", "facet": false},
      {"name": "brand", "type": "string", "facet": true},
      {"name": "color", "type": "string", "facet": true},
      {"name": "size",  "type": "string", "facet": true},
      {"name": "price_eur", "type": "float", "facet": true, "sort": true},
      {"name": "in_stock", "type": "bool", "facet": true},
      {"name": "_geo", "type": "geopoint"}
    ],
    "default_sorting_field": "price_eur"
  }'

Meilisearch declares facets through filterableAttributes; counts come back only when you request facets at query time:

# Meilisearch: declare filterable + sortable + searchable facets
curl -X PATCH "http://localhost:7700/indexes/variants/settings" \
  -H "Authorization: Bearer ${MEILI_KEY}" \
  -H 'Content-Type: application/json' \
  -d '{
    "filterableAttributes": ["brand","color","size","price_eur","in_stock","_geo"],
    "sortableAttributes": ["price_eur","price_usd","_geo"]
  }'

3. Run a faceted, geo-bounded, deduplicated query

Typesense group_by collapses variants back to one row per product while keeping exact facet matching:

# Typesense: filter color+size, group by product, sort by price, geo radius
curl "http://localhost:8108/collections/variants/documents/search" \
  -H "X-TYPESENSE-API-KEY: ${TS_SEARCH_KEY}" \
  -G \
  --data-urlencode "q=shirt" \
  --data-urlencode "query_by=title,brand" \
  --data-urlencode "filter_by=color:blue && size:M && _geo:(48.85,2.35,5 km)" \
  --data-urlencode "facet_by=brand,color,size" \
  --data-urlencode "group_by=product_id" \
  --data-urlencode "group_limit=1" \
  --data-urlencode "sort_by=price_eur:asc"

Meilisearch has no group_by; deduplicate by product_id in the application layer after retrieval:

# Meilisearch: same intent, geoRadius filter, facet distribution returned
curl -X POST "http://localhost:7700/indexes/variants/search" \
  -H "Authorization: Bearer ${MEILI_KEY}" \
  -H 'Content-Type: application/json' \
  -d '{
    "q": "shirt",
    "filter": ["color = blue", "size = M", "_geoRadius(48.85, 2.35, 5000)"],
    "facets": ["brand","color","size"],
    "sort": ["price_eur:asc"]
  }'

4. Tune typo tolerance per field

Fuzzy matching must be off for SKUs and on for free text. Disabling typos on identifier fields prevents SKU-1234 from matching SKU-1235.

# Typesense: per-query control — exact SKU lookups disable typos
curl "http://localhost:8108/collections/variants/documents/search" -G \
  -H "X-TYPESENSE-API-KEY: ${TS_SEARCH_KEY}" \
  --data-urlencode "q=SKU-1234" \
  --data-urlencode "query_by=sku" \
  --data-urlencode "num_typos=0"
# Meilisearch: disable typos on the sku attribute globally
curl -X PATCH "http://localhost:7700/indexes/variants/settings/typo-tolerance" \
  -H "Authorization: Bearer ${MEILI_KEY}" \
  -d '{"disableOnAttributes": ["sku"]}'

5. Load synonyms for merchandising vocabulary

Map “trainers” to “sneakers”, “tee” to “t-shirt” so catalog jargon and shopper language converge.

# Meilisearch: bidirectional synonyms
curl -X PUT "http://localhost:7700/indexes/variants/settings/synonyms" \
  -H "Authorization: Bearer ${MEILI_KEY}" \
  -d '{"sneakers": ["trainers"], "trainers": ["sneakers"]}'
# Typesense: synonyms are a sub-resource on the collection
curl -X PUT "http://localhost:8108/collections/variants/synonyms/sneaker-set" \
  -H "X-TYPESENSE-API-KEY: ${TS_ADMIN_KEY}" \
  -d '{"synonyms": ["sneakers", "trainers"]}'

6. Enforce multi-tenant isolation with scoped keys

For a multi-tenant marketplace, derive a per-store search key that embeds a mandatory filter. Typesense signs a scoped key from a parent search-only key, so the tenant filter cannot be stripped client-side:

# Typesense scoped key: locks every query to one store
import hmac, hashlib, base64, json

parent_key = "SEARCH_ONLY_PARENT_KEY"
params = {"filter_by": "store_id:store_42"}
digest = base64.b64encode(
    hmac.new(parent_key.encode(), json.dumps(params).encode(), hashlib.sha256).digest()
).decode()
scoped_key = base64.b64encode(
    (digest + parent_key[:4] + json.dumps(params)).encode()
).decode()
# Ship scoped_key to store_42's frontend; it can never read another store.

Meilisearch issues tenant tokens signed from an API key, embedding searchRules that pin the index and filter:

# Meilisearch tenant token with a per-store search rule
import meilisearch
client = meilisearch.Client("http://localhost:7700", "MASTER_KEY")
token = client.generate_tenant_token(
    api_key_uid="API_KEY_UID",
    search_rules={"variants": {"filter": "store_id = store_42"}},
)

Verification

Confirm group_by collapsed duplicates and the facet distribution is exact:

# Expect found_count > grouped_hits length; each group_key is a product_id
curl "http://localhost:8108/collections/variants/documents/search" -G \
  -H "X-TYPESENSE-API-KEY: ${TS_SEARCH_KEY}" \
  --data-urlencode "q=*" \
  --data-urlencode "group_by=product_id" \
  --data-urlencode "facet_by=color" | \
  python -c "import sys,json; d=json.load(sys.stdin); print('groups:', len(d['grouped_hits']), 'facet:', d['facet_counts'][0]['counts'][:3])"

Expected output shows fewer groups than raw hits and per-value counts:

groups: 18 facet: [{'value': 'blue', 'count': 7}, {'value': 'red', 'count': 5}, {'value': 'black', 'count': 6}]

Verify a scoped key cannot escape its tenant — a cross-store filter must return zero rows:

# Using store_42's scoped key, ask for store_99 — must be empty
curl "http://localhost:8108/collections/variants/documents/search" -G \
  -H "X-TYPESENSE-API-KEY: ${SCOPED_STORE_42_KEY}" \
  --data-urlencode "q=*" \
  --data-urlencode "filter_by=store_id:store_99"
# Expected: {"found": 0, "hits": []}

Common Pitfalls

Faceting on a high-cardinality field balloons memory

Faceting on product_id or sku (thousands of unique values) forces both engines to hold the full value set in memory and inflates the facet payload. Facet only on bounded fields — brand, color, size, in_stock. Use those identifier fields for filtering, never for facet_by / facets.

Single price field breaks multi-currency sorting

If you store one price and convert per request, neither engine can sort or facet on the converted value — sorting happens on the stored field only. Materialize one sortable numeric field per active currency (price_eur, price_usd) at index time and pick the field name based on the shopper’s locale.

Meilisearch lacks group_by, so variants duplicate in results

With variant-grain documents, Meilisearch returns every matching variant, so one product appears multiple times. Either index at product grain and accept looser facet matching, or retrieve extra hits and deduplicate by product_id in the API layer before paginating. Typesense’s group_by avoids the round-trip.