medusa-plugin-algolia-plus
v1.0.0
Published
Reusable Medusa v2 module that syncs product data to Algolia and powers storefront search through the Medusa backend
Maintainers
Readme
medusa-plugin-algolia-plus
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.
- Backend: zero application code — register the plugin and set three environment variables. Products auto-sync to Algolia; an admin reindex page and a store search route are exposed.
- Storefront: one drop-in
searchClient(shipped with the package) points InstantSearch at the Medusa search route. The Algolia key never reaches the browser.
| | |
| --- | --- |
| Package | medusa-plugin-algolia-plus |
| Algolia client | algoliasearch 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 |
Table of contents
- How it works
- Design principles & ADRs
- Installation
- Configuration
- The Algolia record
- Index settings
- Customizing the mapping
- Sync behavior
- Coverage & limitations
- API surface
- Storefront integration
- Admin UI
- Production hardening
- Operations runbook
- Troubleshooting
- Module API reference
- Testing
- Package structure
- Medusa version compatibility
- Roadmap / deferred
How it works
Medusa (source of truth)
│
├─ product / variant / category / collection / order events
│ │
│ ▼
│ subscribers (thin, never throw)
│ │ resolve affected product ids
│ ▼
│ syncProductsWorkflow ──► AlgoliaModuleService ──► Algolia index
│ deleteProductsFromAlgoliaWorkflow (the only algoliasearch caller)
│
└─ admin: POST /admin/algolia/sync ──► algolia.sync ──► paginated full reindex
Storefront ──► POST /store/products/search ──► AlgoliaModuleService.search ──► Algolia
(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.
Design principles & ADRs
- The Algolia index is a derived view. Medusa is the source of truth.
- One service owns all Algolia traffic.
AlgoliaModuleServiceis the only file that importsalgoliasearch. Subscribers, workflows, and routes call into it — never construct Algolia requests directly. - Subscribers stay thin; workflows do the work. Every meaningful action is a workflow with compensation; subscribers resolve ids and delegate.
- Stable object IDs.
objectID = product.id— repeated syncs upsert, never duplicate. This is the foundational idempotency contract. - Configuration is environment-driven. No hardcoded values that block reuse.
- Storefront search goes through Medusa (ADR-004) — the Algolia write key stays on the backend; search behavior is centralized for every storefront.
| ADR | Decision |
| --- | --- |
| 001 | Workflow + step + compensation for all sync |
| 002 | Product id as Algolia objectID |
| 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 |
Installation
medusa-plugin-algolia-plus is a self-contained package. It is built with medusa
plugin:build (output in .medusa/server) and must be built before a Medusa
backend can load it. algoliasearch and zod ship as runtime dependencies,
so a consumer installs only the one package.
From a registry
npm install medusa-plugin-algolia-plus
# or: pnpm add medusa-plugin-algolia-plus / yarn add medusa-plugin-algolia-plusLocal development with yalc
To 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 npm link (no peer-dependency hoisting surprises, and the
backend resolves medusa-plugin-algolia-plus exactly as it would from npm).
In this plugin repo — build and publish to the local yalc store:
pnpm install
pnpm yalc:publish # runs `medusa plugin:build`, then `yalc publish`In your Medusa backend — link it, install, and register the plugin:
yalc add medusa-plugin-algolia-plus # writes "medusa-plugin-algolia-plus": "file:.yalc/medusa-plugin-algolia-plus"
pnpm install # or npm/yarn — installs the linked packageThen register it in medusa-config.js (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 yalc remove medusa-plugin-algolia-plus (or yalc remove --all) in the backend,
then reinstall the registry version.
Only compiled output is shipped:
package.jsonfilesis[".medusa/server"], soyalc publishpacks.medusa/serverpluspackage.jsonand this README — never the rawsrc/. Always build before publishing; theyalc:publish/yalc:pushscripts do this for you.
Configuration
Environment variables
| Variable | Required | Description |
| ---------------------------- | -------- | -------------------------------------------- |
| ALGOLIA_APP_ID | ✅ yes | Algolia Application ID |
| ALGOLIA_API_KEY | ✅ yes | Algolia write/admin API key (backend-only) |
| ALGOLIA_PRODUCT_INDEX_NAME | ✅ yes | Product index name (e.g. products) |
| ALGOLIA_BATCH_SIZE | no | Reindex page size, 1–1000 (default 50) |
| ALGOLIA_STOREFRONT_URL | no | Base URL used to build each record's url |
⚠️ Use a write key, not a Search-Only key. Indexing and settings calls require
addObject,deleteObject, andeditSettingsACLs. 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 — [algolia] Invalid module options: … — never
silently at the first sync (the fail-fast contract).
Register the plugin
medusa-config.js:
plugins: [
...(ALGOLIA_APP_ID && ALGOLIA_API_KEY && ALGOLIA_PRODUCT_INDEX_NAME
? [
{
resolve: "medusa-plugin-algolia-plus",
options: {
appId: ALGOLIA_APP_ID,
apiKey: ALGOLIA_API_KEY, // write key — stays on the backend
productIndexName: ALGOLIA_PRODUCT_INDEX_NAME,
// optional, see the table below:
// batchSize: 50,
// storefrontUrl: "https://shop.example.com",
// currencyCodes: ["eur", "usd"],
// metadataFields: { material: "material", season: "season" },
// searchableAttributes: [...], attributesForFaceting: [...],
// customRanking: [...], maxRetries: 3, retryBaseDelayMs: 200,
// requestTimeoutMs: 30000,
},
},
]
: []),
];Module options
| Option | Type | Default | Purpose |
| ----------------------- | -------------------------- | ------- | ------- |
| appId | string | — | Algolia Application ID (required) |
| apiKey | string | — | Algolia write key (required) |
| productIndexName | string | — | Product index name (required) |
| batchSize | number (1–1000) | 50 | Full-reindex / fan-out page size |
| storefrontUrl | string (URL) | — | Builds each record's url |
| currencyCodes | string[] | all | Restrict indexed currencies (lowercased) |
| productMapper | function | default | Replace the record shape entirely |
| fieldOverrides | Record<string, unknown> | — | Shallow static fields on every record |
| metadataFields | Record<string, string> | — | Metafield mapping: metadataKey → recordField |
| searchableAttributes | string[] | default | Applied at reindex time |
| attributesForFaceting | string[] | default | Applied at reindex time |
| customRanking | string[] | default | Applied at reindex time |
| requestTimeoutMs | number (1k–120k) | 30000 | Per-request HTTP timeout |
| maxRetries | number (0–10) | 3 | Retries on transient Algolia errors |
| retryBaseDelayMs | number (0–60k) | 200 | Backoff base delay |
The Algolia record
The default mapper produces one record per product (objectID = id = product.id):
{
"objectID": "prod_123",
"id": "prod_123",
"title": "Cool Shirt",
"subtitle": null,
"description": "A very cool shirt",
"handle": "cool-shirt",
"thumbnail": "https://cdn/thumb.jpg",
"images": [{ "id": "img_1", "url": "https://cdn/1.jpg" }],
"categories": [{ "id": "cat_1", "name": "Shirts", "handle": "shirts" }],
"collection": { "id": "col_1", "title": "Spring", "handle": "spring" },
"tags": ["summer"],
"type": "apparel",
"variants": [
{ "id": "var_1", "sku": "SHIRT-S", "title": "S", "prices": { "eur": 1999, "usd": 2499 } }
],
"price_min": { "eur": 1999, "usd": 2499 }, // per currency, across variants
"price_max": { "eur": 2999, "usd": 3499 },
"availability": true, // derived from stock + flags
"url": "https://shop.example.com/products/cool-shirt",
"created_at": "…",
"updated_at": "…"
}Prices are keyed by lowercase currency code, in minor units (as Medusa stores
them). availability is true if any variant is purchasable: not
inventory-managed, allows backorder, or has stocked − reserved ≥ required
across its inventory levels.
Index settings
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 |
| ----------------------- | ------- |
| searchableAttributes | title, description, variants.sku, tags, categories.name, collection.title |
| attributesForFaceting | searchable(categories.handle), collection.handle, tags, availability |
| customRanking | desc(availability) (in-stock first) |
Override any of them via the matching module option. For numeric price
filtering, add per-currency price facets to attributesForFaceting, e.g.
filterOnly(price_min.eur) / filterOnly(price_max.eur).
Customizing the mapping
Three escape hatches, smallest to largest:
1. fieldOverrides — set static fields on every record:
fieldOverrides: { brand: "Acme" } // objectID is never overridable2. metadataFields — metafield-driven mapping; project product.metadata
keys onto record fields:
metadataFields: { material: "material", season: "season_facet" }
// product.metadata.material → record.material, etc.3. productMapper — replace the record shape entirely (pure function):
import type { ProductMapper } from "medusa-plugin-algolia-plus";
const productMapper: ProductMapper = (product, ctx) => ({
objectID: product.id, // must equal the product id
name: product.title,
// …your shape…
});Sync behavior
Full reindex
POST /admin/algolia/sync emits algolia.sync and returns 202 immediately;
the algolia-sync subscriber walks the entire catalog page by page
(batchSize) into syncProductsWorkflow 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.
Event-driven sync
Thin, never-throwing subscribers keep the index current automatically:
| You do in Medusa | Event | In Algolia |
| --- | --- | --- |
| Create / edit a product | product.created / product.updated | record upserted (or removed if unpublished) |
| Delete a product | product.deleted | record removed |
| Add / edit a variant, change price | product-variant.created / .updated | parent product reindexed |
| Rename a category / collection | product-category.updated / product-collection.updated | products in it reindexed |
| Place / cancel / receive-return an order | order.placed / .canceled / .return_received | order's products' availability refreshed |
A product is in search only while status === "published" (ADR-006):
unpublish removes it, republish re-adds it under the same id.
Availability model
Medusa 2.13.5 emits no inventory-level.* event for stock changes, so
availability is tracked through order events — placing an order reserves
inventory (available = stocked − reserved drops); cancel/return releases it.
The mapper computes stock-accurate availability from
variants.inventory_items.inventory.location_levels.*.
Coverage & limitations
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):
- A direct admin stock-level edit (not via an order) emits no event.
- Deleting a category, collection, or single variant severs the product
links before the event is delivered, so affected products can't be resolved
from it. (Whole-product deletes are handled; a variant removed while editing a
product is covered by the accompanying
product.updated.)
Idempotency guarantees (by construction, because objectID 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.
API surface
Admin
| Route | Method | Purpose |
| --- | --- | --- |
| /admin/algolia/sync | POST | Trigger a full reindex (emits algolia.sync, returns 202) |
| /admin/algolia/sync | GET | Connection health, index name, and current options |
| /admin/algolia/products/:id | POST | Re-sync a single product synchronously |
Store
POST /store/products/search — validated by a Zod middleware, calls
AlgoliaModuleService.search, and returns the unmodified Algolia
SearchResponse (hits, nbHits, page, nbPages, facets, …).
curl -X POST http://localhost:9000/store/products/search \
-H "content-type: application/json" \
-H "x-publishable-api-key: pk_..." \
-d '{"query":"shirt","hitsPerPage":12,"facetFilters":[["categories.handle:shirts"]]}'Accepted body fields: query (default ""), page, hitsPerPage (1–200),
filters, facetFilters, numericFilters, facets, attributesToRetrieve;
additional InstantSearch params pass through. Invalid bodies fail with a
structured 400 at the middleware before the handler runs.
Storefront integration
The package ships a reusable, dependency-free InstantSearch searchClient so the
storefront integration is a one-line swap (no Algolia key in the browser):
// storefront: src/lib/search-client.ts
import { createAlgoliaSearchClient } from "medusa-plugin-algolia-plus/storefront/create-search-client"
export const searchClient = createAlgoliaSearchClient({
backendUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,
})
export const SEARCH_INDEX_NAME = process.env.NEXT_PUBLIC_INDEX_NAME || "products"<InstantSearch searchClient={searchClient} indexName={SEARCH_INDEX_NAME}>
<SearchBox />
<RefinementList attribute="categories.handle" />
<Hits />
<Pagination />
</InstantSearch>It forwards query/page/hitsPerPage/filters/facetFilters/
numericFilters/facets/attributesToRetrieve and wraps the route's response
as { results: [response] }, so the full InstantSearch widget ecosystem
(search, autocomplete, faceting, pagination) works unchanged. Pass a custom
fetchFn 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
/store/products/search.
Admin UI
Settings → Algolia renders:
- Connection health (green/red) with the Algolia error if unreachable.
- Index name, Batch size, Storefront URL.
- Reindex all products button (background full reindex).
- Re-sync a single product input (per-product retry).
Production hardening
- Retry / backoff — every Algolia call retries transient failures (network,
HTTP 5xx, 429) with bounded exponential backoff + jitter (
maxRetries, default 3); other 4xx fail immediately. Sits above the Algolia client's own host-failover retry. - Structured logging — every operation emits one parseable
key=valueline through the Medusa logger (noconsole.log):
Fields:[algolia] plugin=algolia trace=ab12cd34 op=full_reindex entity=product index=products count=540 duration_ms=12030 status=successop,entity,index,count,ids,duration_ms,status(started/success/failed/skipped/compensated), and on failureerror+http_status. Retries logstatus=retry attempt=n/m. - Never-throw subscribers — a failed sync is caught and logged; it never propagates into Medusa's request/event lifecycle (a failed sync can't 500 a product save).
- Batch safety —
getObjectsis chunked to Algolia's 1000/call cap;saveObjects/deleteObjectsauto-chunk; large fan-outs are processed inbatchSizechunks; an out-of-rangebatchSizefrom HTTP input is rejected. - Compensation —
syncProductsStep/deleteProductsFromAlgoliaStepsnapshot prior index state and restore it if the surrounding workflow fails.
Operations runbook
Initial setup (pre-flight)
- [ ]
ALGOLIA_APP_ID/ALGOLIA_API_KEY(write) /ALGOLIA_PRODUCT_INDEX_NAMEset - [ ] Plugin built (
pnpm build, orpnpm yalc:publishfor a local link) and registered inmedusa-config - [ ] Backend starts with no
[algolia] Invalid module optionserror - [ ] Settings → Algolia shows Connected
- [ ] "Reindex all products" populates the index (
objectID = product.id); twice → no duplicates - [ ] (Storefront)
searchClientwired; CORS + publishable key allow the store route
When to reindex
After installing/reconfiguring, after changing index-setting options, after bulk stock edits, or after category/collection deletions (see limitations).
Monitoring
Watch for status=failed lines (they carry the product id, error, and
http_status) and status=retry bursts (transient Algolia issues). The full
reindex is the catch-all repair.
Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| Backend can't resolve medusa-plugin-algolia-plus after yalc add | run the package manager install (pnpm install) in the backend after yalc add, and confirm the plugin was built (pnpm yalc:publish builds first) — only .medusa/server is published |
| [algolia] Invalid module options at startup | a required env var is missing/empty — intended fail-fast |
| Not enough rights to add an object / Method not allowed with this API key | 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 |
| MeiliSearchCommunicationError: fetch failed (storefront) | storefront still points at MeiliSearch — swap its searchClient (see storefront integration) |
| No "Algolia" in Settings / routes 404 | plugin not built, or env not set, so it isn't registered |
| Store search returns 503 | Algolia module not registered (missing env) |
| Code changes don't take effect | rebuild + restart (pnpm build; with a yalc link, pnpm yalc:push propagates to the backend), or use pnpm dev watch mode |
| params should be awaited (Next 15) | storefront tech debt (params/searchParams are async) — unrelated to Algolia |
Module API reference
Public exports from medusa-plugin-algolia-plus:
Module & service — algoliaModule, ALGOLIA_MODULE,
AlgoliaModuleService. Service methods:
| Method | Description |
| --- | --- |
| getIndexName(type?) | Resolve an index type to its configured name |
| indexData(records, type?) | Upsert records (objectID upsert; auto-chunked) |
| retrieveFromIndex(objectIDs, type?) | Fetch records by id (chunked snapshot) |
| deleteFromIndex(objectIDs, type?) | Delete records by id |
| search(query, type?, options?) | Search; returns the Algolia SearchResponse |
| configureIndexSettings(type?) | Apply configured-or-default index settings |
| healthCheck(type?) | Connectivity probe (never throws) |
| mapProducts(products) | Map products with the configured mapper/context |
Options — resolveAlgoliaOptions, resolveAlgoliaOptionsFromEnv,
resolveIndexSettings, normalizeBatchSize, algoliaOptionsSchema, and the
DEFAULT_* constants.
Mapping — productToAlgoliaRecord, deriveAvailability,
PRODUCT_SYNC_FIELDS.
Workflows / steps — syncProductsWorkflow,
deleteProductsFromAlgoliaWorkflow, syncProductsStep,
deleteProductsFromAlgoliaStep.
Resolvers — extractIds, resolveProductIdsFromVariantIds,
resolveProductIdsFromCategoryIds, resolveProductIdsFromCollectionIds,
resolveProductIdsFromOrderIds.
Events — AlgoliaEvents namespace (all subscribed event-name constants).
Storefront — createAlgoliaSearchClient (subpath:
medusa-plugin-algolia-plus/storefront/create-search-client).
Testing
pnpm typecheck # tsc --noEmit (module + admin)
pnpm test # 79 unit tests across 9 suites
pnpm 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 medusa exec (resolves the service from
the container — no HTTP, no events):
// <your-backend>/src/scripts/test-algolia.ts
import { syncProductsWorkflow } from "medusa-plugin-algolia-plus"
export default async function ({ container }) {
const algolia: any = container.resolve("algolia")
console.log(await algolia.healthCheck())
await algolia.indexData([{ objectID: "test_1", id: "test_1", title: "Hi" }])
console.log((await algolia.search("Hi")).hits)
await algolia.deleteFromIndex(["test_1"])
const { result } = await syncProductsWorkflow(container).run({ input: {} })
console.log(result)
}cd <your-backend> && npx medusa exec ./src/scripts/test-algolia.tsPackage structure
src/
├─ index.ts # public barrel export
├─ modules/algolia/
│ ├─ index.ts # Module(ALGOLIA_MODULE, { service })
│ ├─ service.ts # AlgoliaModuleService (only algoliasearch importer)
│ ├─ options.ts # Zod options, defaults, index-settings resolver
│ ├─ types.ts # record & mapper types
│ ├─ events.ts # version-pinned event names
│ ├─ logger.ts # structured logging
│ ├─ retry.ts # bounded backoff + jitter
│ ├─ mappers/product.ts # default mapper + PRODUCT_SYNC_FIELDS + availability
│ └─ resolvers/ # dependent-change → product-id resolvers
├─ workflows/ # sync + delete workflows and steps (with compensation)
├─ subscribers/ # product / variant / category / collection / order / reindex
├─ api/
│ ├─ admin/algolia/ # sync (GET/POST) + products/[id] (POST)
│ ├─ store/products/search/ # search route + Zod validators
│ └─ middlewares.ts # store search body validation
├─ admin/routes/algolia/page.tsx # Settings → Algolia UI
└─ storefront/create-search-client.ts # shipped InstantSearch clientMedusa version compatibility
Pinned to Medusa 2.13.5. Event names and Query-graph paths are version-sensitive and centralized:
- Subscribed events live in
events.ts— change them there if a future Medusa version renames events. - The module uses workflow events (
product.updated, …), not module-prefixed events (product.product.updated), to avoid double-processing. - The fetched product fields are
PRODUCT_SYNC_FIELDSinmappers/product.ts.
The module declares no database models, so there is no migration to run.
Roadmap / deferred
- Persistent failed-sync log + a per-product retry queue (today: stateless per-product re-sync + the full reindex as the repair primitive).
- A scheduled (cron) full reindex for automatic staleness repair.
- Additional index types (categories, collections) —
AlgoliaIndexTypeandgetIndexNameare already open for extension.
