Search Index Definitions

In this guide, you'll learn how to declare a search index and control what the search engine holds.

What is a Search Index Definition?#

A search index definition declares one index: the entity it indexes, the fields the search engine holds, and how to fill it. The Search Module owns everything around it, including creating the physical index, batching writes, and rebuilding the index when the definition changes.

Tip: The product indexes in this guide's snippets are simplified to show one feature at a time. For examples of indexing prices in multiple currencies, option values, categories, and other product data, refer to the Product Index Examples guide.

Define an index with defineSearchIndex in a file under the src/search directory of your Medusa application or plugin. Medusa loads every file in that directory before the application boots, and each file registers its indexes on import.

For example, create the file src/search/brand.ts with the following content:

src/search/brand.ts
1import {2  defineSearchIndex,3  graphConsume,4  graphSeed,5  search,6} from "@medusajs/framework/utils"7
8const fields = ["id", "name", "country"]9
10export const brandIndex = defineSearchIndex({11  name: "brand",12  entity: "brand",13  fields: search.define({14    id: search.keyword().filterable(),15    name: search.text().searchable(),16    country: search.keyword().filterable(),17  }),18  events: [19    "brand.created",20    "brand.updated",21    "brand.deleted",22  ],23  consume: graphConsume({ fields }),24  seed: graphSeed({ fields }),25})

graphSeed and graphConsume are helpers that build the index's data from Query:

  • graphSeed reads the entity's records to fill the index for the first time.
  • graphConsume reads the records that an event changed to keep the index current.

Most indexes are a projection of an entity that Query already exposes, so the helpers cover them. Refer to Custom seed and consume Implementation for an index whose data doesn't come from Query.

If you're using the PostgreSQL Search Module Provider or a provider that makes changes to the database, you must also run the migrations that create the necessary tables:


Index Definition Properties#

Property

Description

Required

name

A unique name for the index. This is the value for entity passed to query.search.

Yes

entity

The Query entry point used to hydrate the fields that the search index doesn't hold.

Yes

fields

The fields the search engine holds. Must be a schema built with search.define. Refer to Search Index Fields for every field type and modifier.

Yes

seed

An async generator that yields batches of writes, usually built with graphSeed. Refer to Fill the Index with seed for when the Search Module runs it.

Yes

events

The workflow events that change the data this index holds. Medusa subscribes to them and routes each one to consume. Refer to Keep an Index Up-to-Date for details.

No

consume

A function that turns an event into document writes, usually built with graphConsume. Required when you set events. Refer to Keep an Index Up-to-Date for details.

No

primary_key

The field holding each document's unique identifier. Defaults to id. Declare it in fields too, otherwise the module rejects the definition at startup.

No

provider

The identifier of the Search Module Provider that holds this index, such as search-postgres. Defaults to the module's default provider.

No

settings

Engine settings for this index, such as the field to deduplicate results on. Only the Medusa Search provider supports them. Refer to Index Settings for the settings it accepts.

No

Note: Changing fields or settings changes the definition's hash, which the Search Module treats as a schema change. Refer to the Reindexing and Migrations guide to learn how it rebuilds the index.

Fill the Index with seed#

seed fills the index with documents. Every index definition must set it.

The Search Module decides when to run seed, such as when a migration creates the index, when you rebuild it on demand, or when it runs the catch-up pass. Refer to Seeding at Application Start for every case that triggers it and how the module plans each one.

Fill the Index from Query with graphSeed#

Build seed with the graphSeed helper when the index holds an entity that Query exposes, which is the case for most indexes.

For example:

src/search/brand.ts
1import {2  defineSearchIndex,3  graphSeed,4  search,5} from "@medusajs/framework/utils"6
7export const brandIndex = defineSearchIndex({8  name: "brand",9  entity: "brand",10  fields: search.define({11    id: search.keyword().filterable(),12    name: search.text().searchable(),13  }),14  seed: graphSeed({ fields: ["id", "name"] }),15})

