Links are a Medusa framework primitive. The examples below link a custom Brand module to Medusa’s built-in Product module. It is the kind of relationship you would add in your own project.
Define a link with defineLink
A link is a small file that associates two linkable data models. You define it once and sync it to the database with a migration.
src/links/product-brand.ts
Terminal
Read across the link with Query
The link-direction rule
The order of arguments todefineLink is meaningful, and you control cardinality with isList. Read it left-to-right as “the left model links to the right model”.
defineLink(A.linkable.a, B.linkable.b): onealinks to oneb.isList: true: wrap a side in{ linkable, isList: true }to make it the “many” side.
src/links/product-brand.ts
src/links/product-brand.ts
Create links inside workflows
Links are data, so creating or removing one is a mutation. It must happen inside a workflow through the built-in link steps. Never write to the link table directly.createRemoteLinkStep: creates links, and compensates by removing them on failure.dismissRemoteLinkStep: removes 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 is a normal query.graph filter. Only linked-module fields need a different tool.
Filter by a linked field with 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 with query.index
By default Medusa ingests only
Product, ProductVariant, Price, PriceSet, and SalesChannel. To filter products by a custom module such as 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 to index.
Checklist for a link
- Declared in its own file under
src/links/, usingdefineLink. - Argument order reflects the natural reading direction, with
isListset on the many side or sides. - 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.graphcannot filter by a linked field. - Links are created and removed only inside workflows via
createRemoteLinkStepanddismissRemoteLinkStep.
Next steps
Modules
Keep modules isolated so links stay the only boundary between them.
Workflows
Create and remove links inside compensating workflow steps.