@rokmohar/medusa-plugin-meilisearch
Version:
Meilisearch plugin for Medusa 2
558 lines (442 loc) • 17.2 kB
Markdown
This plugin integrates MeiliSearch with your Medusa e-commerce store and adds support for internationalization (i18n) of your product catalog.
## Features
- Full-text search for your Medusa store
- Real-time indexing
- Typo-tolerance
- Faceted search
- Support for both products and categories
- Internationalization (i18n) support with multiple strategies:
1. Separate index per language
2. Language-specific fields with suffix
- Flexible translation configuration
- Custom field transformations
- Automatic field detection
## Installation
Run the following command to install the plugin with **npm**:
```bash
npm install --save @rokmohar/medusa-plugin-meilisearch
```
Or with **yarn**:
```bash
yarn add @rokmohar/medusa-plugin-meilisearch
```
### Upgrade to v1.0
_This step is required only if you are upgrading from previous version to v1.0._
- The plugin now supports new MedusaJS plugin system.
- Subscribers are included in the plugin.
- You don't need custom subscribers anymore, you can remove them.
## Minimum Compatibility
| Plugin version | Medusa version |
| -------------- | -------------- |
| `^1.4.1` | `^2.15.2` |
| `^1.3.7` | `^2.13.4` |
| `^1.0.0` | `^2.4.0` |
> **Note:** This plugin is only compatible with MedusaJS v2. For MedusaJS v1 / v2.3.x and older, use the [legacy version](https://github.com/rokmohar/medusa-plugin-meilisearch/tree/v0.2.1).
## Configuration
Add the plugin to your `medusa-config.ts` file:
```js
import { loadEnv, defineConfig } from '@medusajs/framework/utils'
import { MeilisearchPluginOptions } from '@rokmohar/medusa-plugin-meilisearch'
loadEnv(process.env.NODE_ENV || 'development', process.cwd())
module.exports = defineConfig({
// ... other config
plugins: [
// ... other plugins
{
resolve: '@rokmohar/medusa-plugin-meilisearch',
options: {
config: {
host: process.env.MEILISEARCH_HOST ?? '',
apiKey: process.env.MEILISEARCH_API_KEY ?? '',
},
settings: {
// The key is used as the index name in Meilisearch
products: {
// Required: Index type
type: 'products',
// Optional: Whether the index is enabled. When disabled:
// - Index won't be created or updated
// - Documents won't be added or removed
// - Index won't be included in searches
// - All operations will be silently skipped
enabled: true,
// Optional: Specify which fields to include in the index
// If not specified, all fields will be included
fields: ['id', 'title', 'description', 'handle', 'variant_sku', 'thumbnail'],
indexSettings: {
searchableAttributes: ['title', 'description', 'variant_sku'],
displayedAttributes: ['id', 'handle', 'title', 'description', 'variant_sku', 'thumbnail'],
filterableAttributes: ['id', 'handle'],
},
primaryKey: 'id',
// Create your own transformer
/*transformer: (product) => ({
id: product.id,
// other attributes...
}),*/
},
categories: {
// Required: Index type
type: 'categories',
// Optional: Whether the index is enabled
enabled: true,
// Optional: Specify which fields to include in the index
// If not specified, all fields will be included
fields: ['id', 'name', 'description', 'handle', 'is_active', 'parent_id'],
indexSettings: {
searchableAttributes: ['name', 'description'],
displayedAttributes: ['id', 'name', 'description', 'handle', 'is_active', 'parent_id'],
filterableAttributes: ['id', 'handle', 'is_active', 'parent_id'],
},
primaryKey: 'id',
// Create your own transformer
/*transformer: (category) => ({
id: category.id,
name: category.name,
// other attributes...
}),*/
},
},
i18n: {
// Choose one of the following strategies:
// 1. Separate index per language
// strategy: 'separate-index',
// languages: ['en', 'fr', 'de'],
// defaultLanguage: 'en',
// 2. Language-specific fields with suffix
strategy: 'field-suffix',
languages: ['en', 'fr', 'de'],
defaultLanguage: 'en',
translatableFields: ['title', 'description'],
},
} satisfies MeilisearchPluginOptions,
},
],
})
```
> **Important:** Product events and background tasks will **not work** if your Medusa instance is running in `server` mode, because the server instance does **not** process subscribers or background jobs.
Depending on your setup:
- **Monolithic architecture** (only one backend instance):
Ensure you **do not set** the `MEDUSA_WORKER_MODE` or `WORKER_MODE` environment variable. By default, Medusa will use `shared` mode, which supports both background processing and serving HTTP requests from the same instance.
- **Split architecture** (separate server and worker instances):
Follow the [official Medusa documentation on worker mode](https://docs.medusajs.com/learn/production/worker-mode#content).
In this case, you **must add this plugin in the worker instance**, as the server instance does not handle event subscribers or background tasks.
The plugin supports two main strategies for handling translations, with flexible configuration options for each.
```typescript
{
i18n: {
// Choose strategy: 'separate-index' or 'field-suffix'
strategy: 'field-suffix',
// List of supported languages
languages: ['en', 'fr', 'de'],
// Default language to fall back to
defaultLanguage: 'en',
// Optional: List of translatable fields
translatableFields: ['title', 'description', 'handle']
}
}
```
You can provide detailed configuration for each translatable field:
```typescript
{
i18n: {
strategy: 'field-suffix',
languages: ['en', 'fr', 'de'],
defaultLanguage: 'en',
translatableFields: [
// Simple field name
'title',
// Field with different target name
{
source: 'description',
target: 'content' // Will be indexed as content_en, content_fr, etc.
},
// Field with transformation
{
source: 'handle',
transform: (value) => value.toLowerCase().replace(/\s+/g, '-')
}
]
}
}
```
The plugin provides a flexible way to transform your products with custom translations. Translations are passed directly to the default transformer via `TransformOptions`:
```typescript
{
settings: {
products: {
type: 'products',
// ... other config
transformer: async (product, defaultTransformer, options) => {
const translations = {
title: [
{ language_code: 'en', value: 'Blue T-Shirt' },
{ language_code: 'fr', value: 'T-Shirt Bleu' },
],
description: [
{ language_code: 'en', value: 'A comfortable blue t-shirt' },
{ language_code: 'fr', value: 'Un t-shirt bleu confortable' },
],
}
return defaultTransformer(product, {
...options,
translations,
includeAllTranslations: true,
})
},
}
}
}
```
Pass `includeAllTranslations: true` to emit all language suffixes (e.g. `title_en`, `title_fr`). Without it only the current language suffix is written.
### Integration with Medusa Native Translations
The recommended approach for production is to use the [Medusa Translation module](https://docs.medusajs.com/resources/commerce-modules/product/translations), which is built into Medusa v2.
**1. Enable the translation feature flag and module in `medusa-config.ts`:**
```typescript
module.exports = defineConfig({
featureFlags: {
translation: true,
},
// ... other config
modules: [
// ... other modules
{
resolve: '@medusajs/medusa/translation',
},
],
})
```
**2. Create a translations utility (`src/utils/translations.ts`):**
```typescript
import { ContainerRegistrationKeys } from '@medusajs/utils'
import type { MedusaContainer } from '@medusajs/framework'
import { TranslationMap } from '@rokmohar/medusa-plugin-meilisearch'
// Maps Medusa locale codes (e.g. 'sl-SI') to index field suffixes (e.g. 'sl')
export const LOCALE_MAP: Record<string, string> = {
'sl-SI': 'sl',
'en-US': 'en',
// add more as needed
}
export const getTranslations = async (
id: string,
langs: string[],
container: MedusaContainer,
): Promise<TranslationMap> => {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: rows } = await query.graph({
entity: 'translation',
fields: ['reference_id', 'locale_code', 'translations'],
filters: { reference_id: id, locale_code: langs },
})
const result: TranslationMap = {}
for (const row of rows) {
const langCode = LOCALE_MAP[row.locale_code] ?? row.locale_code
for (const [field, value] of Object.entries(row.translations as Record<string, string>)) {
if (!result[field]) {
result[field] = []
}
result[field].push({ language_code: langCode, value })
}
}
return result
}
```
**3. Use in your transformer:**
The transformer receives `options.container` — the real `MedusaContainer` forwarded by workflow steps. It is `undefined` when no container is available (e.g. during a manual index rebuild without a workflow context), so always guard with a fallback:
```typescript
import { getTranslations, LOCALE_MAP } from './src/utils/translations'
{
settings: {
products: {
type: 'products',
indexSettings: {
searchableAttributes: ['title', 'title_sl', 'title_en'],
displayedAttributes: ['id', 'handle', 'title', 'title_sl', 'title_en', 'thumbnail'],
filterableAttributes: ['id'],
},
transformer: async (product, defaultTransformer, options) => {
if (!options?.container) {
// No container available — index without translations
return defaultTransformer(product, options)
}
const raw = await getTranslations(product.id, ['sl-SI', 'en-US'], options.container)
// Remap locale codes to match index field suffixes
const translations = Object.fromEntries(
Object.entries(raw).map(([field, values]) => [
field,
values.map((t) => ({
language_code: LOCALE_MAP[t.language_code] ?? t.language_code,
value: t.value,
})),
]),
)
return defaultTransformer(product, {
...options,
translations,
includeAllTranslations: true,
})
},
}
}
}
```
> **How `container` reaches the transformer:** Medusa plugin modules receive the awilix cradle proxy at construction time, not the real `MedusaContainer` — so the service cannot self-supply it. Workflow steps (triggered by product/category events) have the real container from their `createStep` context and forward it via `addDocuments(indexName, documents, type, { container })`. The transformer then resolves services (e.g. `QUERY`) from it.
## i18n Strategies
### 1. Separate Index per Language
This strategy creates a separate MeiliSearch index for each language. For example, if your base index is named "products", it will create:
- products_en
- products_fr
- products_de
Benefits:
- Better performance for language-specific searches
- Language-specific settings and ranking rules
- Cleaner index structure
### 2. Language-specific Fields with Suffix
This strategy adds language suffixes to translatable fields in the same index. For example:
- title_en, title_fr, title_de
- description_en, description_fr, description_de
Benefits:
- Single index to maintain
- Ability to search across all languages at once
- Smaller storage requirements
## Custom Translatable Fields
If no translatable fields are specified and using the field-suffix strategy, the plugin will automatically detect string fields as translatable. You can override this by explicitly specifying the fields:
```typescript
{
i18n: {
strategy: 'field-suffix',
languages: ['en', 'fr'],
defaultLanguage: 'en',
// Only these fields will be translatable
translatableFields: ['title', 'description']
}
}
```
```http
GET /store/meilisearch/products-hits
```
Query Parameters:
- `query`: Search query string
- `limit`: (Optional) Limit results from search
- `offset`: (Optional) Offset results from search
- `language`: (Optional) Language code
- `semanticSearch` - Enable AI-powered semantic search (boolean)
- `semanticRatio` - Semantic vs keyword search ratio (0-1)
### Search for Products
```http
GET /store/meilisearch/products
```
Query Parameters:
- `fields` - MedusaJS fields expression
- `limit`: (Optional) Limit results from search
- `offset`: (Optional) Offset results from search
- `region_id`: (Optional, but required for `calculated_price`) Current region ID
- `currency_code`: (Optional, but required for `calculated_price`) Current currency code
- `query`: Search query string
- `language`: (Optional) Language code
- `semanticSearch` - Enable AI-powered semantic search (boolean)
- `semanticRatio` - Semantic vs keyword search ratio (0-1)
## Category Support
This plugin provides full support for MedusaJS v2 categories, including:
- Real-time indexing of category changes
- i18n support for category names and descriptions
- Hierarchical category structure support with parent-child relationships
- Custom category field transformations
### Category Configuration Example
```typescript
{
settings: {
categories: {
type: 'categories',
enabled: true,
fields: ['id', 'name', 'description', 'handle', 'is_active', 'parent_id'],
indexSettings: {
searchableAttributes: ['name', 'description'],
displayedAttributes: ['id', 'name', 'description', 'handle', 'is_active', 'parent_id'],
filterableAttributes: ['id', 'handle', 'is_active', 'parent_id'],
},
primaryKey: 'id',
},
},
i18n: {
strategy: 'field-suffix',
languages: ['en', 'fr', 'de'],
defaultLanguage: 'en',
translatableFields: ['name', 'description'], // Category-specific translatable fields
},
}
```
```http
GET /store/meilisearch/categories-hits
```
Query Parameters:
- `query`: Search query string
- `limit`: (Optional) Limit results from search
- `offset`: (Optional) Offset results from search
- `language`: (Optional) Language code
- `semanticSearch` - Enable AI-powered semantic search (boolean)
- `semanticRatio` - Semantic vs keyword search ratio (0-1)
### Search for Categories
```http
GET /store/meilisearch/categories
```
Query Parameters:
- `fields` - MedusaJS fields expression
- `limit`: (Optional) Limit results from search
- `offset`: (Optional) Offset results from search
- `query`: Search query string
- `language`: (Optional) Language code
- `semanticSearch` - Enable AI-powered semantic search (boolean)
- `semanticRatio` - Semantic vs keyword search ratio (0-1)
## AI-Powered Semantic Search
This plugin supports AI-powered semantic search using vector embeddings. See [docs/semantic-search.md](docs/semantic-search.md) for detailed configuration and usage instructions.
## ENV variables
Add the environment variables to your `.env` and `.env.template` file:
```env
# ... others vars
MEILISEARCH_HOST=
MEILISEARCH_API_KEY=
```
If you want to use with the `docker-compose` from this README, use the following values:
```env
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_API_KEY=ms
```
You can add the following configuration for Meilisearch to your `docker-compose.yml`:
```yml
services:
meilisearch:
image: getmeili/meilisearch:latest
ports:
- '7700:7700'
volumes:
- ~/data.ms:/data.ms
environment:
- MEILI_MASTER_KEY=ms
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:7700']
interval: 10s
timeout: 5s
retries: 5
```
You can find instructions on how to add search to a Medusa NextJS starter inside the [nextjs](nextjs) folder.
- [How do I include product categories and tags in my search index?](docs/faq-product-categories-and-tags.md)
- [How do I include product variant prices (min_price, max_price) in my search index?](docs/faq-product-variant-prices.md)
- [How do I include prices in the search response from the search endpoint?](docs/faq-product-search-prices.md)
## Contributing
Feel free to open issues and pull requests!