Open-source search engine for your storefront
Meilisearch for Medusa v2 catalogs: full-text and hybrid search over products and categories, plus store endpoints you can swap in for and without your storefront noticing.
Medusa 2.19.0 introduced a Search Module of its own, and from v2.0.0 this plugin plugs into it as the Meilisearch engine behind it. The division of labour:
| Who | Does what |
|---|---|
| Medusa's Search Module | Creates and migrates indexes, seeds them, batches writes, routes catalog events, rebuilds on drift |
| This plugin's provider | Turns index declarations into Meilisearch settings and queries into Meilisearch requests |
| This plugin's factories | Ship ready-made product and category declarations you can extend |
| This plugin's routes and admin page | Native-parity store search, plus a settings screen for indexes and reindexing |
Practically, that means you no longer run any indexing code yourself. You declare what an index holds; Medusa keeps it filled.
| Plugin | Medusa | Meilisearch server | Node |
|---|---|---|---|
The 2.19 release removed the search interface the v1 line was written against, so the two lines do not overlap: v1 stops at Medusa 2.18, v2 starts at 2.19. Coming from v1, work through the upgrade guide.
The server floor comes from index swapping: the plugin's reindex strategy uses the field that Meilisearch 1.20 added to . Medusa 2.19 itself still runs on Node 20.19+, but this plugin is built and tested on Node 22 only.
The Meilisearch JS client stays on , the last version published as CommonJS.
Register the plugin (for its API routes and admin page) and the Search Module with this package as its provider:
Then declare your indexes under . The application loads every file in that directory and hands the declarations to the Search Module:
Create the indexes, then start the app:
creates and migrates the physical Meilisearch indexes. On boot the Search Module seeds any index that was just created, was emptied, or whose declaration changed; from then on it keeps them current from events. Indexes live in Meilisearch and are never recreated at startup.
| Option | Type | Description |
|---|---|---|
| Meilisearch client config. is required; is optional for keyless instances. | ||
| Meilisearch embedders applied to every index this provider manages. Keys are the embedder names. | ||
| Meilisearch settings applied below the settings derived from each declaration, e.g. . | ||
| How long to wait for a deferred write. Default . | ||
| How often to poll for a write to land. Default . |
Both and take the same options and always return an array of declarations (one per locale).
| Option | Default | Description |
|---|---|---|
| / | Base index name. | |
| the module's default provider | Provider identifier this index binds to. | |
| Document primary key. | ||
| the default schema | Replaces the field schema. Use . | |
| Index settings (synonyms, stop words, typo tolerance, …). | ||
| the default selection | Extra paths to fetch while seeding and ingesting. | |
| / | filters. | |
| pick of the declared paths | Maps an entity to a search document. Synchronous. | |
| Seed page size. | ||
| product / category events, both namespaces | Events this index reacts to. | |
| the default routing table | Turns an event into index mutations. | |
| – | BCP-47 locales; emits one index per locale. | |
| first entry of | Which locale keeps the bare index name. |
Extending the default schema:
Meilisearch has no per-attribute weights — relevance follows the order of — so declared weights become that ordering.
Seeding and event ingestion run outside . In a split deployment, install and configure the plugin on the worker instance too, or its indexes will stay empty.
Pass to a factory to get one index per locale. The default locale keeps the bare name, the others are suffixed:
This registers , and . Each index declares its locale, seeds and ingests through so documents carry the translated values, and tells Meilisearch which analyzer to use. Localized reads require Medusa's Translation Module ().
Store requests select an index by locale: (or the header). Region variants fall back to the same language, so uses the index when no index exists, and an unknown locale falls back to the default index. addresses one index directly.
Configure Meilisearch embedders on the provider and query them with :
Declare embedders under on an index to scope them to that index — the admin status card reads declarations, so per-index embedders also show up there. Pre-computed embeddings are supported by declaring a field. See docs/semantic-search.md for Ollama and OpenAI walkthroughs.
does the same and additionally hydrates fields the index does not hold. Anything Meilisearch cannot express — and filters, cursor pagination, the matching strategy, query-time typo tolerance, ascending relevance — raises an error instead of silently returning different results. Raw Meilisearch parameters go through .
Reindex on demand:
All four endpoints accept the Meilisearch-specific parameters , , , , , and (a raw Meilisearch filter expression).
Everything the native accepts, plus the parameters above. Without it behaves exactly like the native route. With one, Meilisearch supplies the matching product ids and their ranking, and the response is hydrated natively — calculated prices, tax, , sales-channel scoping.
Response: .
Same idea against the native . Response: .
Raw engine hits, with no database read: , plus when requested and / for a hybrid query. Each hit carries the index's retrievable fields and . Additional parameters: , , , , .
| Endpoint | Description |
|---|---|
| Registered indexes with their entity, locales and retrievable fields. | |
| Starts a reindex () and returns immediately. | |
| Raw product hits, same body as the store endpoint. | |
| Raw category hits. | |
| Semantic-search status derived from the registered declarations. |
Medusa's own dashboard search uses the core endpoint and picks up these indexes automatically.
See nextjs/README.md.
Issues and pull requests are welcome at github.com/rokmohar/medusa-plugin-meilisearch.
1npm install --save @rokmohar/medusa-plugin-meilisearch2# or3yarn add @rokmohar/medusa-plugin-meilisearch1// medusa-config.ts2import { defineConfig, Modules } from '@medusajs/framework/utils'3
4export default defineConfig({5 plugins: [6 {7 resolve: '@rokmohar/medusa-plugin-meilisearch',8 options: {},9 },10 ],11 modules: [12 {13 resolve: '@medusajs/medusa/search',14 options: {15 providers: [16 {17 resolve: '@rokmohar/medusa-plugin-meilisearch/providers/meilisearch',18 id: 'meilisearch',19 options: {20 config: {21 host: process.env.MEILISEARCH_HOST!,22 apiKey: process.env.MEILISEARCH_API_KEY,23 },24 },25 },26 ],27 },28 },29 ],30})1// src/search/products.ts2import { defineProductSearchIndex } from '@rokmohar/medusa-plugin-meilisearch/indexes'3
4export default defineProductSearchIndex()1// src/search/categories.ts2import { defineCategorySearchIndex } from '@rokmohar/medusa-plugin-meilisearch/indexes'3
4export default defineCategorySearchIndex()1npx medusa db:migrate2npx medusa develop1import { search } from '@medusajs/framework/utils'2import { defineProductSearchIndex, productSearchSchema } from '@rokmohar/medusa-plugin-meilisearch/indexes'3
4export default defineProductSearchIndex({5 fields: search.define({6 ...productSearchSchema(),7 brand: search.text().searchable({ weight: 3 }).facetable(),8 }),9 graph_fields: ['brand'],10 settings: {11 synonyms: { trousers: ['pants'] },12 stop_words: ['the'],13 },14})1export default defineProductSearchIndex({2 locales: ['en-US', 'fr-FR', 'de-DE'],3 default_locale: 'en-US',4})1options: {2 config: { host: process.env.MEILISEARCH_HOST!, apiKey: process.env.MEILISEARCH_API_KEY },3 embedders: {4 default: {5 source: 'openAi',6 apiKey: process.env.OPENAI_API_KEY,7 model: 'text-embedding-3-small',8 dimensions: 1536,9 documentTemplate: '{{doc.title}} {{doc.description}}',10 },11 },12}1import { Modules } from '@medusajs/framework/utils'2
3const search = container.resolve(Modules.SEARCH)4
5const { hits, facets, metadata } = await search.search({6 entity: 'products',7 fields: ['id', 'title'],8 filters: { q: 'shirt', status: 'published' },9 pagination: { skip: 0, take: 20 },10 search_options: {11 facets: ['categories.name'],12 highlight: { fields: ['title'] },13 count: 'exact',14 },15})await search.reindex({ index: 'products', strategy: 'swap' })1curl 'http://localhost:9000/store/meilisearch/products?query=shirt&limit=10®ion_id=reg_1&fields=id,title,*variants.calculated_price' \2 -H 'x-publishable-api-key: pk_...'1curl 'http://localhost:9000/store/meilisearch/products-hits?query=shirt&limit=5&facets=categories.name' \2 -H 'x-publishable-api-key: pk_...'1MEILISEARCH_HOST=http://localhost:77002MEILISEARCH_API_KEY=your_master_key1services:2 meilisearch:3 image: getmeili/meilisearch:v1.534 ports:5 - '7700:7700'6 environment:7 MEILI_MASTER_KEY: your_master_key8 MEILI_NO_ANALYTICS: 'true'9 volumes:10 - meilisearch:/meili_data11
12volumes:13 meilisearch:1yarn install2yarn lint3yarn typecheck4yarn test5yarn build