InstantSearch Adapter

In this guide, you'll learn how to install and configure the InstantSearch adapter to build a search experience in your storefront.

Note: If you installed your Medusa application after v2.21.1, its storefront already has the search functionalities explained in this guide, and the Medusa application already defines the product index at src/search/product.ts and allows it on the /store/search API route in src/api/middlewares.ts. You can still follow this guide to understand how the search works and customize it.

Integrate with an AI Agent#

If you use an AI agent, such as Claude Code or Codex, ask it to fetch this page:

Terminal
Fetch https://docs.medusajs.com/resources/instantsearch and follow the instructions.

It will receive the following prompt with instructions on how to integrate the InstantSearch adapter into the storefront:

Code
<role>You are a front-end engineer integrating search into an existing Medusa storefront. You work within the storefront's current framework, conventions, and styling rather than introducing new ones.</role>
<task>Add a product search experience to this storefront using the `@medusajs/instantsearch-adapter` package.</task>
<context>- Medusa's Search Module indexes data in a search engine and serves queries with filters, facets, sorting, and pagination.- Medusa exposes a `/store/search` API route that searches any index of the Medusa application, so you don't create a route for product search. An index is only reachable through it once the Medusa application allows it with the `configureStoreSearch` middleware. The route narrows a product index to published products in the publishable API key's sales channels on its own, and any further scoping is that middleware's `filters` in the Medusa application, never the storefront.- `@medusajs/instantsearch-adapter` provides a search client, not UI components. It converts InstantSearch requests into Medusa search queries, sends them to `/store/search`, and converts the results back.- InstantSearch is a family of UI widget libraries: `react-instantsearch`, `vue-instantsearch`, `angular-instantsearch`, and `instantsearch.js` for storefronts without a framework.- Everything that reads or changes the search must sit inside one `InstantSearch` provider. Where that provider lives decides the shape of the experience.- A field only works with a widget if the search index definition marks it as `searchable`, `filterable`, `facetable`, or `sortable`.- A range or stats widget needs the field listed in the search client's `numericAttributes` option, on top of being `facetable({ types: ["stats"] })` in the index definition. Without it, the adapter asks for value facets instead of stats, so the widget never receives `facets_stats` and renders no bounds.- Consult the Medusa documentation at https://docs.medusajs.com, or the Medusa MCP server if it's available, for anything this prompt doesn't cover.</context>
<input>You have access to the storefront's codebase. Before writing code, determine from it:- The framework and package manager in use.- Whether a Medusa JS SDK instance already exists, and where.- The environment variables holding the Medusa backend URL and publishable API key.- The components the search will live in or next to, and the UI primitives the storefront already depends on.- The fields the `product` search index holds, from the Medusa application's index definition if it's available.</input>
<steps>1. Ask the user where the search should live and how it should behave. Offer the common shapes and let them pick or describe their own:   - A control in the navbar that opens a panel, such as a drawer or a modal.   - A field in the navbar that shows results in a dropdown beneath it.   - A dedicated search page with the results as the page body.   Ask in the same message which parts of the search they want beyond the field and the results, such as filters, sorting, or pagination. Don't start writing code until they answer.2. Install `@medusajs/instantsearch-adapter` and the InstantSearch library matching the storefront's framework, using the storefront's package manager. Install `@medusajs/js-sdk` only if the storefront doesn't already have it.3. Create the search client in a shared module, such as `src/lib/search-client.ts`. Pass the existing JS SDK instance if there is one; otherwise create one from the storefront's backend URL and publishable API key environment variables. Set `path` to `/store/search`, and export the index name as a constant so the UI never repeats the string.4. Export the client from a shared module, so every search surface in the storefront uses the same one.5. Build the search UI in the shape the user chose, with one `InstantSearch` provider wrapping the field, the results, and any other search widgets.6. Render each result with the storefront's existing product card or link pattern, using only fields the index holds.7. Make the search feel responsive: debounce the query so a burst of typing sends one request rather than one per keystroke, and keep the results already on screen while the next search runs.8. Keep the previous results on screen, with one exception: results fetched for the empty query, once the customer has typed. The empty query matches every document, so those "previous results" are the entire index, and rendering them flashes the whole catalogue the moment the first real query goes out. Gate on that case alone, from the query the visible results were fetched for and whether there's input, such as `hasInput && !resultsQuery`. Don't treat every mismatch between the results' query and the query last sent as stale: after a debounce the two differ on nearly every keystroke, so an equality check blanks the list as the customer types, which is the flicker step 7 forbids. Leave a comment on the gate saying which case it covers, so nobody widens it later.9. Give every state its own treatment: no query yet, results, no results, and a failed search. An empty list must never stand in for any of the others. Add an in-flight treatment, such as a spinner or skeleton, only for the case where there's nothing valid to show: the first search of a session, or the empty-query results that step 8 gates out. While a search runs over results that are still valid, leave those results on screen; a loading state that replaces them is the flicker step 7 avoids, and it barely appears in practice.10. Place the finished component where the user asked for it.11. Report what you changed and how to test it.</steps>
<constraints>- Never decide the search's placement or interaction yourself. That's the user's call in step 1.- Never render the empty query's hits once the customer has typed.- Never blank the results because the query moved on. Only the empty query's results are stale.- Never place a filter in the storefront that exists to protect data. Customers can change anything the client sends.- Never hardcode the publishable API key, the backend URL, or the index name. Read the first two from environment variables and the third from the exported constant.- Never invent index field names. Use only fields you confirmed in an index definition or in a search response.- Never add these widgets, as the adapter doesn't support them: `geoSearch`, Insights and Analytics widgets, Query Rules, related-items widgets, autocomplete, vector-search widgets, and Algolia `filters` strings.- Never add a CSS framework, component library, state-management library, or UI primitive that the storefront doesn't already use. Style the search with the storefront's existing approach.- Never create more than one search client or more than one `InstantSearch` provider for the same search.- Never modify the Medusa application's `/store/search` route.- Never create a custom API route for an index that `/store/search` already serves. Report to the user instead when an index isn't allowed on it, so they add it to the `configureStoreSearch` middleware.</constraints>
<error_handling>- If the user doesn't answer step 1, ask again rather than picking a placement. It's the decision the rest of the work depends on.- If you can't determine the storefront's framework, ask the user instead of guessing.- If the chosen shape needs a UI primitive the storefront doesn't have, ask the user before installing a library.- If no index definition is available, ask the user which fields the index holds. Don't infer field names from the storefront's product types.- If a search request answers with "No search index named \"product\"", the index isn't allowed on the route. Ask the user to add it to the `configureStoreSearch` middleware in their Medusa application rather than working around it.- If the search request fails at runtime, surface the error in the UI and report the status code and response body. Don't fall back to an empty state that hides it.- If the storefront already has a search implementation, ask whether to replace it or add alongside it before changing any file.- If a range or stats widget renders no bounds, add the field to the search client's `numericAttributes` and report that the index definition needs `facetable({ types: ["stats"] })`. Don't remove the widget silently.- If the work needs migrations in the Medusa application, ask the user to run `npx medusa db:migrate` themselves. The command can end in an interactive prompt to sync module links, so it hangs when you run it unattended.</error_handling>
<output_format>Report in this structure:
## ApproachThe placement and behavior the user chose, in one or two sentences.
## ChangesA list of the files you created or modified, each with a one-line description.
## SetupAny environment variables the user must set, and any command they must run.
## How to TestNumbered steps that end in an observable result, including one that types a query from an empty search and confirms the full catalogue never flashes before the matches, and one that keeps typing and confirms the list never blanks between keystrokes.
## SkippedAny widget or feature you left out, each with the reason. Write "None" if there are none.</output_format>
<success_criteria>- The search is placed and behaves the way the user described in step 1.- Typing shows matching products, and the previous results stay on screen until the new ones arrive.- Typing the first query from an empty search never flashes results from the empty query.- Typing further characters never blanks the list. The previous matches stay until the new ones arrive.- A burst of typing sends one search request, not one per character.- Each of the four states in step 9 shows something distinct from the others, and the in-flight treatment only appears when no valid results are on screen.- A failed search shows an error message rather than an empty list.- Every field referenced in the code exists in a search index definition.- The storefront builds and type-checks with no new errors.</success_criteria>

