SV Premium

Migration guides

Migrate site search from Algolia to Typesense Cloud

Move records and search settings to Typesense, connect your InstantSearch UI through an adapter, and compare relevance before switching traffic.

From Algolia to Typesense Cloud · Published 2026-10-08

Before you commit

Hands-on effort
2–5 days for product or docs search; more for rules, personalization or NeuralSearch
Elapsed time
Add relevance testing and keep dual writes running for at least two weeks after reaching full traffic.
Examples cover
Typesense Server 30.x · Algolia JavaScript SDK v5
Content review
2026-10-09
Integration testing
Not recorded. Rehearse the commands and rollback on staging.

Prerequisites

  • Algolia exports and analytics access, a Typesense cluster, staging and owners for indexing and frontend search.
  • Measured record sizes, peak query load and a decision on production high availability.

Don’t migrate yet if…

  • Required personalization, NeuralSearch, analytics or merchandising features have no acceptable replacement.
  • The proposed cluster cannot meet tested peak load or availability requirements.

Algolia’s Grow plan bills per search request and per stored record: $0.50 per 1,000 searches above 10,000 a month, and $0.40 per 1,000 records above 100,000. With search-as-you-type, every keystroke can be a billable request. Typesense Cloud charges for cluster hours, RAM and vCPU, without per-search or per-record fees.

At 1 million records and 5 million searches a month, Algolia comes to about $2,855/month. A 4-GB Typesense Cloud node costs about $72/month, or $237.60 for a 3-node high-availability cluster. Confirm sizing with your record sizes and peak query load. Try the example in the calculator.

Copying records is the quick part. Your schema, ranking settings and frontend widgets decide what users find, so test with real queries before switching traffic.

0. Before you start

1. Export from Algolia

The Algolia CLI exports records, rules and synonyms as NDJSON (one JSON object per line), and settings as a single JSON object:

algolia objects browse products > records.ndjson
algolia settings get products | jq . > settings.json
algolia synonyms browse products > synonyms.ndjson
algolia rules browse products > rules.ndjson

Repeat settings get for each replica. You only need their ranking and customRanking, which become sort options. To export from code instead, the v5 JavaScript client’s browseObjects helper pages through the whole index:

import { algoliasearch } from 'algoliasearch';

const client = algoliasearch(APP_ID, ADMIN_KEY);
const records = [];
await client.browseObjects({
  indexName: 'products',
  aggregator: (res) => records.push(...res.hits),
});

2. Create the cluster and size it

Typesense’s system requirements say you typically need 2–3× the size of your searchable fields in RAM. Fields you only display don’t count. A million 1 KB records needs about 2–3 GB, so pick 4 GB. For CPU, the same page measured a 4 vCPU node at about 100 concurrent searches per second. Use burstable vCPUs for low, spiky traffic and at least 4 vCPUs for sustained load.

For production, Typesense strongly recommends a 3-node cluster. It survives one node failing, and Typesense Cloud gives it a load-balanced hostname. Pick the region closest to your users. The examples in this guide use Typesense Server v30.x; older versions use different endpoints for synonyms and curation (step 5).

Create a search-only key for browsers. The admin key never leaves your servers:

curl "https://$TS_HOST/keys" -X POST \
  -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"description":"Search-only","actions":["documents:search"],"collections":["products"]}'

If you used Algolia secured API keys to restrict results per user, generate scoped search keys from this key with client.keys().generateScopedSearchKey(searchKey, { filter_by: 'tenant_id:=42', expires_at }). They’re signed locally, with no API call.

3. Define the schema

Algolia infers types from your records. Typesense needs a declared field for everything you search, filter, facet or sort on. Other fields are stored and returned but not indexed.

Algolia Typesense
Index Collection, behind an alias so you can reindex without downtime
objectID id (string)
searchableAttributes order query_by field order, plus query_by_weights (0–127)
customRanking default_sorting_field (one int32/float), or sort_by=_text_match:desc,popularity:desc (up to 3 fields)
attributesForFaceting "facet": true on the field. filterOnly(...) fields just need to be indexed
Replicas for sort orders sort_by at query time. No replicas needed
Synonyms Synonym sets (/synonym_sets, v30+)
Rules (pin, hide, filter) Curation sets (/curation_sets, v30+; called “overrides” before v30)
typoTolerance, minWordSizefor1Typo num_typos, min_len_1typo, min_len_2typo, typo_tokens_threshold
distinct + attributeForDistinct group_by (on a faceted field) with group_limit=1
_geoloc, aroundLatLng, aroundRadius geopoint field, filter_by=location:(lat, lng, 5 km), sort_by=location(lat, lng):asc
attributesToRetrieve include_fields / exclude_fields
Search-only / secured API keys Search-only key / scoped search keys

Most Algolia settings are query parameters in Typesense, not index settings. They go in the frontend adapter config in step 7.

curl "https://$TS_HOST/collections" -X POST \
  -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "products_v1",
    "fields": [
      {"name": "name",        "type": "string"},
      {"name": "description", "type": "string"},
      {"name": "brand",       "type": "string",   "facet": true},
      {"name": "categories",  "type": "string[]", "facet": true},
      {"name": "price",       "type": "float",    "facet": true},
      {"name": "popularity",  "type": "int32"},
      {"name": "location",    "type": "geopoint", "optional": true}
    ],
    "default_sorting_field": "popularity"
  }'

curl "https://$TS_HOST/aliases/products" -X PUT \
  -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"collection_name": "products_v1"}'

Mark fields "optional": true if some records lack them. Otherwise those records are rejected.

4. Transform and import

