npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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

  1. How it works
  2. Design principles & ADRs
  3. Installation
  4. Configuration
  5. The Algolia record
  6. Index settings
  7. Customizing the mapping
  8. Sync behavior
  9. Coverage & limitations
  10. API surface
  11. Storefront integration
  12. Admin UI
  13. Production hardening
  14. Operations runbook
  15. Troubleshooting
  16. Module API reference
  17. Testing
  18. Package structure
  19. Medusa version compatibility
  20. 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. AlgoliaModuleService is the only file that imports algoliasearch. 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-plus

Local 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 package

Then 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 consumers

Unlink 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.json files is [".medusa/server"], so yalc publish packs .medusa/server plus package.json and this README — never the raw src/. Always build before publishing; the yalc:publish / yalc:push scripts 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, and editSettings 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[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 overridable

2. 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=value line through the Medusa logger (no console.log):
    [algolia] plugin=algolia trace=ab12cd34 op=full_reindex entity=product index=products count=540 duration_ms=12030 status=success
    Fields: op, entity, index, count, ids, duration_ms, status (started/success/failed/skipped/compensated), and on failure error + http_status. Retries log status=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 safetygetObjects is chunked to Algolia's 1000/call cap; saveObjects/deleteObjects auto-chunk; large fan-outs are processed in batchSize chunks; an out-of-range batchSize from HTTP input is rejected.
  • CompensationsyncProductsStep / deleteProductsFromAlgoliaStep snapshot 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_NAME set
  • [ ] Plugin built (pnpm build, or pnpm yalc:publish for a local link) and registered in medusa-config
  • [ ] Backend starts with no [algolia] Invalid module options error
  • [ ] Settings → Algolia shows Connected
  • [ ] "Reindex all products" populates the index (objectID = product.id); twice → no duplicates
  • [ ] (Storefront) searchClient wired; 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 & servicealgoliaModule, 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 |

OptionsresolveAlgoliaOptions, resolveAlgoliaOptionsFromEnv, resolveIndexSettings, normalizeBatchSize, algoliaOptionsSchema, and the DEFAULT_* constants.

MappingproductToAlgoliaRecord, deriveAvailability, PRODUCT_SYNC_FIELDS.

Workflows / stepssyncProductsWorkflow, deleteProductsFromAlgoliaWorkflow, syncProductsStep, deleteProductsFromAlgoliaStep.

ResolversextractIds, resolveProductIdsFromVariantIds, resolveProductIdsFromCategoryIds, resolveProductIdsFromCollectionIds, resolveProductIdsFromOrderIds.

EventsAlgoliaEvents namespace (all subscribed event-name constants).

StorefrontcreateAlgoliaSearchClient (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/server

Unit 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.ts

Package 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 client

Medusa 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_FIELDS in mappers/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) — AlgoliaIndexType and getIndexName are already open for extension.