What is the InstantSearch Adapter?#

InstantSearch is a family of open source UI libraries that render search interfaces from small widgets, such as a search box, a hit list, facet filters, and pagination. It's available for plain JavaScript, React, Vue, and Angular.

The @medusajs/instantsearch-adapter package provides a search client for Medusa. It translates each InstantSearch request into a Medusa-compatible search query, sends it to Medusa's /store/search API route, then maps the result back into the shape the widgets expect.

So, you install the adapter and add InstantSearch widgets instead of writing the data fetching, filter serialization, and pagination logic yourself.

Note: The adapter doesn't provide UI components. You install an InstantSearch library alongside it and use its widgets.

Step 1: Install the Adapter#

In your storefront project, install the adapter and the InstantSearch library that matches your storefront's framework.

For example, if you're using a Next.js project, install the React library in the storefront:

You install instantsearch.js alongside react-instantsearch for the types it exports, such as Hit and SearchClient. The same adapter also works with vue-instantsearch and angular-instantsearch.


Step 2: Create the Search Client#

Next, create the search client that the InstantSearch widgets use.

The adapter needs a way to send requests to your Medusa application. Pass an instance of the Medusa JS SDK, as it sends the publishable API key and other headers for you.

For example, if you're using the default starter, it already exports an SDK instance from apps/storefront/src/lib/config.ts. So, create the file apps/storefront/src/lib/search-client.ts with the following content:

