4.5.2. Search Queries

In this chapter, you’ll learn about Query's search method and how to use it to run full-text search queries with filters, facets, highlighting, and vector search.

What is a Search Query?#

A search query runs against the Search Module to retrieve and rank documents based on relevance. For example, searching products with full-text search, filtering by price, and sorting by popularity.

Under the hood, the Search Module retrieves the results from the integrated Search Module Provider, such as Medusa Search or your custom third-party provider.

For searching from a storefront, Medusa provides a Store Search API route that uses query.search under the hood. You can also use query.search in your custom API routes, such as to search data models that the route doesn't expose.

Best Practices: Prefer query.search over query.graph when the request has a free-text term to rank by. query.graph filters records in the database without relevance ranking, whereas query.search delegates the ranking to a Search Module Provider.
Don't use query.search if: 
  • The request has no search term and no relevance ordering. Use query.graph instead.

Search Example#

Assuming you want a separate API route for searching products, create an API route at src/api/store/products/custom-search/route.ts with the following content:

src/api/store/products/custom-search/route.ts
6  ContainerRegistrationKeys,7} from "@medusajs/framework/utils"8
9export const GET = async (10  req: MedusaRequest,11  res: MedusaResponse12) => {13  const query = req.scope.resolve(14    ContainerRegistrationKeys.QUERY15  )16
17  const { data, search_result } = await query.search({18    entity: "product",19    fields: [20      "id",21      "title",22      "handle",23      "variants.id",24      "variants.sku",25    ],26    filters: {27      q: req.query.q as string,28      status: "published",29    },30    pagination: {31      skip: 0,32      take: 20,33    },34  })35
36  res.json({37    products: data,38    metadata: search_result.metadata,39  })40}

In the above example, you resolve Query from the Medusa container using the ContainerRegistrationKeys.QUERY (query) key.

Then, you run a search using its search method. This method accepts as a parameter an object with the following properties:

  • entity (required): The name of the index to search, as specified in the name property of the index definition.
  • fields: The fields to return on every result, including fields that the index doesn't hold, such as variants.sku. If you omit fields, you'll receive every retrievable field the index holds.
  • filters: The filters to apply, with the free-text term passed as q.
  • pagination: The skip and take options that page through the results.

The method returns an object with two properties:

  • data: The hydrated entities, in the relevance order the provider returned.
  • search_result: What the provider reported, including hits, scores, facets, and pagination metadata.

For example, if you pass q=t-shirt in the request, the response may look like this:

Returned Data
1{2  "data": [3    {4      "id": "prod_123",5      "title": "Medusa T-Shirt",6      "handle": "t-shirt",7      "variants": [8        {9          "id": "variant_123",10          "sku": "SHIRT-S"11        }12      ]13    }14  ],15  "search_result": {16    "hits": [17      {18        "id": "prod_123",19        "document": {20          "id": "prod_123",21          "title": "Medusa T-Shirt",22          "handle": "t-shirt"23        }24      }25    ],26    "metadata": {27      "skip": 0,28      "take": 20,29      "count": 1,30      "query": "t-shirt",31      "processing_time_ms": 932    }33  }34}

Search Usage in Workflows#

To run a search query in a workflow, create a step that resolves Query from the container and uses it to run the search query.

For example:

src/workflows/steps/search-products.ts
1import { Modules } from "@medusajs/framework/utils"2import {3  createStep,4  StepResponse,5} from "@medusajs/framework/workflows-sdk"6
7export const searchProductsStep = createStep(8  "search-products",9  async (input: { q: string }, { container }) => {10    const searchModuleService = container.resolve(11      ContainerRegistrationKeys.QUERY12    )13
14    const { data, search_result } = await query.search({15      entity: "product",16      fields: [17        "id",18        "title",19        "handle",20        "variants.id",21        "variants.sku",22      ],23      filters: {24        q: input.q,25        status: "published",26      },27      pagination: {28        skip: 0,29        take: 20,30      },31    })32
33    return new StepResponse({34      products: data,35      metadata: search_result.metadata,36    })37  }38)

How Search Queries Work#

When you pass fields to query.search, the Search Module splits them into two groups:

  1. The fields the index holds. The provider returns them on every hit.
  2. The remaining fields. query.graph fetches them, and they're merged to the returned data.

So you can request fields of relations that the index never stores, such as variants.sku, without adding them to the index definition.


Select Specific Fields#

Always list in fields the fields you need, rather than relying on the default or passing {relation}.*.

Selecting specific fields keeps the hydration query cheaper, and it keeps the response smaller. For example, instead of retrieving all properties of a product and its variants:

