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
- Check your Algolia plan and billing cycle. Grow is pay-as-you-go and billed monthly. Elevate is an annual contract, so find the renewal date and notice period first.
- Note your record count and average record size (Dashboard → Indices), and monthly search requests (Usage). These are the inputs for sizing in step 2.
- Export the top 100 queries from Algolia Analytics now. You’ll need them in step 8.
- List features beyond plain search. Each of these needs a decision: Rules with custom data or banners, Personalization, NeuralSearch, Recommend, Query Suggestions, A/B testing, and the Insights events behind Click and Conversion Analytics. None of them map one to one.
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
- Stop dual-writes, then delete the Algolia indices and revoke every API key, including keys built into old app versions.
- Downgrade or close the Algolia application near the end of the billing period. On Elevate, send termination notice per the contract.
- Delete unused Typesense keys, and keep the reindex script. A new schema version means creating
products_v2, importing, then pointing theproductsalias at it.
Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.