The createInstantSearchAdapter function accepts the adapter's options and returns an object with a searchClient property, which you pass to InstantSearch. Export the client once and import it wherever you need it. Every search surface in your storefront uses the same one.

Note: If your storefront doesn't use the JS SDK, pass baseUrl and publishableApiKey instead, and the adapter uses the native fetch function. You can also pass a requester function to control the transport yourself. Refer to the Adapter Configuration Options section for details.

Step 3: Add the Search Interface#

Finally, add the search interface. This example puts a search button in the navbar that opens a drawer holding the search field and its results.

Render a Hit#

Start with the component that renders a single result. Each hit's fields come from the index, so only reference the fields your index definition holds.

For example, if you're using the default starter, create the file apps/storefront/src/modules/layout/components/search/hit.tsx with the following content:

InstantSearch's Hit type adds objectID to the fields you pass it, so use hit.objectID as the key when you render a list of hits.

Create the Drawer#

Next, add the drawer that the search opens in.

The default starter depends on Headless UI, whose Dialog component brings the focus trap, the Escape key, and the backdrop click. So, the drawer only describes its own layout.

Note: The search doesn't depend on Headless UI. Use whichever dialog primitive your storefront has, as long as it traps focus while open.

Create the file apps/storefront/src/modules/layout/components/search/drawer.tsx with the following content:

Render the Search Panel#

Next, add the panel that holds the search field and its results, then the button that opens the drawer.

This example uses the useSearchBox and useHits hooks rather than the SearchBox and Hits widgets, which gives you the same behavior with your own markup.

Create the file apps/storefront/src/modules/layout/components/search/index.tsx with the following content:

The indexName prop is the name property of your search index definition, which the adapter sends as the query's entity. For the products index that Medusa provides, use product.

Each widget and hook then contributes to the query the adapter builds. useSearchBox's refine function sets the free-text q filter, and Configure sets the query's take through its hitsPerPage prop.

The field stays at the top of the drawer while the results scroll below it.

Important: A field must be searchable, filterable, or facetable in your index definition before a widget can use it. Learn more in the Index Field Modifiers guide.

Add the Search to the Navbar#

Finally, render the search component in your storefront's navbar.

For example, if you're using the default starter, add it to apps/storefront/src/modules/layout/templates/nav/index.tsx:

To show the full results on a dedicated search page, use the same InstantSearch provider with the Pagination, RefinementList, and SortBy widgets. Learn more in the Supported Widgets section.


To test the search integration:

  1. Start the Medusa backend and the storefront.
  2. Open the storefront in a browser and click the search button in the navbar.
  3. Type a product's title or other searchable field into the search box. The panel should show matching products as you type.

Adapter Configuration Options#

createInstantSearchAdapter accepts the following options:

Option

Description

Default

sdk

A Medusa JS SDK instance. The adapter sends requests through it, so the publishable API key and other headers are included.

-

path

The path of the search route, or an absolute URL. Use /store/search unless you added a custom route. Required unless you pass requester.

-

baseUrl

The Medusa backend's URL. Use it with publishableApiKey when you don't pass sdk.

-

publishableApiKey

The publishable API key sent in the x-publishable-api-key header. Only used with baseUrl.

-

requester

A custom transport function that receives the adapted queries and returns one result per query, in the same order.

-

headers

Extra headers to send with every request.

-

batch

Whether to send all queries in one request as { queries }. When disabled, the adapter sends each query as its own request body.