Avoid
1const { data } = await query.search({2  entity: "product",3  fields: ["*", "variants.*"],4  filters: {5    q: "shirt",6  },7})

Retrieve only the fields you use:

Prefer
1const { data } = await query.search({2  entity: "product",3  fields: [4    "id",5    "title",6    "variants.id",7    "variants.sku",8  ],9  filters: {10    q: "shirt",11  },12})

Apply Filters#

The search method accepts a filters property, whose value is an object of filters to apply. Its keys are index field names, and its values are the values to filter on.

To perform a free-text search, pass the term in the q property. The Search Module lifts q out before it compiles the rest, so a provider never treats q as a field.

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "running shoe",6    status: "published",7  },8})

In this example, the search query looks for products that match the free-text term "running shoe" and also have a status of "published".

Note: You can only filter on fields that the index definition marks as filterable. The Search Module validates the query against the definition before it reaches the provider, so an unsupported field fails with a clear error.

You can also filter by multiple values of a field. For example:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    brand: [7      "acme",8      "borg",9    ],10  },11})

Filter Operators#

A filter value can be:

  • A literal value performing an equality check, such as filtering by a specific status.
Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    status: "published",7  },8})
  • An array performing an "is one of" check, such as filtering by multiple brands.
Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    brand: [7      "acme",8      "borg",9    ],10  },11})
  • An object of operators for advanced filtering, such as filtering by a minimum price.
Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    min_price: {7      $gte: 50,8    },9  },10})

For object filters, you can use the following operators:

Loading...

Filter by a Range

For example, to filter products by a price range:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    min_price: {7      $gte: 50,8      $lte: 200,9    },10  },11})

Filter an Array Field

For example, to filter products by tags that overlap with a given set:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    tags: {7      $overlaps: [8        "summer",9        "sale",10      ],11    },12  },13})

In the example above, you retrieve the products having at least one of the summer and sale tags. Use $contains instead to require all of them.

Filter a Nested Field

For example, to filter products by a nested field:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    "variants.color": "olive",7  },8})

In the example above, you filter on a nested field of the index. The index definition must declare variants.color as filterable.

Combine Filters

You can nest conditions with the $and, $or, and $not operators:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    $or: [7      { status: "published" },8      { brand: { $eq: "acme" } },9    ],10    $not: {11      tags: { $contains: "clearance" },12    },13  },14})

In the example above, you retrieve the products that are either published or from the acme brand, excluding the ones tagged as clearance.


Apply Pagination#

The search method's object parameter accepts a pagination property to configure the pagination of returned hits.

For example:

Code
1const {2  data,3  search_result: { metadata },4} = await query.search({5  entity: "product",6  fields: ["id", "title"],7  filters: { q: "shoe" },8  pagination: {9    skip: 0,10    take: 15,11  },12})

In this example, the pagination property specifies that the search should skip the first 0 hits and return the next 15 hits.

pagination is optional. If you omit it, or omit one of its properties, the Search Module applies skip: 0 and take: 20. So a search returns at most 20 hits unless you raise take.

Note: This is unlike query.graph, which returns every matching record when you don't pass pagination.
Loading...

The result's metadata property is an object with the following properties:

Loading...
Note: No provider that Medusa ships supports pagination.cursor, so paginate with skip and take.

Sort Hits#

To sort the returned hits, pass an order property to pagination. Its value is an object whose keys are field names, and whose values are either ASC or DESC.

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: { q: "shoe" },5  pagination: {6    order: {7      min_price: "ASC",8    },9  },10})

The index definition must mark a field as sortable before you can order by it. The only exception is the reserved _score key, which orders by relevance:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: { q: "shoe" },5  pagination: {6    order: { _score: "DESC" },7  },8})

Change the Count Strategy#

Counting every matching document can be expensive on a large index, so the count search option tells the provider how accurate the count has to be:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: { q: "shoe" },5  search_options: {6    count: "exact",7  },8})

count accepts one of the following values:

  • estimated (default): The provider returns whichever count its engine can produce cheaply, which may be an estimate.
  • exact: The provider counts every matching document.
  • none: The provider skips the count query, and metadata.count is null. Use this when the page you're building never shows a total or page numbers, such as a storefront with a "Load more" button. To find out whether more hits exist, check whether the returned hits filled the page, meaning their number equals take.
Note: No provider that Medusa ships treats exact differently from estimated. All of them run a real count unless you pass none. The distinction only matters for a provider whose engine can't count exactly, which either rejects exact or runs it slowly.

Query Search Options#

The search_options property of the search method's parameter object is an object of options passed to the provider to control how it treats the query. For example, you can tell it which fields to match the term against, whether to enable typo tolerance, and which facets to compute.

