Reusable Medusa v2 module that syncs product data to Algolia and powers storefront search through the Medusa backend
A production-grade, reusable Medusa v2 module that synchronizes product catalog data to Algolia and powers storefront search through the Medusa backend. It is client-agnostic, configuration-driven, and safe to deploy across multiple storefront projects without modification.
| Package | |
| Algolia client | v5 (the only place that imports it) |
| Medusa | pinned to 2.13.5 (peer dependency) |
| Status | Standalone repo · 79 unit tests · typecheck + plugin build green · installable locally via yalc |
1Medusa (source of truth)2 │3 ├─ product / variant / category / collection / order events4 │ │5 │ ▼6 │ subscribers (thin, never throw)7 │ │ resolve affected product ids8 │ ▼9 │ syncProductsWorkflow ──► AlgoliaModuleService ──► Algolia index10 │ deleteProductsFromAlgoliaWorkflow (the only algoliasearch caller)11 │12 └─ admin: POST /admin/algolia/sync ──► algolia.sync ──► paginated full reindex13
14Storefront ──► POST /store/products/search ──► AlgoliaModuleService.search ──► Algolia15 (InstantSearch via the shipped searchClient; key stays on the backend)The Algolia index is a derived view: every record is reproducible from Medusa data alone, so the index can be wiped and rebuilt at any time with no loss of business state.
| ADR | Decision |
|---|---|
| 001 | Workflow + step + compensation for all sync |
| 002 | Product id as Algolia |
| 003 | Subscribers delegate, never compute |
| 004 | Storefront searches through Medusa, not Algolia directly |
| 005 | Dependent changes resolved via Query graph |
| 006 | Unpublished == deleted in the index |
is a self-contained package. It is built with (output in ) and must be built before a Medusa backend can load it. and ship as runtime dependencies, so a consumer installs only the one package.
1npm install medusa-plugin-algolia-plus2# or: pnpm add medusa-plugin-algolia-plus / yarn add medusa-plugin-algolia-plusTo develop the plugin against a Medusa backend on the same machine without publishing to a registry, use yalc. It packs the built package into a local store and copies it into the backend — closer to a real install than (no peer-dependency hoisting surprises, and the backend resolves exactly as it would from npm).
In this plugin repo — build and publish to the local yalc store:
1pnpm install2pnpm yalc:publish # runs `medusa plugin:build`, then `yalc publish`In your Medusa backend — link it, install, and register the plugin:
1yalc add medusa-plugin-algolia-plus # writes "medusa-plugin-algolia-plus": "file:.yalc/medusa-plugin-algolia-plus"2pnpm install # or npm/yarn — installs the linked packageThen register it in (see Configuration) and restart the backend.
Iterating — after each change to the plugin, rebuild and propagate to every linked backend in one step:
pnpm yalc:push # rebuilds, then `yalc push` updates all consumersUnlink with (or ) in the backend, then reinstall the registry version.
Only compiled output is shipped: is , so packs plus and this README — never the raw . Always build before publishing; the / scripts do this for you.
| Variable | Required | Description |
|---|---|---|
| ✅ yes | Algolia Application ID | |
| ✅ yes | Algolia write/admin API key (backend-only) | |
| ✅ yes | Product index name (e.g. ) | |
| no | Reindex page size, 1–1000 (default ) | |
| no | Base URL used to build each record's |
⚠️ Use a write key, not a Search-Only key. Indexing and settings calls require , , and ACLs. A Search-Only key fails with "Not enough rights to add an object".
If the three required vars are not all present, the module is not registered (search silently disabled). If it is registered with invalid options, it throws at Medusa startup — — never silently at the first sync (the fail-fast contract).
:
1plugins: [2 ...(ALGOLIA_APP_ID && ALGOLIA_API_KEY && ALGOLIA_PRODUCT_INDEX_NAME3 ? [4 {5 resolve: "medusa-plugin-algolia-plus",6 options: {7 appId: ALGOLIA_APP_ID,8 apiKey: ALGOLIA_API_KEY, // write key — stays on the backend9 productIndexName: ALGOLIA_PRODUCT_INDEX_NAME,10 // optional, see the table below:11 // batchSize: 50,12 // storefrontUrl: "https://shop.example.com",13 // currencyCodes: ["eur", "usd"],14 // metadataFields: { material: "material", season: "season" },15 // searchableAttributes: [...], attributesForFaceting: [...],16 // customRanking: [...], maxRetries: 3, retryBaseDelayMs: 200,17 // requestTimeoutMs: 30000,18 },19 },20 ]21 : []),22];| Option | Type | Default | Purpose |
|---|---|---|---|
| — | Algolia Application ID (required) | ||
| — | Algolia write key (required) | ||
| — | Product index name (required) | ||
| (1–1000) | Full-reindex / fan-out page size | ||
| (URL) | — | Builds each record's | |
| all | Restrict indexed currencies (lowercased) | ||
| default | Replace the record shape entirely | ||
| — | Shallow static fields on every record | ||
| — | Metafield mapping: | ||
| default | Applied at reindex time | ||
| default | Applied at reindex time | ||
| default | Applied at reindex time | ||
| (1k–120k) | Per-request HTTP timeout | ||
| (0–10) | Retries on transient Algolia errors | ||
| (0–60k) | Backoff base delay |
The default mapper produces one record per product ():
1{2 "objectID": "prod_123",3 "id": "prod_123",4 "title": "Cool Shirt",5 "subtitle": null,6 "description": "A very cool shirt",7 "handle": "cool-shirt",8 "thumbnail": "https://cdn/thumb.jpg",9 "images": [{ "id": "img_1", "url": "https://cdn/1.jpg" }],10 "categories": [{ "id": "cat_1", "name": "Shirts", "handle": "shirts" }],11 "collection": { "id": "col_1", "title": "Spring", "handle": "spring" },12 "tags": ["summer"],13 "type": "apparel",14 "variants": [15 { "id": "var_1", "sku": "SHIRT-S", "title": "S", "prices": { "eur": 1999, "usd": 2499 } }16 ],17 "price_min": { "eur": 1999, "usd": 2499 }, // per currency, across variants18 "price_max": { "eur": 2999, "usd": 3499 },19 "availability": true, // derived from stock + flags20 "url": "https://shop.example.com/products/cool-shirt",21 "created_at": "…",22 "updated_at": "…"23}Prices are keyed by lowercase currency code, in minor units (as Medusa stores them). is if any variant is purchasable: not inventory-managed, allows backorder, or has across its inventory levels.
Sensible defaults are applied automatically at the start of every full reindex, so search, faceting, filtering, and ranking work out of the box — no manual Algolia dashboard setup:
| Setting | Default |
|---|---|
| , , , , , | |
| , , , | |
| (in-stock first) |
Override any of them via the matching module option. For numeric price filtering, add per-currency price facets to , e.g. / .
Three escape hatches, smallest to largest:
1. — set static fields on every record:
fieldOverrides: { brand: "Acme" } // objectID is never overridable2. — metafield-driven mapping; project keys onto record fields:
1metadataFields: { material: "material", season: "season_facet" }2// product.metadata.material → record.material, etc.3. — replace the record shape entirely (pure function):
1import type { ProductMapper } from "medusa-plugin-algolia-plus";2
3const productMapper: ProductMapper = (product, ctx) => ({4 objectID: product.id, // must equal the product id5 name: product.title,6 // …your shape…7});emits and returns immediately; the subscriber walks the entire catalog page by page () into and applies the configured index settings first. Triggered from Settings → Algolia → Reindex all products or the route directly. Idempotent — running it twice yields no duplicates.
Thin, never-throwing subscribers keep the index current automatically:
| You do in Medusa | Event | In Algolia |
|---|---|---|
| Create / edit a product | / | record upserted (or removed if unpublished) |
| Delete a product | record removed | |
| Add / edit a variant, change price | / | parent product reindexed |
| Rename a category / collection | / | products in it reindexed |
| Place / cancel / receive-return an order | / / | order's products' refreshed |
A product is in search only while (ADR-006): unpublish removes it, republish re-adds it under the same id.
Medusa 2.13.5 emits no event for stock changes, so availability is tracked through order events — placing an order reserves inventory ( drops); cancel/return releases it. The mapper computes stock-accurate availability from .
Two changes can't be caught in real time in 2.13.5 (both repaired by a manual full reindex — the documented disaster-recovery primitive):
Idempotency guarantees (by construction, because is stable): duplicate event delivery, out-of-order delivery (last-writer-wins), and a manual reindex racing an event sync all converge to the correct state.
| Route | Method | Purpose |
|---|---|---|
| Trigger a full reindex (emits , returns ) | ||
| Connection health, index name, and current options | ||
| Re-sync a single product synchronously |
— validated by a Zod middleware, calls , and returns the unmodified Algolia (, , , , , …).
1curl -X POST http://localhost:9000/store/products/search \2 -H "content-type: application/json" \3 -H "x-publishable-api-key: pk_..." \4 -d '{"query":"shirt","hitsPerPage":12,"facetFilters":[["categories.handle:shirts"]]}'Accepted body fields: (default ), , (1–200), , , , , ; additional InstantSearch params pass through. Invalid bodies fail with a structured at the middleware before the handler runs.
The package ships a reusable, dependency-free InstantSearch so the storefront integration is a one-line swap (no Algolia key in the browser):
1// storefront: src/lib/search-client.ts2import { createAlgoliaSearchClient } from "medusa-plugin-algolia-plus/storefront/create-search-client"3
4export const searchClient = createAlgoliaSearchClient({5 backendUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,6 publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,7})8export const SEARCH_INDEX_NAME = process.env.NEXT_PUBLIC_INDEX_NAME || "products"1<InstantSearch searchClient={searchClient} indexName={SEARCH_INDEX_NAME}>2 <SearchBox />3 <RefinementList attribute="categories.handle" />4 <Hits />5 <Pagination />6</InstantSearch>It forwards ///// // and wraps the route's response as , so the full InstantSearch widget ecosystem (search, autocomplete, faceting, pagination) works unchanged. Pass a custom for SSR or extra headers.
If you prefer not to add the package as a storefront dependency, inline the same ~15-line client — it just POSTs to .
Settings → Algolia renders:
[algolia] plugin=algolia trace=ab12cd34 op=full_reindex entity=product index=products count=540 duration_ms=12030 status=successAfter installing/reconfiguring, after changing index-setting options, after bulk stock edits, or after category/collection deletions (see limitations).
Watch for lines (they carry the product id, error, and ) and bursts (transient Algolia issues). The full reindex is the catch-all repair.
| Symptom | Cause / fix |
|---|---|
| Backend can't resolve after | run the package manager install () in the backend after , and confirm the plugin was built ( builds first) — only is published |
| at startup | a required env var is missing/empty — intended fail-fast |
| / | you used a Search-Only key — switch to a write/admin key |
| Connection Unreachable, "Index products does not exist" | the index is created on the first successful write — fix the key, then reindex |
| (storefront) | storefront still points at MeiliSearch — swap its (see storefront integration) |
| No "Algolia" in Settings / routes 404 | plugin not built, or env not set, so it isn't registered |
| Store search returns | Algolia module not registered (missing env) |
| Code changes don't take effect | rebuild + restart (; with a yalc link, propagates to the backend), or use watch mode |
| (Next 15) | storefront tech debt (/ are async) — unrelated to Algolia |
Public exports from :
Module & service — , , . Service methods:
| Method | Description |
|---|---|
| Resolve an index type to its configured name | |
| Upsert records (objectID upsert; auto-chunked) | |
| Fetch records by id (chunked snapshot) | |
| Delete records by id | |
| Search; returns the Algolia | |
| Apply configured-or-default index settings | |
| Connectivity probe (never throws) | |
| Map products with the configured mapper/context |
Options — , , , , , and the constants.
Mapping — , , .
Workflows / steps — , , , .
Resolvers — , , , , .
Events — namespace (all subscribed event-name constants).
Storefront — (subpath: ).
1pnpm typecheck # tsc --noEmit (module + admin)2pnpm test # 79 unit tests across 9 suites3pnpm build # medusa plugin:build → .medusa/serverUnit suites cover options validation, the mapper (incl. availability, currency, metafields), resolvers (mocked Query), retry policy, index settings, the search route handler, the storefront client, and service construction/configurable mapping.
Test the live module directly with (resolves the service from the container — no HTTP, no events):
1// <your-backend>/src/scripts/test-algolia.ts2import { syncProductsWorkflow } from "medusa-plugin-algolia-plus"3export default async function ({ container }) {4 const algolia: any = container.resolve("algolia")5 console.log(await algolia.healthCheck())6 await algolia.indexData([{ objectID: "test_1", id: "test_1", title: "Hi" }])7 console.log((await algolia.search("Hi")).hits)8 await algolia.deleteFromIndex(["test_1"])9 const { result } = await syncProductsWorkflow(container).run({ input: {} })10 console.log(result)11}cd <your-backend> && npx medusa exec ./src/scripts/test-algolia.ts1src/2├─ index.ts # public barrel export3├─ modules/algolia/4│ ├─ index.ts # Module(ALGOLIA_MODULE, { service })5│ ├─ service.ts # AlgoliaModuleService (only algoliasearch importer)6│ ├─ options.ts # Zod options, defaults, index-settings resolver7│ ├─ types.ts # record & mapper types8│ ├─ events.ts # version-pinned event names9│ ├─ logger.ts # structured logging10│ ├─ retry.ts # bounded backoff + jitter11│ ├─ mappers/product.ts # default mapper + PRODUCT_SYNC_FIELDS + availability12│ └─ resolvers/ # dependent-change → product-id resolvers13├─ workflows/ # sync + delete workflows and steps (with compensation)14├─ subscribers/ # product / variant / category / collection / order / reindex15├─ api/16│ ├─ admin/algolia/ # sync (GET/POST) + products/[id] (POST)17│ ├─ store/products/search/ # search route + Zod validators18│ └─ middlewares.ts # store search body validation19├─ admin/routes/algolia/page.tsx # Settings → Algolia UI20└─ storefront/create-search-client.ts # shipped InstantSearch clientPinned to Medusa 2.13.5. Event names and Query-graph paths are version-sensitive and centralized:
The module declares no database models, so there is no migration to run.