true

placeholderSearch

Whether to search when the query is empty. When disabled, the adapter returns empty results without calling the backend.

true

numericAttributes

The fields that range widgets use. The adapter requests them as stats facets. The index must also mark them as facetable({ types: ["stats"] }). If the index holds a price field per currency, list every currency's fields, as explained in Price Fields per Currency.

-

distinctAttribute

The index field to deduplicate on when a widget sends distinct.

-

primaryKey

The document field that the adapter falls back to for InstantSearch's objectID. The adapter uses the hit's id first, so this option only applies when a hit has no id, such as when you pass a custom requester.

"id"

cacheSearchResultsForSeconds

How long the adapter caches results in the browser. 0 disables caching.

0

additionalSearchParameters

Defaults merged into every query. The adapter combines its filters with the widgets' filters using $and, and the widgets' pagination and sorting take precedence.

-

indexSpecificSearchParameters

The same as additionalSearchParameters, but applied per index. The object's keys are index names.

-

transformQuery

A function that receives the adapted query and the original InstantSearch request, and returns the query to send.

-

transformResponse

A function that receives the adapted response, the raw search result, and the original request, and returns the response to hand to the widgets.

-

Set Default Query Parameters#

Use additionalSearchParameters to add filters or search options that every query must include, and indexSpecificSearchParameters to scope them to one index.

For example:

apps/storefront/src/lib/search-client.ts
1export const { searchClient } = createInstantSearchAdapter({2  sdk,3  path: "/store/search",4  additionalSearchParameters: {5    search_options: {6      match_strategy: "last",7      typo_tolerance: true,8    },9  },10  indexSpecificSearchParameters: {11    product: {12      fields: ["id", "title", "handle", "thumbnail"],13    },14  },15})

Every query then uses the last match strategy and typo tolerance, and the product index only returns the fields listed.

Warning: Filters you set in the storefront's client are visible to customers, who can change them, so use these options for presentation, such as choosing the fields to retrieve, and not to protect data. Scoping a search to what a customer may see is the /store/search route's own responsibility in your Medusa application, which narrows a product index to published products in the publishable API key's sales channels.

Search Another Index#

The /store/search route searches any index that a middleware allowed in your Medusa application, so an index you defined yourself needs no route of its own. Add its name to the configureStoreSearch middleware:

src/api/middlewares.ts
1import {2  configureStoreSearch,3  defineMiddlewares,4} from "@medusajs/framework/http"5
6export default defineMiddlewares({7  routes: [8    {9      matcher: "/store/search",10      middlewares: [11        configureStoreSearch({12          allowed_indexes: {13            product: true,14            article: true,15          },16        }),17      ],18    },19  ],20})

Then, pass the index's name as the indexName of an InstantSearch provider in your storefront, and the same search client serves it:

Code
1<InstantSearch2  indexName="article"3  searchClient={searchClient}4>5  {/* widgets... */}6</InstantSearch>

Refer to the Search Module documentation to learn how to define an index.

If you'd rather serve an index from a route of your own, such as one that reshapes the result, point the adapter's path option to it instead:

apps/storefront/src/lib/search-client.ts
1export const { searchClient } = createInstantSearchAdapter({2  sdk,3  path: "/store/article-search",4})

Supported InstantSearch Widgets#

The adapter supports the following widgets:

Widgets with Requirements#

The following widgets work, but they need extra setup:

Widget

Requirement

rangeSlider, rangeInput, numericMenu, and ratingMenu

List the field in the adapter's numericAttributes option, and mark it as facetable({ types: ["stats"] }) in the index definition. Stats facets are opt-in, and both the PostgreSQL and Medusa Search providers support them.

hierarchicalMenu and breadcrumb

The index must hold the level fields the widgets expect, such as categories.lvl0 and categories.lvl1. Build them in the index definition's consume and seed functions.

highlight and snippet

The provider must support highlighting. Medusa Search supports it, while the PostgreSQL provider doesn't.


Filtering, Sorting, and Pagination#

With this adapter, you can add widgets to a product listing page that allow customers to filter, sort, and paginate through products. The adapter translates the widgets' state into Medusa-compatible search queries, so you don't have to write the logic yourself.

Learn more in the Filtering, Sorting, and Pagination guide.

Was this page helpful?
Ask Bloom
For assistance in your development, use Claude Code Plugins or Medusa MCP server in Cursor, VSCode, etc...FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break