Rename objectID to id, and turn _geoloc into a [lat, lng] array. Note the order: it’s latitude first, the reverse of GeoJSON.

jq -c '.id = .objectID | del(.objectID)
       | if ._geoloc then .location = [._geoloc.lat, ._geoloc.lng] | del(._geoloc) else . end' \
  records.ndjson > records.jsonl

curl "https://$TS_HOST/collections/products/documents/import?action=upsert" -X POST \
  -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" -H "Content-Type: text/plain" \
  --data-binary @records.jsonl > import-result.jsonl

grep -c '"success":false' import-result.jsonl

The import endpoint always returns HTTP 200, with one result line per document. Failures are usually type mismatches against the schema. Fix them and re-run; upsert makes re-runs safe.

5. Synonyms and rules

Convert synonyms.ndjson into one synonym set. Algolia synonym maps to a multi-way item. oneWaySynonym maps to an item with root set to the Algolia input.

curl "https://$TS_HOST/synonym_sets/products-synonyms" -X PUT \
  -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"items":[{"id":"coat","synonyms":["coat","jacket","blazer"]},
                {"id":"phone","root":"smartphone","synonyms":["iphone","android"]}]}'

curl "https://$TS_HOST/collections/products_v1" -X PATCH \
  -H "X-TYPESENSE-API-KEY: $TS_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"synonym_sets":["products-synonyms"]}'

Rebuild Algolia rules by hand as curation sets, and attach them the same way with "curation_sets". Promote and hide map to includes (with position) and excludes. Query-based filters map to filter_by with placeholders such as {brand}. Rules that return custom data or banners need app code.

Older tutorials use /collections/{name}/synonyms and /overrides. Those endpoints are gone in v30, and keys scoped to synonyms:* or overrides:* get a 401 on the new routes.

6. Keep the index in sync

Change your indexing job to write to both engines until cutover. Write to Algolia first, so a Typesense error can’t break production:

import Typesense from 'typesense';

const ts = new Typesense.Client({
  nodes: [{ host: process.env.TS_HOST, port: 443, protocol: 'https' }],
  apiKey: process.env.TS_ADMIN_KEY,
});

await algolia.saveObjects({ indexName: 'products', objects });
await ts.collections('products').documents()
  .import(objects.map(toTypesense), { action: 'upsert' });
// deletes: ts.collections('products').documents(id).delete()

toTypesense applies the same transform as the jq step. The Typesense client throws if any document in the batch fails, so catch and log that error instead of failing the whole job. Once dual-write is live, run the full import from step 4 again to pick up changes made in between.

7. Swap the frontend

typesense-instantsearch-adapter gives InstantSearch.js and React InstantSearch a search client that talks to Typesense. Widgets, routing and templates stay as they are.

npm i typesense-instantsearch-adapter @babel/runtime

Before:

import { liteClient as algoliasearch } from 'algoliasearch/lite';
const searchClient = algoliasearch('APP_ID', 'SEARCH_ONLY_KEY');

After:

import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter';

const adapter = new TypesenseInstantSearchAdapter({
  server: {
    apiKey: 'SEARCH_ONLY_KEY',
    nodes: [{ host: 'xxx.typesense.net', port: 443, protocol: 'https' }], // from the cluster dashboard
    cacheSearchResultsForSeconds: 120,
  },
  additionalSearchParameters: {
    query_by: 'name,brand,categories,description', // your searchableAttributes, in order
    query_by_weights: '4,3,2,1',
  },
  geoLocationField: 'location', // only if you use geo widgets
});
const searchClient = adapter.searchClient;

<InstantSearch indexName="products" searchClient={searchClient}>…</InstantSearch>

Replicas in a SortBy widget become products/sort/price:asc and products/sort/price:desc. Remove the Insights middleware, or keep sending events to Algolia until you’ve chosen a replacement.

8. Compare relevance

Pull the top 100 queries from the Analytics API (use analytics.de.algolia.com for EU apps) and compare the top 5 IDs from each engine:

curl -s "https://analytics.us.algolia.com/2/searches?index=products&limit=100" \
  -H "x-algolia-application-id: $ALGOLIA_APP" -H "x-algolia-api-key: $ALGOLIA_ANALYTICS_KEY" \
| jq -r '.searches[].search' | while read -r q; do
  a=$(curl -s "https://$ALGOLIA_APP-dsn.algolia.net/1/indexes/products/query" \
        -H "x-algolia-application-id: $ALGOLIA_APP" -H "x-algolia-api-key: $ALGOLIA_SEARCH_KEY" \
        -d "$(jq -n --arg q "$q" '{query:$q, hitsPerPage:5}')" | jq -r '[.hits[].objectID]|join(",")')
  t=$(curl -s -G "https://$TS_HOST/collections/products/documents/search" \
        -H "X-TYPESENSE-API-KEY: $TS_SEARCH_KEY" --data-urlencode "q=$q" \
        -d query_by=name,brand,categories,description -d per_page=5 | jq -r '[.hits[].document.id]|join(",")')
  [ "$a" = "$t" ] || printf '%s\n  algolia:   %s\n  typesense: %s\n' "$q" "$a" "$t"
done

Expect differences in order. Look for queries where a clearly right result is missing. Fix those with query_by_weights, sort_by, synonyms or curation, not one query at a time. Also try a few misspellings and zero-result queries.

9. Rollback plan

Put the search client behind a feature flag (flags.typesense ? adapter.searchClient : algoliaClient) and ramp up traffic gradually. Keep dual-write running and the Algolia index current for at least two weeks after reaching 100%. To roll back, flip the flag.

10. Clean up

Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.