Links are a Medusa framework primitive. The examples below link a custom Brand module to Medusa’s built-in Product module — the kind of relationship you’d add in your own project.
defineLink
A link is a small file that associates two linkable data models. Define it once and sync it to the database with a migration.
src/links/product-brand.ts
The link-direction rule
The order of arguments todefineLink is meaningful and cardinality is controlled with isList. Read it left-to-right as “the left model links to the right model”.
defineLink(A.linkable.a, B.linkable.b)— onea↔ oneb.- Wrap a side in
{ linkable, isList: true }to make it the “many” side.
src/links/product-brand.ts — one brand, many products
src/links/product-brand.ts — many-to-many
Built-in link steps — create links inside workflows
Links are data, so creating or removing one is a mutation and must happen inside a workflow through the built-in link steps — never by writing to the link table directly.createRemoteLinkStep— create links (and it compensates by removing them on failure).dismissRemoteLinkStep— remove links.
transform (never inline logic in the composition function), then pass them to the step. Each entry names the two modules and the ids to associate:
Linking a product to a brand inside a workflow
Reading vs filtering across a link
This is the distinction that trips people up:- Reading linked data — fetching
brand.*alongside a product — works withquery.graph. Query aggregates the two modules’ data to build the result. - Filtering by a linked module’s field — “give me products where
brand.id = X” — does not work withquery.graph.
product.status or offer.seller_id) — that’s a normal query.graph filter. It’s only linked-module fields that need a different tool.
Filtering by a linked field — the Index Module
Cross-module filtering is what the Index Module (@medusajs/index) exists for. It ingests data models into a single relational store on startup, so you can filter one entity by another’s fields. Install it, make sure both models are ingested, and query with query.index instead of query.graph:
Filter products by their linked brand — query.index, not query.graph
By default Medusa ingests only
Product, ProductVariant, Price, PriceSet, and SalesChannel. To filter products by a custom module like Brand, you must ingest that model into the Index Module first. The Index Module is still marked experimental, though it powers filtering in the Medusa Admin.query.index takes the same shape as query.graph (entity, fields, filters, pagination), so a route handler can forward req.filterableFields to it exactly the same way — the only change is graph → index.
Checklist for a link
- Declared in its own file under
src/links/, usingdefineLink. - Argument order reflects the natural reading direction;
isListset on the many side(s). - Migration generated and run (
medusa db:migrate). - Cross-module reads go through
query.graph, never a service-to-service call. - Cross-module filters go through
query.index(Index Module, with the model ingested) —query.graphcan’t filter by a linked field. - Links are created/removed only inside workflows via
createRemoteLinkStep/dismissRemoteLinkStep.