Product Search Index Examples
In this guide, you'll find examples of indexing product data that a storefront's search and browsing experience needs, such as prices in multiple currencies, option values, and categories.
Simple Product Index#
The following index definition holds a product's basic details, and it's the starting point that the rest of this guide's sections extend.
Create the file src/search/product.ts with the following content:
1import {2 defineSearchIndex,3 graphConsume,4 graphSeed,5 search,6} from "@medusajs/framework/utils"7 8const source = {9 fields: [10 "id",11 "title",12 "description",13 "handle",14 "thumbnail",15 "status",16 ],17 transform: (products) => {18 return products.filter(19 (product) => product.status === "published"20 )21 },22}23 24export default defineSearchIndex({25 name: "product",26 entity: "product",27 fields: search.define({28 id: search.keyword().filterable().retrievable(),29 title: search30 .text()31 .searchable({ weight: 3 })32 .sortable()33 .retrievable(),34 description: search.text().searchable(),35 handle: search.keyword().retrievable(),36 thumbnail: search.keyword().retrievable(),37 created_at: search.date().sortable().retrievable(),38 }),39 events: [40 "product.created",41 "product.updated",42 "product.deleted",43 ],44 consume: graphConsume(source),45 seed: graphSeed(source),46})
source holds the options that graphSeed and graphConsume share, so a document written by an event matches the one the seed writes.
Since the transform returns no document for a product that isn't published, unpublishing a product removes it from the index as soon as its product.updated event arrives.
Scope the Index to Published Products and Sales Channels#
A storefront must never find a draft product, or a product that isn't in the request's sales channel. So, declare a status and a sales_channel_ids field with the filterable modifier, and the Store Search API route narrows every query to the products a storefront may see.
sales_channel_ids isn't a field of the product itself, so select the product's sales_channels.id and flatten them onto the document in the transform:
1const source = {2 fields: [3 // ...4 "status",5 "sales_channels.id",6 ],7 transform: (products) => {8 return products9 .filter(10 (product) => product.status === "published"11 )12 .map((product) => ({13 // ...14 status: product.status,15 sales_channel_ids: (16 product.sales_channels ?? []17 ).map((salesChannel) => salesChannel.id),18 }))19 },20}21 22export default defineSearchIndex({23 // ...24 fields: search.define({25 // ...26 status: search27 .keyword()28 .filterable()29 .retrievable(false),30 sales_channel_ids: search31 .keyword()32 .array()33 .filterable()34 .retrievable(false),35 }),36 consume: graphConsume(source),37 seed: graphSeed(source),38})
Both fields use retrievable(false), so the route filters on them without returning them in a hit.
Refer to the Store Search API Route guide for the filters that the route applies, and for how to narrow an index further with filters of your own.
Index Prices for Multiple Currencies#
The Pricing Module calculates a price for one currency per query, and a search engine can't calculate a price at query time. So, the index holds a set of price fields per currency, and each currency's fields are filterable, sortable, and facetable on their own.
1. Declare the Price Fields#
Declare the currencies the index holds prices in, build a set of fields for each of them, then spread the fields into the index:
1const PRICE_CURRENCIES = ["usd", "eur"] as const2 3type PriceCurrency = (typeof PRICE_CURRENCIES)[number]4 5const priceFields = Object.fromEntries(6 PRICE_CURRENCIES.flatMap((currency) => [7 [8 `min_price_${currency}`,9 search10 .float()11 .filterable()12 .sortable()13 .facetable({ types: ["stats"] })14 .retrievable(),15 ],16 [17 `max_price_${currency}`,18 search19 .float()20 .filterable()21 .sortable()22 .facetable({ types: ["stats"] })23 .retrievable(),24 ],25 [26 `original_price_${currency}`,27 search.float().retrievable(),28 ],29 [30 `on_sale_${currency}`,31 search32 .boolean()33 .filterable()34 .facetable()35 .retrievable(),36 ],37 ])38)39 40export default defineSearchIndex({41 // ...42 fields: search.define({43 // ...44 ...priceFields,45 }),46})
The min_price and max_price fields use the stats facet type, which returns the lowest and highest price among the matching products. A storefront uses that to render a price-range slider whose bounds follow the current filters.
2. Read the Prices#
variants.calculated_price only resolves when query.graph receives a query context with a currency. So, add a function that reads the products once per currency:
1import { QueryContext } from "@medusajs/framework/utils"2import type { SearchTypes } from "@medusajs/framework/types"3 4// ...5 6type PricedVariant = {7 calculated_price?: {8 calculated_amount?: number | null9 original_amount?: number | null10 } | null11}12 13type ProductPricingRows = Partial<14 Record<PriceCurrency, PricedVariant[] | null>15>16 17async function loadPricing(18 ids: string[],19 { container }: SearchTypes.SearchIngestionContext20) {21 const pricing = new Map<string, ProductPricingRows>()22 23 if (!ids.length) {24 return pricing25 }26 27 await Promise.all(28 PRICE_CURRENCIES.map(async (currency) => {29 const { data } = await container.query.graph({30 entity: "product",31 fields: [32 "id",33 "variants.calculated_price.calculated_amount",34 "variants.calculated_price.original_amount",35 ],36 filters: { id: ids },37 context: {38 variants: {39 calculated_price: QueryContext({40 currency_code: currency,41 }),42 },43 },44 })45 46 for (const product of data) {47 const rows = pricing.get(product.id) ?? {}48 rows[currency] = product.variants49 pricing.set(product.id, rows)50 }51 })52 )53 54 return pricing55}
loadPricing receives the IDs of the products being indexed, then reads each of them once per currency with the currency's pricing context. It returns a map of a product's ID to its priced variants per currency, which the next step turns into the document's price fields.
transform receives a whole page of products at a time, so this costs one extra read per currency for the page, rather than one per product.
3. Build the Price Fields of a Document#
Next, turn a product's variants into the price fields of its document. The cheapest variant provides the calculated and original price as a pair, so the discount a storefront renders describes one real variant instead of mixing two variants' amounts:
1function toPricing(2 currency: PriceCurrency,3 variants: PricedVariant[] | null | undefined4) {5 let cheapest:6 | { calculated: number; original: number }7 | undefined8 let maxPrice: number | undefined9 10 for (const variant of variants ?? []) {11 const price = variant?.calculated_price12 const calculated = price?.calculated_amount13 14 if (typeof calculated !== "number") {15 continue16 }17 18 const original =19 typeof price?.original_amount === "number"20 ? price.original_amount21 : calculated22 23 if (maxPrice === undefined || calculated > maxPrice) {24 maxPrice = calculated25 }26 27 if (!cheapest || calculated < cheapest.calculated) {28 cheapest = { calculated, original }29 }30 }31 32 if (!cheapest) {33 return {}34 }35 36 return {37 [`min_price_${currency}`]: cheapest.calculated,38 [`max_price_${currency}`]: maxPrice,39 [`original_price_${currency}`]: cheapest.original,40 [`on_sale_${currency}`]:41 cheapest.original > cheapest.calculated,42 }43}44 45function toProductPricing(46 rows: ProductPricingRows | undefined47) {48 return Object.assign(49 {},50 ...PRICE_CURRENCIES.map((currency) =>51 toPricing(currency, rows?.[currency])52 )53 )54}
toPricing scans one currency's variants for the cheapest and the most expensive calculated price, then returns that currency's four fields.
toProductPricing calls toPricing for every currency and merges the results into the fields of one document.
A product without a price in a currency writes none of that currency's fields, so it drops out of that currency's price filter and sort rather than showing up with a price of 0.
4. Write the Prices to the Documents#
Finally, call both functions in an asynchronous transform:
1const source = {2 fields: ["id", "title", "status"],3 transform: async (products, context) => {4 // ...5 const pricing = await loadPricing(6 published.map((product) => product.id),7 context8 )9 10 return published.map((product) => ({11 id: product.id,12 title: product.title,13 // ...14 ...toProductPricing(pricing.get(product.id)),15 }))16 },17}
A storefront then filters on min_price_usd, sorts by it, and shows an "On sale" toggle backed by the on_sale_usd facet.
Index Option Values as a Facet#
To let a storefront filter products by option values, such as a size or a color, flatten a product's options into one array field of "{option title}:{value}" entries. One field keeps the index simple, and the storefront splits each entry on the first : to group the facet by option title.
1// ...2 3function toOptionValues(4 options:5 | ({6 title?: string | null7 values?: ({ value?: string | null } | null)[]8 } | null)[]9 | null10 | undefined11) {12 const values = (options ?? []).flatMap((option) => {13 const title = option?.title?.trim()14 15 if (!title) {16 return []17 }18 19 return (option?.values ?? [])20 .map((optionValue) => optionValue?.value?.trim())21 .filter((value): value is string => Boolean(value))22 .map((value) => `${title}:${value}`)23 })24 25 // A value shared by two options is otherwise26 // counted twice in the facet.27 return Array.from(new Set(values))28}29 30const source = {31 fields: [32 // ...33 "options.title",34 "options.values.value",35 ],36 transform: (products) => {37 return products.map((product) => ({38 // ...39 option_values: toOptionValues(product.options),40 }))41 },42}43 44export default defineSearchIndex({45 // ...46 fields: search.define({47 // ...48 option_values: search49 .keyword()50 .array()51 .searchable({ weight: 2 })52 .filterable()53 .facetable()54 .retrievable(),55 }),56 consume: graphConsume(source),57 seed: graphSeed(source),58})
toOptionValues walks a product's options, pairs each option's title with each of its values, and drops duplicates. A product with a Size and a Color option yields entries such as ["Size:S", "Size:M", "Color:Red"], which the transform writes to the document's option_values field.
Since the field is searchable, a customer searching for "red" also matches a product whose color option has a Red value.
Index Categories and Tags as Facets#
query.graph returns a product's categories and tags as arrays of objects, whereas a facet needs a flat array of values. So, select the field to show from each relation and flatten it in the transform:
1const source = {2 fields: [3 // ...4 "categories.name",5 "tags.value",6 ],7 transform: (products) => {8 return products.map((product) => ({9 // ...10 category: (product.categories ?? []).map(11 (category) => category.name12 ),13 labels: (product.tags ?? []).map(14 (tag) => tag.value15 ),16 }))17 },18}19 20export default defineSearchIndex({21 // ...22 fields: search.define({23 // ...24 category: search25 .keyword()26 .array()27 .filterable()28 .facetable()29 .retrievable(),30 labels: search31 .keyword()32 .array()33 .filterable()34 .facetable()35 .retrievable(),36 }),37 consume: graphConsume(source),38 seed: graphSeed(source),39})
To index a product's data from a linked module instead, such as a brand you created in a custom module, refer to the Index Data from a Linked Module guide.
Re-Index a Product When Related Data Changes#
A product's document holds data from its variants, options, tags, categories, and sales channels. So, subscribe to the events of all of them, then map each event back to the products it affects with the resolve_ids option of graphConsume.
Add the following functions to the file:
1import type {2 RemoteQueryFunction,3} from "@medusajs/framework/types"4 5// ...6 7function payloadIds(data: unknown): string[] {8 return (Array.isArray(data) ? data : [data])9 .map((entry) => (entry as { id?: string })?.id)10 .filter((id): id is string => Boolean(id))11}12 13async function relatedProductIds(14 query: RemoteQueryFunction,15 entity: string,16 fields: string[],17 ids: string[],18 pick: (row: any) => (string | null | undefined)[],19 withDeleted: boolean20) {21 const { data } = await query.graph({22 entity,23 fields,24 filters: { id: ids },25 withDeleted,26 })27 28 return data29 .flatMap(pick)30 .filter((id): id is string => Boolean(id))31}32 33async function resolveProductIds(34 event: { name: string; data: unknown },35 { container }: SearchTypes.SearchIngestionContext36) {37 const ids = payloadIds(event.data)38 const [entity] = event.name.split(".")39 const deleted = event.name.endsWith(".deleted")40 41 if (!ids.length) {42 return []43 }44 45 switch (entity) {46 case "product":47 return ids48 case "product-variant":49 return relatedProductIds(50 container.query,51 "product_variant",52 ["product_id"],53 ids,54 (row) => [row.product_id],55 deleted56 )57 case "product-tag":58 return relatedProductIds(59 container.query,60 "product_tag",61 ["products.id"],62 ids,63 (row) => (row.products ?? []).map((p) => p?.id),64 deleted65 )66 case "product-category":67 return relatedProductIds(68 container.query,69 "product_category",70 ["products.id"],71 ids,72 (row) => (row.products ?? []).map((p) => p?.id),73 deleted74 )75 default:76 return []77 }78}
resolveProductIds is the function you pass as resolve_ids in the next snippet, and graphConsume calls it for every event the index subscribes to. It reads the IDs from the event's payload, then decides what they point to based on the entity in the event's name: a product event names the products directly, while any other event's IDs are passed to relatedProductIds, which reads the products behind those records through query.graph.
Medusa soft-deletes records, so a deleted variant or category is still readable with withDeleted, which is how it still leads back to the products to re-index.
Then, add the related entities' events to the index, pass the function to graphConsume, and tell it that only product.deleted removes documents. Every other event means the product must be read again:
1export default defineSearchIndex({2 // ...3 events: [4 "product.created",5 "product.updated",6 "product.deleted",7 "product-variant.created",8 "product-variant.updated",9 "product-variant.deleted",10 "product-option.updated",11 "product-option-value.updated",12 "product-tag.updated",13 "product-tag.deleted",14 "product-category.updated",15 "product-category.deleted",16 ],17 consume: graphConsume({18 ...source,19 resolve_ids: resolveProductIds,20 is_delete: (event) =>21 event.name === "product.deleted",22 }),23 seed: graphSeed(source),24})
Deleting a Sales Channel#
Deleting a sales channel removes its product links, so query.graph no longer finds the products that were in it. The index still holds the channel's ID on each document, so search the index itself to find the products to re-index:
1async function productIdsInSalesChannels(2 query: RemoteQueryFunction,3 salesChannelIds: string[]4) {5 const { search_result } = await query.search({6 entity: "product",7 fields: ["id"],8 filters: { sales_channel_ids: salesChannelIds },9 pagination: { take: 200 },10 })11 12 return search_result.hits.map((hit) => hit.id)13}
Then, call it from a sales-channel case in resolveProductIds, and add the sales-channel.deleted event to the definition's events:
1async function resolveProductIds(2 event: { name: string; data: unknown },3 { container }: SearchTypes.SearchIngestionContext4) {5 // ...6 7 switch (entity) {8 // ...9 case "sales-channel":10 return productIdsInSalesChannels(11 container.query,12 ids13 )14 default:15 return []16 }17}
If your store has more than 200 products in a channel, page through the results with the skip pagination option.
Complete Index Definition#
The following file combines the examples of this guide. The pricing and event functions are left out, since they're unchanged from the sections above:
1import type { SearchTypes } from "@medusajs/framework/types"2import {3 defineSearchIndex,4 graphConsume,5 graphSeed,6 search,7} from "@medusajs/framework/utils"8 9// The functions and price fields of the10// previous sections...11 12const productFields = search.define({13 id: search.keyword().filterable().retrievable(),14 status: search15 .keyword()16 .filterable()17 .retrievable(false),18 sales_channel_ids: search19 .keyword()20 .array()21 .filterable()22 .retrievable(false),23 title: search24 .text()25 .searchable({ weight: 3 })26 .sortable()27 .retrievable(),28 description: search.text().searchable({ weight: 1 }),29 handle: search.keyword().retrievable(),30 thumbnail: search.keyword().retrievable(),31 created_at: search.date().sortable().retrievable(),32 category: search33 .keyword()34 .array()35 .filterable()36 .facetable()37 .retrievable(),38 labels: search39 .keyword()40 .array()41 .filterable()42 .facetable()43 .retrievable(),44 option_values: search45 .keyword()46 .array()47 .searchable({ weight: 2 })48 .filterable()49 .facetable()50 .retrievable(),51 ...priceFields,52})53 54const source = {55 fields: [56 "id",57 "title",58 "description",59 "handle",60 "thumbnail",61 "status",62 "created_at",63 "sales_channels.id",64 "categories.name",65 "tags.value",66 "options.title",67 "options.values.value",68 ],69 transform: async (70 products,71 context: SearchTypes.SearchIngestionContext72 ) => {73 const published = products.filter(74 (product) => product.status === "published"75 )76 const pricing = await loadPricing(77 published.map((product) => product.id),78 context79 )80 81 return published.map((product) => ({82 id: product.id,83 status: product.status,84 sales_channel_ids: (85 product.sales_channels ?? []86 ).map((salesChannel) => salesChannel.id),87 title: product.title,88 description: product.description,89 handle: product.handle,90 thumbnail: product.thumbnail,91 created_at: product.created_at,92 category: (product.categories ?? []).map(93 (category) => category.name94 ),95 labels: (product.tags ?? []).map(96 (tag) => tag.value97 ),98 option_values: toOptionValues(product.options),99 ...toProductPricing(pricing.get(product.id)),100 }))101 },102}103 104export default defineSearchIndex({105 name: "product",106 entity: "product",107 primary_key: "id",108 fields: productFields,109 settings: {110 typo_tolerance: { enabled: true },111 },112 events: [113 "product.created",114 "product.updated",115 "product.deleted",116 "product-variant.created",117 "product-variant.updated",118 "product-variant.deleted",119 "product-option.updated",120 "product-option-value.updated",121 "product-tag.updated",122 "product-tag.deleted",123 "product-category.updated",124 "product-category.deleted",125 "sales-channel.deleted",126 ],127 consume: graphConsume({128 ...source,129 resolve_ids: resolveProductIds,130 is_delete: (event) =>131 event.name === "product.deleted",132 }),133 seed: graphSeed(source),134})
After changing the definition, run the migrations command to build the new index version:
Then, allow the index on the Store Search API route to search it from your storefront.