graphSeed reads the entity through query.graph and writes every row it reads to the index. It also does the following for you:

  • Pages the read, so a large catalog never loads into memory at once.
  • Resumes an interrupted run from the record it stopped at, rather than restarting it.
  • Handles the catch-up pass, including deleting the documents of records that were removed while the run was writing.

Index Part of an Entity

transform receives a page of records and returns the documents to index. To index a subset of an entity's records, leave the records the index must not hold out of the array you return, rather than narrowing the read with a filter.

For example, to index published products only:

src/search/product.ts
1export const productIndex = defineSearchIndex({2  // ...3  seed: graphSeed({4    fields: ["id", "title", "status"],5    transform: (products) => {6      return products.filter(7        (product) => product.status === "published"8      )9    },10  }),11})

graphSeed deletes the document of a record you leave out on the catch-up pass, so a product that stops qualifying leaves the index instead of going stale in it. A filtered read can't do that, since it never sees the record again.

Important: Every document you return must carry the record's primary key value as its id, since that's how graphSeed matches a document back to the record it came from. The helper throws if a document has no id.

Use transform too when the document differs from the record, such as when you flatten a relation into an array of IDs.

For example, query.graph returns a product's categories as an array of objects, whereas the index holds their IDs in a category_ids field:

src/search/product.ts
1export const productIndex = defineSearchIndex({2  // ...3  fields: search.define({4    id: search.keyword().filterable(),5    title: search.text().searchable(),6    category_ids: search.keyword().filterable(),7  }),8  seed: graphSeed({9    fields: ["id", "title", "categories.id"],10    transform: (products) => products.map((product) => ({11      id: product.id,12      title: product.title,13      category_ids: product.categories.map(14        (category) => category.id15      ),16    })),17  }),18})

Read More Data in transform

transform is asynchronous and receives a whole page of records at a time, so a document that needs data beyond what one query.graph call returns costs a fixed number of extra queries per page, rather than one per record.

For example, to index a French title alongside the English one, read the page again with the locale option:

src/search/product.ts
1export const productIndex = defineSearchIndex({2  name: "product",3  entity: "product",4  fields: search.define({5    id: search.keyword().filterable(),6    title_en: search.text().searchable(),7    title_fr: search.text().searchable(),8  }),9  seed: graphSeed({10    fields: ["id", "title"],11    transform: async (products, { container }) => {12      const { query } = container13      const ids = products.map((product) => product.id)14
15      const { data: translated } = await query.graph({16        entity: "product",17        fields: ["id", "title"],18        filters: { id: ids },19      }, { locale: "fr-FR" })20
21      const frTitles = new Map(22        translated.map((product) => [23          product.id,24          product.title,25        ])26      )27
28      return products.map((product) => ({29        id: product.id,30        title_en: product.title,31        title_fr:32          frTitles.get(product.id) ?? product.title,33      }))34    },35  }),36})

Pass a Query Context

Some fields only resolve when query.graph receives a query context, such as variants.calculated_price, which needs a currency or a region to calculate a price for. Pass that context to graphSeed with the context option:

src/search/product.ts
1import { QueryContext } from "@medusajs/framework/utils"2
3export const productIndex = defineSearchIndex({4  name: "product",5  entity: "product",6  fields: search.define({7    // ...8  }),9  seed: graphSeed({10    fields: [11      "id",12      "title",13      "variants.calculated_price.*",14    ],15    context: {16      variants: {17        calculated_price: QueryContext({18          currency_code: "usd",19        }),20      },21    },22  }),23})

graphSeed applies the context to the full run and to the catch-up pass alike. graphConsume accepts the same option, so pass it to both helpers to write the same document from an event.

You can also pass a function, which receives the same context seed receives. Use it when the query context depends on the index being filled:

src/search/product.ts
1export const productIndex = defineSearchIndex({2  // ...3  seed: graphSeed({4    // ...5    context: ({ index }) => ({6      variants: {7        calculated_price: QueryContext({8          currency_code: index.name.endsWith("_eur")9            ? "eur"10            : "usd",11        }),12      },13    }),14  }),15})

graphSeed Options

Loading...

Catch Up on Changes During a Run#

A full seed or rebuild reads your data while your application keeps writing to it, so a record that changes mid-run can land in the index stale, or not at all. To close that gap, the Search Module runs seed a second time as soon as the run finishes, passing catchup.since, which is the time the run started.

The pass must read the records updated at or after catchup.since, including the ones that were deleted, then write the changes to the index. graphSeed does all of that, so an index using it needs nothing else. Refer to Handle the Catch-Up Pass to handle it in a seed you write yourself.

The module skips the pass for a partial rebuild, since a filtered run is already scoped to what the caller asked for.


Keep an Index Up-to-Date#

If you omit events and consume, the Search Module only fills an index in the cases listed for seed. So the index holds whatever the last run produced until something triggers another one.

Note: A restart doesn't refill an index that already has documents. The module only seeds an index that holds no documents, or one whose previous seed didn't finish.

To ensure your index is always up-to-date, add the following to your index definition:

  • events: an array of workflow event names that change the data the index holds. Medusa subscribes to them and calls consume for each one.
    • See the Events Reference for the full list of events Medusa emits and their payloads. You can also use custom events that you emit.
  • consume: a function that turns an event into document writes.

Consume Events from Query with graphConsume#

Build consume with the graphConsume helper when the index holds an entity that Query exposes, the same way graphSeed builds seed.

For example:

src/search/brand.ts
1import {2  defineSearchIndex,3  graphConsume,4  graphSeed,5  search,6} from "@medusajs/framework/utils"7
8const fields = ["id", "name"]9
10export const brandIndex = defineSearchIndex({11  // ...12  events: [13    "brand.created",14    "brand.updated",15    "brand.deleted",16  ],17  consume: graphConsume({ fields }),18  seed: graphSeed({ fields }),19})

graphConsume reads the records the event affects through query.graph and writes them to the index.

It deletes a record's document instead when the event's name ends in .deleted, when the read doesn't return the record, or when a transform leaves it out of the documents it returns.

Share the Options with graphSeed

Pass the same options to both helpers so a document written by an event matches the one the seed writes. Declare them once and reuse them, rather than repeating the fields and transform in each call.

For example, this is the published-products index with its events applied to it:

src/search/product.ts
1const source = {2  fields: ["id", "title", "status"],3  transform: (products) => {4    return products.filter(5      (product) => product.status === "published"6    )7  },8}9
10export const productIndex = defineSearchIndex({11  // ...12  events: [13    "product.created",14    "product.updated",15    "product.deleted",16  ],17  consume: graphConsume(source),18  seed: graphSeed(source),19})

graphConsume deletes the document of a product the transform leaves out, so unpublishing a product removes it from the index as soon as its product.updated event arrives, rather than waiting for the next seed.

The same holds for a transform that reshapes the record. For example, this is the category IDs index kept current by its events:

src/search/product.ts
1const source = {2  fields: ["id", "title", "categories.id"],3  transform: (products) => products.map((product) => ({4    id: product.id,5    title: product.title,6    category_ids: product.categories.map(7      (category) => category.id8    ),9  })),10}11
12export const productIndex = defineSearchIndex({13  // ...14  events: [15    "product.created",16    "product.updated",17    "product.deleted",18  ],19  consume: graphConsume(source),20  seed: graphSeed(source),21})

graphConsume Options

Loading...
Tip: graphSeed and graphConsume can also select fields from a linked module, such as a product's brand. Refer to the Index Data from a Linked Module guide to learn how.

Custom seed and consume#

Write seed and consume yourself when the index doesn't map to a single Query read, such as when its documents come from an external service, or when building a document takes more than reading the entity's fields.

Both return the same writes, which are one of the following:

  • { action: "upsert", documents }: writes the documents into the index, replacing any document already stored under the same ID.
  • { action: "delete", filters }: removes every document matching the filters.

The examples in this section index an article entity whose content lives in a headless CMS rather than in your Medusa application, so they call a cms client instead of Query.

Warning: The container that seed and consume receive isn't the Medusa container. It only exposes query, to read data with Query, and logger, to report what an ingestion run is doing. You can't resolve a module or another registration from it, so import the client you need into the file instead, as the examples do.

Custom seed Implementation#

seed is an async generator, so yield a batch of writes as you build it. The Search Module writes each batch to the engine as it arrives, rather than holding the entire index in memory.

For example:

src/search/article.ts
1import { cms } from "../lib/cms"2
3export const articleIndex = defineSearchIndex({4  name: "article",5  entity: "article",6  fields: search.define({7    id: search.keyword().filterable(),8    title: search.text().searchable(),9    body: search.text().searchable(),10  }),11  async *seed() {12    let page = 113
14    while (true) {15      const articles = await cms.listArticles({16        page,17        limit: 200,18      })19
20      if (!articles.length) {21        break22      }23
24      yield [{25        action: "upsert",26        documents: articles,27      }]28
29      page++30    }31  },32})

Log an Ingestion Run

A full run can take a while, so use the logger of the container to report what it's doing. Its messages show in your application's logs alongside the messages that the Search Module logs itself.

src/search/article.ts
1export const articleIndex = defineSearchIndex({2  // ...3  async *seed({ container, index }) {4    const { logger } = container5
6    // ...7
8    logger.info(9      `Seeding ${index.name} with ` +10        `${articles.length} articles`11    )12  },13})

seed receives an object parameter with the following properties:

Loading...

Handle the Catch-Up Pass

A seed you write yourself also runs as the catch-up pass that follows a full run. Handle it by doing the following:

  1. Narrow your read to records changed at or after catchup.since, so the pass stays small.
  2. Include the records that were deleted, since a record removed mid-run is a document the index must lose.
  3. Yield an upsert write for a record that still exists, and a delete write for one that's gone.

For example:

src/search/article.ts
1export const articleIndex = defineSearchIndex({2  // ...3  async *seed({ catchup }) {4    const articles = await cms.listArticles({5      changed_since: catchup?.since,6      include_deleted: !!catchup,7    })8
9    const live = articles.filter((a) => !a.deleted)10    const gone = articles.filter((a) => a.deleted)11
12    yield [13      ...(live.length14        ? [{ action: "upsert", documents: live }]15        : []),16      ...gone.map((article) => ({17        action: "delete",18        filters: { id: [article.id] },19      })),20    ]21  },22})

Custom consume Implementation#

consume turns one event into the writes that apply it to the index. It returns them in an array rather than yielding them, since an event changes specific documents.

It returns the same writes seed yields:

  • { action: "upsert", documents }: writes the documents into the index, replacing any document already stored under the same ID.
  • { action: "delete", filters }: removes every document matching the filters.

Return an empty array for an event that changes nothing the index holds.

For example:

src/search/article.ts
1export const articleIndex = defineSearchIndex({2  // ...3  events: [4    "article.published",5    "article.unpublished",6  ],7  async consume(event) {8    if (event.name === "article.unpublished") {9      return [{10        action: "delete",11        filters: { id: [event.data.id] },12      }]13    }14
15    const article = await cms.retrieveArticle(16      event.data.id17    )18
19    return [{20      action: "upsert",21      documents: [article],22    }]23  },24})

consume receives two parameters:

Loading...
Note: context holds container and index only. Unlike seed, it carries no filters, last_key, or catchup, since an event changes specific documents rather than filling the whole index.

Point an Index at a Specific Provider#

If you're using multiple Search Module Providers, set the provider property to the ID of a registered provider.

For example, you can keep a high-traffic index on a dedicated engine while quieter indexes stay on PostgreSQL.

src/search/product.ts
1export const productIndex = defineSearchIndex({2  name: "product",3  entity: "product",4  provider: "search-postgres",5  // ...6})

When you omit it, the index uses the module's default_provider.

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