Migrating from Algolia to Meilisearch
Algolia is a great product. It's also a closed, usage-based one: you pay per search and per record, the price climbs as your traffic grows, and the engine itself is a black box you can't run, read, or self-host. For a lot of teams that's a fine trade until it isn't. Two things usually tip the scale: the bill, and the realization that your search, one of the most important parts of your product, lives entirely inside someone else's proprietary service.
Meilisearch is the open-source answer. It's MIT-licensed, you can run it anywhere, and it's built for the exact same job: instant, typo-tolerant, faceted search that returns results on every keystroke. If you've already shipped Algolia, the move is more mechanical than scary. Your records become documents, most of your index settings have direct equivalents, and your frontend can keep using the InstantSearch widgets you already wrote.
This guide walks the whole path: stand up Meilisearch locally, copy your records and settings out of Algolia, translate what needs translating, and swap your frontend. We'll be honest about the parts that don't map, too.
Contents
- What Maps and What Doesn't
- Set Up Meilisearch Locally with SpinDB
- Pull Your Data from Algolia
- Translate Your Index Settings
- Push Into Meilisearch
- Update Your Frontend
- What Changes in Search Behavior
- The Managed Path: Layerbase Cloud
What Maps and What Doesn't
Algolia and Meilisearch are the same kind of thing: an index of JSON documents with a primary key, ranked for as-you-type relevance. So the core concepts line up almost one to one. The friction is all in the index settings, where Algolia has accumulated a lot of surface area.
Here's the honest mapping:
| Algolia | Meilisearch | Notes |
|---|---|---|
| Records | Documents | Same JSON. objectID becomes the primary key. |
searchableAttributes | searchableAttributes | Direct. Algolia's unordered(x) modifiers and comma-grouped "a,b" flatten into a plain ordered list. |
attributesForFaceting | filterableAttributes (+ sortableAttributes) | Algolia overloads one setting for filtering and faceting; Meilisearch splits them. filterOnly(x) and searchable(x) modifiers get stripped. |
customRanking | rankingRules | desc(popularity) becomes the rule popularity:desc, appended after Meilisearch's defaults. Those attributes also become sortable. |
ranking | rankingRules (defaults) | Algolia's fixed tie-break formula has no 1:1 equivalent. Keep Meilisearch's defaults and tune from there. |
replicas (for sort orders) | Query-time sort on sortableAttributes | Multiple Algolia replica indexes collapse into one Meilisearch index you sort at query time. |
synonyms | synonyms | Two-way and one-way synonyms map; alt-corrections and placeholders do not. |
typoTolerance | typoTolerance | minWordSizefor1Typo / 2Typos map to minWordSizeForTypos. |
And the parts with no clean target, which you should plan to rebuild or drop:
- Query Rules (merchandising, pinning, query rewriting): Meilisearch has no rule engine. Recreate critical merchandising by hand.
- Personalization, Recommend, A/B testing, Analytics, NeuralSearch: these are Algolia's paid layer and have no Meilisearch equivalent.
If your search is "index of things, type to find them, filter by a few facets," basically everything migrates. If you lean hard on Query Rules and Personalization, budget for rebuilding that part.
Set Up Meilisearch Locally with SpinDB
Before you touch production, get a Meilisearch instance running locally and migrate into it so you can compare results side by side. The fastest way is SpinDB: one CLI, no Docker, no manual binary downloads. (What is SpinDB?)
Install SpinDB:
npm i -g spindb # npm
pnpm add -g spindb # pnpmCreate and start a Meilisearch instance:
spindb create algolia-migration -e meilisearch --startSpinDB downloads the Meilisearch binary for your platform, configures it, and starts the server. Grab the URL:
spindb url algolia-migrationhttp://127.0.0.1:7700A local SpinDB instance runs without a master key, so there's nothing to authenticate against while you test. Leave it running. We'll migrate into it from a script next.
Set up a small project to hold the migration script:
mkdir algolia-to-meili && cd algolia-to-meili
pnpm init
pnpm add algoliasearch meilisearch
pnpm add -D tsx typescriptPull Your Data from Algolia
Two things come out of Algolia: your records and your index settings. You'll need an Algolia Application ID and an Admin API key (Dashboard, then Settings, then API Keys). A search-only key will not work here: browsing every record and reading settings both require admin access.
Create migrate.ts. First, the read side:
import { algoliasearch } from 'algoliasearch'
const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID!
const ALGOLIA_ADMIN_KEY = process.env.ALGOLIA_ADMIN_KEY!
const INDEX = 'your_index_name'
const algolia = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_ADMIN_KEY)
// Browse every record. browseObjects pages through the whole index with a
// cursor, so it bypasses the 1000-hit cap that normal search has.
const records: Record<string, unknown>[] = []
await algolia.browseObjects({
indexName: INDEX,
aggregator: (response) => {
records.push(...response.hits)
},
})
// Read the index settings (searchable attributes, ranking, faceting, etc.)
const settings = await algolia.getSettings({ indexName: INDEX })
console.log(`Pulled ${records.length} records from Algolia index "${INDEX}"`)browseObjects is the important one. Normal Algolia search caps you at 1000 hits per query; browseObjects walks the entire index with a cursor, so you get every record no matter how large the index is.
Translate Your Index Settings
This is the only real logic in the migration: turning Algolia's settings object into Meilisearch's. The table above becomes a function. It's short:
type AlgoliaSettings = {
searchableAttributes?: string[]
attributesForFaceting?: string[]
customRanking?: string[]
}
function toMeiliSettings(algolia: AlgoliaSettings) {
// unordered(x) / filterOnly(x) / searchable(x) -> x
const strip = (a: string) => a.replace(/^\w+\((.+)\)$/, '$1').trim()
// searchableAttributes: expand comma groups, strip modifiers
const searchableAttributes = (algolia.searchableAttributes ?? []).flatMap(
(entry) => entry.split(',').map(strip),
)
// attributesForFaceting -> filterableAttributes
const filterableAttributes = (algolia.attributesForFaceting ?? []).map(strip)
// customRanking -> ranking rules (after the defaults) + sortable attributes
const rankingRules = [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
]
const sortableAttributes: string[] = []
for (const rule of algolia.customRanking ?? []) {
const match = rule.match(/^(asc|desc)\((.+)\)$/)
if (match) {
rankingRules.push(`${match[2]}:${match[1]}`)
sortableAttributes.push(match[2])
}
}
return {
searchableAttributes: searchableAttributes.length
? searchableAttributes
: ['*'],
filterableAttributes,
sortableAttributes,
rankingRules,
}
}That covers the settings the vast majority of indexes actually use. Synonyms and typo tolerance map cleanly too if you use them (fetch them with algolia.searchSynonyms({ indexName: INDEX }) and Meilisearch's synonyms setting). Query Rules and Personalization are the ones to leave behind and rebuild deliberately.
Push Into Meilisearch
Now the write side. Add this to the bottom of migrate.ts:
import { Meilisearch } from 'meilisearch'
const meili = new Meilisearch({ host: 'http://localhost:7700' })
// Algolia records use `objectID` as their key, so make it the Meili primary key.
const createTask = await meili.createIndex(INDEX, { primaryKey: 'objectID' })
await meili.waitForTask(createTask.taskUid)
// Push the records in. addDocuments dedupes by primary key, so re-running is safe.
const addTask = await meili.index(INDEX).addDocuments(records)
await meili.waitForTask(addTask.taskUid)
// Apply the translated settings.
const settingsTask = await meili
.index(INDEX)
.updateSettings(toMeiliSettings(settings))
await meili.waitForTask(settingsTask.taskUid)
console.log(`Migrated ${records.length} documents into Meilisearch`)Run it:
ALGOLIA_APP_ID=XXXX ALGOLIA_ADMIN_KEY=yyyy npx tsx migrate.tsPulled 25043 records from Algolia index "your_index_name"
Migrated 25043 documents into MeilisearchOne detail worth knowing: Meilisearch primary key values must match ^[a-zA-Z0-9_-]+$. Most Algolia objectIDs already do (product_42, a UUID, a numeric id). If yours contain slashes or spaces, sanitize them into a clean id field before pushing and set that as the primary key instead.
Confirm it worked by searching, with the same typo-tolerance you had on Algolia:
curl 'http://localhost:7700/indexes/your_index_name/search' \
-H 'Content-Type: application/json' \
--data '{ "q": "your serch term", "limit": 3 }'Update Your Frontend
This is the part people worry about, and it's the part Meilisearch made easy. There are two cases.
If you use InstantSearch widgets (the React/Vue InstantSearch libraries), Meilisearch ships an official adapter. You swap the search client and your entire widget tree stays:
// Before (Algolia):
// import algoliasearch from 'algoliasearch/lite'
// const searchClient = algoliasearch('APP_ID', 'SEARCH_KEY')
// After (Meilisearch):
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'
const { searchClient } = instantMeiliSearch(
'http://localhost:7700',
'YOUR_SEARCH_KEY',
)
// Your <InstantSearch indexName="..."> and <SearchBox>, <Hits>,
// <RefinementList> widgets do not change.If you call the Algolia client directly, the shapes are close. The main differences are a couple of parameter names and the response fields:
import { Meilisearch } from 'meilisearch'
const client = new Meilisearch({ host: 'http://localhost:7700' })
// Algolia: index.search(query, { hitsPerPage: 8, filters: 'genre:drama' })
// Meilisearch:
const { hits, estimatedTotalHits } = await client
.index('your_index_name')
.search(query, { limit: 8, filter: 'genre = "drama"' })
// Response remap: nbHits -> estimatedTotalHits, page/hitsPerPage -> offset/limitThat's the whole frontend change for most apps: one client swap, or a small rewrite of your search call sites.
What Changes in Search Behavior
Set this expectation with your team before you cut over: results will be equivalent, not byte-identical. Algolia and Meilisearch are different engines with different ranking internals, so even with the settings translated perfectly, the exact order of results will differ on some queries. That's not a bug, it's two engines ranking the same documents their own way.
Run them side by side on your real queries (this is exactly why we stood up a local instance first), tune rankingRules and searchableAttributes order where the ordering matters, and you'll land somewhere that feels right. Typo tolerance, prefix search, and faceting all behave the way you're used to.
The Managed Path: Layerbase Cloud
Everything above is the do-it-yourself version, and it's worth doing once to understand what actually moves. When you'd rather not run the migration script or host Meilisearch yourself, Layerbase Cloud has a migration wizard that does the same pull-translate-push for you, server-side. It's the same five steps you wrote by hand, turned into a form.
Here's the whole flow:
1. Start the migration. In the dashboard, go to Create, then Migrate to Layerbase (the "migrate from another platform" flow), and pick Algolia as the source.
2. Paste your credentials. Enter your Algolia Application ID and an Admin API key, the same two values the script uses. They're used once to read your data and are never stored. (A search-only key won't work here, for the same reason as the script: browsing records and reading settings need admin access.)
3. Find your index. Click Find databases. The wizard calls Algolia and lists your indexes with their record counts, for example your_index_name (25043 records). Pick the one you want to bring over and give the new database a name.
4. Migrate. Click Migrate to Layerbase. It provisions a fresh managed Meilisearch database and runs the migration server-side: it browses every record from Algolia, translates your settings (searchable attributes, ranking, faceting, synonyms) exactly the way the script above does, and loads them into the new index. You watch live progress while it runs, for example Pushed 17000 documents, so a large index isn't a black box.
5. Review and wire up. When it finishes, the completion screen does three useful things:
- It shows a review report, a count of what migrated plus a "review manually" note for anything that didn't translate cleanly (the Algolia ranking formula, Query Rules, and so on), so nothing vanishes silently.
- It generates the exact frontend swap snippets, both the InstantSearch one-liner and the direct-client version, with your new browser-safe search key already filled in. Copy, paste, done.
- The database's Query tab is a search explorer: a search box with result cards, image previews, and highlighted matches, so you can sanity-check relevance the second the migration completes, before you change a line of frontend code.
Cloud instances use TLS, so your client points at the https:// endpoint with the search key from the completion screen:
const client = new Meilisearch({
host: 'https://your-host.cloud.layerbase.dev',
apiKey: 'YOUR_SEARCH_KEY', // the browser-safe search key, not the master key
})The managed version also hands you the right keys by default: a search key for the browser and an admin key for server-side index management, the same split Algolia gives you between a search key and an admin key, so you're not shipping a write-capable key to the client.
Either way, local or managed, you end up off the per-search meter and on an engine you can actually run and read.
Wrapping Up
Migrating from Algolia to Meilisearch is mostly mechanical: records become documents, objectID becomes the primary key, and a short function translates the settings that have equivalents. The frontend is a one-line client swap if you're on InstantSearch, or a small rewrite if you call the client directly. The honest caveats are Query Rules and Personalization (no equivalent) and that result ordering will differ slightly between engines.
For a deeper tour of Meilisearch features like faceting, synonyms, and ranking rules, see Getting Started with Meilisearch. If you're weighing the field more broadly first, Algolia Alternatives compares the open-source options. And if your search needs to match by meaning rather than text, that's a job for a vector database like Qdrant, not a keyword engine.
Manage your local Meilisearch instance with SpinDB:
spindb stop algolia-migration # Stop the server
spindb start algolia-migration # Start it again
spindb url algolia-migration # Print the connection URL
spindb list # See all your instancesSpinDB handles 20+ database engines, so Meilisearch can sit next to your Postgres, Redis, or Qdrant while you verify the migration. Layerbase Desktop wraps the same thing in a GUI on macOS.