If a provider doesn't support an option, it either ignores it or throws an error. Refer to Medusa Search vs PostgreSQL for what each one does with the options below.

It accepts the following properties:

Loading...

For example, to match any of the query's terms in the product's title only:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: { q: "comfortable running shoe" },5  search_options: {6    attributes_to_search_on: ["title"],7    match_strategy: "any",8    include_score: true,9  },10})

Facets#

Facets return the distinct values of a field and how many documents fall into each one, which is what a storefront's filter sidebar shows.

Note: A field must be marked facetable in the index definition. Learn more in the Search Index Field Modifiers guide.

Pass a field name for a value facet, or an object for more control:

Code
1const { search_result } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: { q: "shoe" },5  search_options: {6    facets: [7      "brand",8      { field: "status", limit: 5, sort: "count" },9    ],10  },11})

There are three facet types:

  • value (default): The distinct values of the field and their counts.
  • range: The number of documents falling into each range you define. Available on numeric and date fields.
  • stats: The minimum, maximum, average, sum, and count of the field.

For example, to compute a range facet and a stats facet on the same field:

Code
1const { search_result } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: { q: "shoe" },5  search_options: {6    facets: [7      {8        field: "min_price",9        type: "range",10        ranges: [11          { key: "cheap", to: 50 },12          { key: "mid", from: 50, to: 200 },13          { key: "expensive", from: 200 },14        ],15      },16      { field: "min_price", type: "stats" },17    ],18  },19})

The facets are in the result's facets property, keyed by field name. For example:

Returned Facets
1{2  "facets": {3    "min_price": {4      "type": "range",5      "ranges": [6        { "key": "cheap", "to": 50, "count": 1 },7        { "key": "mid", "from": 50, "to": 200, "count": 2 }8      ]9    }10  }11}

Disjunctive Facets#

When a customer filters by one brand, a facet on brand normally returns only that brand. Set disjunctive_facets to true so the provider computes each facet while ignoring the filter on its own field, keeping the sibling values visible:

Code
1const { search_result } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    q: "shoe",6    brand: "acme",7  },8  search_options: {9    facets: ["brand", "status"],10    disjunctive_facets: true,11  },12})

The result's facets property then holds the following:

Returned Facets
1{2  "facets": {3    "brand": {4      "type": "value",5      "values": [6        { "value": "acme", "count": 12 },7        { "value": "borg", "count": 7 },8        { "value": "zeta", "count": 3 }9      ]10    },11    "status": {12      "type": "value",13      "values": [14        { "value": "published", "count": 10 },15        { "value": "draft", "count": 2 }16      ]17    }18  }19}

The brand facet still lists borg and zeta, despite the brand: "acme" filter. A shopper can see how many results switching brands would give them.

The status facet is unaffected because the query didn't filter on status. So, its results only show the counts of published and draft products whose brand is acme.


Highlighting#

Highlighting wraps the matched terms in the fields you name, so a storefront can show why a result matched.

Code
1const { search_result } = await query.search({2  entity: "product",3  fields: ["id", "title", "description"],4  filters: { q: "shoe" },5  search_options: {6    highlight: {7      fields: ["title", "description"],8      pre_tag: "<mark>",9      post_tag: "</mark>",10      snippet: { length: 120 },11    },12  },13})

highlight accepts the following properties:

Loading...

The highlighted fragments are in each hit's highlights property, keyed by field name. Each key holds an array, since a field can match in more than one place:

Returned Hits
1{2  "hits": [3    {4      "id": "prod_123",5      "document": {6        "id": "prod_123",7        "title": "Trail Running Shoe",8        "description": "A light shoe for trails."9      },10      "highlights": {11        "title": ["Trail Running <mark>Shoe</mark>"],12        "description": ["A light <mark>shoe</mark> for trails."]13      }14    }15  ]16}

The document property keeps the original values, so render the highlights fragments only where you want the matched terms marked.


A field declared with search.vector(dimensions) holds an embedding, which lets the provider rank results by semantic similarity instead of term matching.

Note: Learn more about vector fields in the Search Index Fields guide.

Pass text as search_options.vector.query for the provider's embedder to embed:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  search_options: {5    vector: {6      field: "title_embedding",7      query: "comfortable shoes for long runs",8      semantic_ratio: 0.7,9    },10  },11})

Alternatively, pass a pre-computed embedding as search_options.vector.value:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  search_options: {5    vector: {6      field: "title_embedding",7      value: [0.021, -0.113, 0.884],8    },9  },10})

search_options.vector accepts the following properties:

Loading...
Was this chapter 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