The first genuine question everyone has after building a Medusa module is: fine, but how do I attach it to a product?
You cannot add a foreign key — that would couple your module to the Product module and defeat the isolation the whole architecture rests on. The answer is a module link: a declared relationship that Medusa stores in its own table and resolves through the query layer.
Defining a link
A brand module, linked to core products:
import BrandModule from "../modules/brand"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
ProductModule.linkable.product,
BrandModule.linkable.brand
)linkable is generated from each module's models. This declares a relationship between products and brands — the direction reads as "a product has a brand."
Generate and run the migration:
npx medusa db:migrateMedusa creates a table holding the pairs. Neither module's schema changes.
Reading across a link
The query service resolves links transparently. Ask for nested fields and it assembles the result across modules:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data: products } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"handle",
"brand.*", // resolved through the link
"variants.*",
],
filters: { id: req.params.id },
})
res.json({ product: products[0] })
}You did not join anything. query read the link table, fetched from both modules and stitched the result. It also handles pagination and filtering on either side.
Filtering by the linked entity works the same way:
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.name"],
filters: { brand: { name: "Aurora" } },
})Writing links
Links are created in workflows using the link management step:
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
import { Modules } from "@medusajs/framework/utils"
import { BRAND_MODULE } from "../modules/brand"
type Input = { product_id: string; brand_id: string }
export const assignBrandWorkflow = createWorkflow(
"assign-brand",
(input: Input) => {
const links = createRemoteLinkStep([
{
[Modules.PRODUCT]: { product_id: input.product_id },
[BRAND_MODULE]: { brand_id: input.brand_id },
},
])
return new WorkflowResponse(links)
}
)Because it is a step, it compensates: if a later step fails, the link is removed. Use dismissRemoteLinkStep to detach.
One-to-one versus list
By default a link is a list — many products to many brands. For a strict one-to-one, say so:
export default defineLink(
ProductModule.linkable.product,
{
linkable: BrandModule.linkable.brand,
isList: false,
}
)Get this right at design time. Changing it later means a data migration, because the shape of the link table changes.
When to use a link versus metadata
The decision that determines whether your data model ages well.
| Situation | Use |
|---|---|
| Data with its own lifecycle, edited independently | Link to a module |
| Data you filter, sort or aggregate on | Link to a module |
| Data with validation rules | Link to a module |
| A display string nothing depends on | `metadata` |
| A flag read once in a template | `metadata` |
The failure mode is predictable: metadata starts as one field and becomes an untyped, unindexed, unvalidated schema that half the codebase reads with optional chaining. If you would want a database index on it, it belongs in a module.
Practical guidance
Name link files for the pair. product-brand.ts, order-invoice.ts. Someone will need to find them.
Query for exactly the fields you need. brand.* is convenient; on a listing that returns 50 products it fetches more than it should. Ask for brand.name when that is all you render.
Do not copy linked data into your module. Storing brand_name alongside brand_id "for speed" creates a cache you must invalidate. Query across the link.
Remember links are directional in reading, not in truth. You can traverse from either side; the definition order only sets the natural field name.
Read-only links to core data
Links also work in the other direction: exposing your module's data on a core entity without your module knowing anything about it. This is what makes a plugin able to enrich products it did not define.
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "reviews.rating", "reviews.body"],
filters: { "reviews.rating": { $gte: 4 } },
})Filtering on the linked entity's fields is resolved by the query layer, so a plugin's reviews become a first-class filter on products with no change to the Product module.
Performance of linked queries
Links are resolved by the query layer rather than by a SQL join, which has one practical consequence: a listing that requests deeply nested linked fields issues more work than one that does not.
| Query | Cost |
|---|---|
| `fields: ["id", "title"]` | One module |
| `fields: ["id", "title", "brand.name"]` | Two modules, one link resolution |
| `fields: ["id", "title", "brand.*", "reviews.*"]` | Three modules, two links, larger payload |
Guidance that holds up in practice:
- **Request named fields, not
*, on listings.**brand.namewherebrand.*is not needed. - Index the columns you filter on. A link's foreign column in your module should carry an index —
model.text().index(). - Cache listing queries. Category pages with linked data are the classic candidate; see caching.
Detail pages can afford richer projections. Listings, which fan out over dozens of records, cannot.
Deleting linked records
Because links are not foreign keys, the database will not stop you deleting a record something else points at. Cleanup is your responsibility.
Handle it in the workflow that deletes, using dismissRemoteLinkStep so the link goes with the record:
dismissRemoteLinkStep([
{
[Modules.PRODUCT]: { product_id: input.product_id },
[BRAND_MODULE]: { brand_id: input.brand_id },
},
])If you delete outside a workflow, add a periodic job that finds links whose targets no longer resolve and removes them. Orphaned links are harmless in the sense that queries skip them, and they accumulate — and an audit that says "12,000 links point at nothing" is an uncomfortable conversation you can avoid with one scheduled job.
Links are the piece that makes module isolation practical rather than theoretical. If a data model is fighting you, we are happy to look.
Frequently asked questions
What is a module link in Medusa v2?
A declared relationship between records in two isolated modules, stored in a table Medusa manages. It lets you associate, for example, a custom brand record with a core product without either module importing the other or sharing a foreign key.
How do I query data across a module link?
Use the `query` service's `graph` method and request nested fields — `fields: ["id", "title", "brand.*"]`. The query layer reads the link table and fetches from both modules. You can also filter by fields on the linked entity.
Can I add a foreign key between modules instead?
No. Modules are isolated so they can be replaced, extracted or scaled independently, and a cross-module foreign key breaks that. Links exist precisely to provide the relationship without the coupling.
How do I create a link between two records?
In a workflow, using `createRemoteLinkStep` with an object keyed by module name and record id. Because it is a workflow step it participates in compensation, so a failure later in the workflow removes the link automatically.
Is a module link the same as a database join?
No. A join happens inside one database query across tables in the same schema. A link is resolved by Medusa's query layer, which may issue separate queries per module and combine the results — which is what allows modules to live in different databases if you ever need that.
When should I use metadata instead of a link?
Only for inert display data that nothing queries, validates or aggregates. Anything you would want indexed, filtered or constrained belongs in a module with a link. Metadata is convenient early and expensive later.
Medusa Project Structure: Where Every Kind of Code Belongs
Medusa loads code by convention. A directory-by-directory guide to what goes where, and the decision tree for placing any new piece of logic.
The Medusa Container: Dependency Injection Without the Ceremony
Everything in Medusa is resolved from a container. What is registered, how scoping works per request, and how to avoid the two mistakes that cause leaks.
Medusa v2 Modules Explained: Building Custom Commerce Domains
Modules are the unit of everything in Medusa v2. What they are, why isolation matters, and how to build one that will not embarrass you in a year.



