Diagnostic Modules, links, and data
Linked data missing after a Medusa link migration
The code looks right. A brand module exists, a product-brand link is defined with defineLink(), and both sides independently hold real rows. But every product you fetch with the brand relation expanded comes back with brand set to null, as if the link were never made. Here is why that happens and a small script that tells you, from the outside, whether the link's own database table was ever built.
In Medusa v2, a module link created with defineLink() under src/links/ is its own database artifact. Medusa generates a dedicated pivot table, named with the convention {module1}_{table1}_{module2}_{table2}, to store the linked record id pairs, separate from each module's own migrations. That table is only created or updated when npx medusa db:sync-links runs, or implicitly as part of npx medusa db:migrate. A deploy that runs migrations but skips the link sync, or a link file added after the last migrate, leaves the link defined in code but the table absent or stale in the database. The proxy signal, from the API alone, is that the expanded relation is empty for one hundred percent of parent records while the linked module independently has rows. Run a small Python or Node.js script that checks exactly that. Full code, tests, and the decision logic are below.
The problem in plain words
Most relations in Medusa live inside one module's own tables and are set up by that module's own migrations. A module link is different. When you write defineLink() in src/links/product-brand.ts to connect the Product module and a custom Brand module, Medusa does not add a column to either module's tables. It generates a brand new pivot table just for that link, something like product_product_brand_brand, that stores pairs of linked record ids.
That pivot table has nothing to do with the product migrations or the brand module's own migrations. It is only created, or brought up to date, when you run npx medusa db:sync-links, or when npx medusa db:migrate runs and syncs links as part of its normal flow. If a CI pipeline runs migrations but the deploy step that syncs links gets skipped, or if the link file was added to the codebase after the last time anyone ran a full migrate, the table is either missing entirely or stuck on an older shape. The link is fully defined in code and the two modules both have valid data, but the join table that would connect them was never built or refreshed.
Why it happens
Since a module link's pivot table lives outside both modules' own migrations, keeping it in sync is a separate operational step, and it is easy to lose track of. A few common ways stores end up here:
- CI runs
db:migratefor each module's own schema changes, but the deploy step that also runsdb:sync-linkswas never added, or was removed during a pipeline cleanup. - A new
defineLink()file was added tosrc/links/after the last successful migrate, so nothing has synced it against the running database yet. - A local
db:migratewas run once during development, and the same command was assumed to cover links in every environment, but a later Medusa upgrade changed how link sync is invoked in CI. - A rollback restored an older database snapshot that predates the link's pivot table, and the deploy that followed only ran forward migrations, not a link sync.
This is a common source of confusion because everything else about the link looks correct. The module builds, the link file has no type errors, and a direct query against each module's own table returns real records. Nobody thinks to ask whether a completely separate table, the pivot table, was ever created for the link itself. See the citations at the end for the exact docs and a related GitHub issue.
An empty relation on a module link is not automatically "no data was linked yet." Those are two different problems with two different fixes. The tell is in the pattern: if the linked module independently has records, but every single parent record resolves the relation as empty, that is not a data problem, it is the classic symptom of a missing or stale pivot table. That distinction is exactly what the pure decision function below checks for.
The fix, as a flow
We do not touch the database directly and we do not try to create the pivot table from a script. We add a read-only check that lists parent records with the relation expanded, counts how many actually resolved something, independently confirms the linked module has rows of its own, and classifies the result. If the pattern matches an unmigrated link, the fix is to run the migration command against the backend, not to patch data.
Build it step by step
Get an admin token
Exchange an admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # this script only ever reads, DRY_RUN just controls logging
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // this script only ever reads, DRY_RUN just controls logging
List products with the linked relation expanded
Ask the Admin API for products with the custom brand relation expanded. The fields query param prefixes a relation with * to include it, so *brand asks Medusa to resolve the product-brand link for each row. Page through with count, offset, and limit.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def get_products_with_brand(token):
products = []
offset = 0
limit = 100
while True:
r = requests.get(
f"{BACKEND_URL}/admin/products",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,title,*brand", "limit": limit, "offset": offset},
timeout=30,
)
r.raise_for_status()
body = r.json()
products.extend(body["products"])
offset += limit
if offset >= body["count"]:
return products
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
return sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function getProductsWithBrand() {
const products = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.product.list({
fields: "id,title,*brand",
limit,
offset,
});
products.push(...body.products);
offset += limit;
if (offset >= body.count) return products;
}
}
Independently confirm the linked module has data
Query the custom module's own list route, for example /admin/brands, on its own terms. This step exists specifically to rule out the other explanation: if the linked module has zero records of its own, an empty relation everywhere is expected, not a sign of a broken pivot table.
def get_brands(token):
r = requests.get(
f"{BACKEND_URL}/admin/brands",
headers={"Authorization": f"Bearer {token}"},
params={"limit": 100},
timeout=30,
)
r.raise_for_status()
return r.json()["brands"]
async function getBrands() {
const res = await sdk.client.fetch("/admin/brands", {
query: { limit: 100 },
});
return res.brands;
}
Decide, with one pure function
Keep the classification in its own function that takes plain counts and booleans and returns one of four labels. It never touches the network, so it is trivial to test with synthetic numbers, which we do later. LIKELY_UNMIGRATED_LINK is the one that matters: every parent record resolves the relation empty, even though the linked module independently has rows.
def detect_unmigrated_link(total_parent_records, parents_with_linked_field_present,
linked_module_has_any_records, link_definition_exists_in_code):
if not link_definition_exists_in_code:
return "NO_LINK_DEFINED"
if total_parent_records == 0:
return "OK"
if parents_with_linked_field_present == 0 and linked_module_has_any_records:
return "LIKELY_UNMIGRATED_LINK"
if parents_with_linked_field_present == 0 and not linked_module_has_any_records:
return "LINK_NOT_YET_POPULATED"
return "OK"
export function detectUnmigratedLink({
totalParentRecords,
parentsWithLinkedFieldPresent,
linkedModuleHasAnyRecords,
linkDefinitionExistsInCode,
}) {
if (!linkDefinitionExistsInCode) return "NO_LINK_DEFINED";
if (totalParentRecords === 0) return "OK";
if (parentsWithLinkedFieldPresent === 0 && linkedModuleHasAnyRecords) return "LIKELY_UNMIGRATED_LINK";
if (parentsWithLinkedFieldPresent === 0 && !linkedModuleHasAnyRecords) return "LINK_NOT_YET_POPULATED";
return "OK";
}
Count what actually resolved
Turn the product list into the two numbers the classifier needs: how many products have any records at all, and how many of them came back with a non-null, non-empty brand. This is the only place we look at the shape of the linked field itself.
def has_linked_field(product):
brand = product.get("brand")
if brand is None:
return False
if isinstance(brand, list):
return len(brand) > 0
return True
def count_linked_field_present(products):
return sum(1 for p in products if has_linked_field(p))
export function hasLinkedField(product) {
const brand = product.brand;
if (brand === null || brand === undefined) return false;
if (Array.isArray(brand)) return brand.length > 0;
return true;
}
export function countLinkedFieldPresent(products) {
return products.filter(hasLinkedField).length;
}
Wire it together and report, never repair automatically
The run loop fetches both sides, counts, classifies, and logs a verdict. There is no write path here on purpose. There is no admin route that creates a pivot table, so a script cannot safely fix a LIKELY_UNMIGRATED_LINK result. The right response is operational: run npx medusa db:sync-links or npx medusa db:migrate against the deployed backend, then run this check again to confirm the count is no longer zero.
This script never writes. DRY_RUN only changes how verbosely it logs, since there is nothing here to turn off. If it reports LIKELY_UNMIGRATED_LINK, the fix is a CLI command against the backend process, not an API call, and definitely not a manual write against the pivot table.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, fetches both sides of the link, classifies the result with a pure function, and logs a clear verdict along with the operational fix when it detects a likely unmigrated link.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Detect a Medusa module link whose pivot table was never synced, safely.
A module link created with defineLink() under src/links/ is backed by its own
pivot table, separate from each module's own migrations. That table is only
created or updated when db:sync-links runs, or as part of db:migrate. A
deploy that runs migrations but skips the link sync, or a link file added
after the last migrate, leaves the link defined in code but the table absent
or stale, so the expanded relation resolves empty for every record even
though the linked module independently has rows.
This script only reads. It lists parent records with the relation expanded,
independently confirms the linked module has data of its own, classifies the
result with a pure function, and reports the verdict. It never touches a
pivot table directly, because there is no admin route that can create one.
When it reports LIKELY_UNMIGRATED_LINK, the fix is to run
`npx medusa db:sync-links` or `npx medusa db:migrate` against the deployed
backend, then run this check again.
Guide: https://www.allanninal.dev/medusa/linked-data-missing-after-link-migration/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_unmigrated_link")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
LINK_DEFINITION_EXISTS_IN_CODE = os.environ.get("LINK_DEFINITION_EXISTS_IN_CODE", "true").lower() == "true"
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def get_products_with_brand(token):
products = []
offset = 0
limit = 100
while True:
r = requests.get(
f"{BACKEND_URL}/admin/products",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,title,*brand", "limit": limit, "offset": offset},
timeout=30,
)
r.raise_for_status()
body = r.json()
products.extend(body["products"])
offset += limit
if offset >= body["count"]:
return products
def get_brands(token):
r = requests.get(
f"{BACKEND_URL}/admin/brands",
headers={"Authorization": f"Bearer {token}"},
params={"limit": 100},
timeout=30,
)
r.raise_for_status()
return r.json()["brands"]
def has_linked_field(product):
brand = product.get("brand")
if brand is None:
return False
if isinstance(brand, list):
return len(brand) > 0
return True
def count_linked_field_present(products):
return sum(1 for p in products if has_linked_field(p))
def detect_unmigrated_link(total_parent_records, parents_with_linked_field_present,
linked_module_has_any_records, link_definition_exists_in_code):
"""Pure decision function. No I/O.
Returns one of "OK", "NO_LINK_DEFINED", "LIKELY_UNMIGRATED_LINK", "LINK_NOT_YET_POPULATED".
"""
if not link_definition_exists_in_code:
return "NO_LINK_DEFINED"
if total_parent_records == 0:
return "OK"
if parents_with_linked_field_present == 0 and linked_module_has_any_records:
return "LIKELY_UNMIGRATED_LINK"
if parents_with_linked_field_present == 0 and not linked_module_has_any_records:
return "LINK_NOT_YET_POPULATED"
return "OK"
def run():
token = get_admin_token()
products = get_products_with_brand(token)
brands = get_brands(token)
total = len(products)
present = count_linked_field_present(products)
linked_module_has_any_records = len(brands) > 0
verdict = detect_unmigrated_link(total, present, linked_module_has_any_records, LINK_DEFINITION_EXISTS_IN_CODE)
log.info("Checked %d product(s), %d with brand present, %d brand record(s) exist.", total, present, len(brands))
log.info("Verdict: %s", verdict)
if verdict == "LIKELY_UNMIGRATED_LINK":
log.warning(
"The brand relation is empty on every product even though brands exist. "
"This looks like the pivot table behind the link was never synced. "
"Run `npx medusa db:sync-links` or `npx medusa db:migrate` against the backend, "
"then run this check again to confirm."
)
elif verdict == "LINK_NOT_YET_POPULATED":
log.info("No brand records exist yet, so an empty relation is expected, not a migration bug.")
elif verdict == "NO_LINK_DEFINED":
log.info("LINK_DEFINITION_EXISTS_IN_CODE is false, so this check does not apply here.")
else:
log.info("Looks fine. At least one product resolved the brand relation.")
return verdict
if __name__ == "__main__":
run()
/**
* Detect a Medusa module link whose pivot table was never synced, safely.
*
* A module link created with defineLink() under src/links/ is backed by its own
* pivot table, separate from each module's own migrations. That table is only
* created or updated when db:sync-links runs, or as part of db:migrate. A
* deploy that runs migrations but skips the link sync, or a link file added
* after the last migrate, leaves the link defined in code but the table absent
* or stale, so the expanded relation resolves empty for every record even
* though the linked module independently has rows.
*
* This script only reads. It lists parent records with the relation expanded,
* independently confirms the linked module has data of its own, classifies the
* result with a pure function, and reports the verdict. It never touches a
* pivot table directly, because there is no admin route that can create one.
* When it reports LIKELY_UNMIGRATED_LINK, the fix is to run
* `npx medusa db:sync-links` or `npx medusa db:migrate` against the deployed
* backend, then run this check again.
*
* Guide: https://www.allanninal.dev/medusa/linked-data-missing-after-link-migration/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const LINK_DEFINITION_EXISTS_IN_CODE = (process.env.LINK_DEFINITION_EXISTS_IN_CODE || "true").toLowerCase() === "true";
export function hasLinkedField(product) {
const brand = product.brand;
if (brand === null || brand === undefined) return false;
if (Array.isArray(brand)) return brand.length > 0;
return true;
}
export function countLinkedFieldPresent(products) {
return products.filter(hasLinkedField).length;
}
export function detectUnmigratedLink({
totalParentRecords,
parentsWithLinkedFieldPresent,
linkedModuleHasAnyRecords,
linkDefinitionExistsInCode,
}) {
if (!linkDefinitionExistsInCode) return "NO_LINK_DEFINED";
if (totalParentRecords === 0) return "OK";
if (parentsWithLinkedFieldPresent === 0 && linkedModuleHasAnyRecords) return "LIKELY_UNMIGRATED_LINK";
if (parentsWithLinkedFieldPresent === 0 && !linkedModuleHasAnyRecords) return "LINK_NOT_YET_POPULATED";
return "OK";
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
return sdk;
}
async function getProductsWithBrand(sdk) {
const products = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.product.list({
fields: "id,title,*brand",
limit,
offset,
});
products.push(...body.products);
offset += limit;
if (offset >= body.count) return products;
}
}
async function getBrands(sdk) {
const res = await sdk.client.fetch("/admin/brands", { query: { limit: 100 } });
return res.brands;
}
export async function run() {
const sdk = await getSdk();
const products = await getProductsWithBrand(sdk);
const brands = await getBrands(sdk);
const total = products.length;
const present = countLinkedFieldPresent(products);
const linkedModuleHasAnyRecords = brands.length > 0;
const verdict = detectUnmigratedLink({
totalParentRecords: total,
parentsWithLinkedFieldPresent: present,
linkedModuleHasAnyRecords,
linkDefinitionExistsInCode: LINK_DEFINITION_EXISTS_IN_CODE,
});
console.log(`Checked ${total} product(s), ${present} with brand present, ${brands.length} brand record(s) exist.`);
console.log(`Verdict: ${verdict}`);
if (verdict === "LIKELY_UNMIGRATED_LINK") {
console.warn(
"The brand relation is empty on every product even though brands exist. " +
"This looks like the pivot table behind the link was never synced. " +
"Run `npx medusa db:sync-links` or `npx medusa db:migrate` against the backend, " +
"then run this check again to confirm."
);
} else if (verdict === "LINK_NOT_YET_POPULATED") {
console.log("No brand records exist yet, so an empty relation is expected, not a migration bug.");
} else if (verdict === "NO_LINK_DEFINED") {
console.log("LINK_DEFINITION_EXISTS_IN_CODE is false, so this check does not apply here.");
} else {
console.log("Looks fine. At least one product resolved the brand relation.");
}
return verdict;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classifier is the part most worth testing, because it decides whether an on-call engineer gets told to run a migration command against production. Because we kept detect_unmigrated_link pure, the test needs no network and no Medusa backend. It just feeds in plain numbers and booleans and checks the answer across all four branches.
from detect_unmigrated_link import detect_unmigrated_link
def test_no_link_defined_wins_over_everything_else():
assert detect_unmigrated_link(10, 0, True, False) == "NO_LINK_DEFINED"
def test_ok_when_no_parent_records_at_all():
assert detect_unmigrated_link(0, 0, True, True) == "OK"
def test_likely_unmigrated_when_all_empty_but_linked_module_has_rows():
assert detect_unmigrated_link(50, 0, True, True) == "LIKELY_UNMIGRATED_LINK"
def test_link_not_yet_populated_when_linked_module_is_also_empty():
assert detect_unmigrated_link(50, 0, False, True) == "LINK_NOT_YET_POPULATED"
def test_ok_when_at_least_one_parent_resolved_the_relation():
assert detect_unmigrated_link(50, 1, True, True) == "OK"
def test_ok_when_every_parent_resolved_the_relation():
assert detect_unmigrated_link(50, 50, True, True) == "OK"
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectUnmigratedLink } from "./detect-unmigrated-link.js";
test("no link defined wins over everything else", () => {
assert.equal(
detectUnmigratedLink({ totalParentRecords: 10, parentsWithLinkedFieldPresent: 0, linkedModuleHasAnyRecords: true, linkDefinitionExistsInCode: false }),
"NO_LINK_DEFINED",
);
});
test("OK when no parent records at all", () => {
assert.equal(
detectUnmigratedLink({ totalParentRecords: 0, parentsWithLinkedFieldPresent: 0, linkedModuleHasAnyRecords: true, linkDefinitionExistsInCode: true }),
"OK",
);
});
test("likely unmigrated when all empty but linked module has rows", () => {
assert.equal(
detectUnmigratedLink({ totalParentRecords: 50, parentsWithLinkedFieldPresent: 0, linkedModuleHasAnyRecords: true, linkDefinitionExistsInCode: true }),
"LIKELY_UNMIGRATED_LINK",
);
});
test("link not yet populated when linked module is also empty", () => {
assert.equal(
detectUnmigratedLink({ totalParentRecords: 50, parentsWithLinkedFieldPresent: 0, linkedModuleHasAnyRecords: false, linkDefinitionExistsInCode: true }),
"LINK_NOT_YET_POPULATED",
);
});
test("OK when at least one parent resolved the relation", () => {
assert.equal(
detectUnmigratedLink({ totalParentRecords: 50, parentsWithLinkedFieldPresent: 1, linkedModuleHasAnyRecords: true, linkDefinitionExistsInCode: true }),
"OK",
);
});
test("OK when every parent resolved the relation", () => {
assert.equal(
detectUnmigratedLink({ totalParentRecords: 50, parentsWithLinkedFieldPresent: 50, linkedModuleHasAnyRecords: true, linkDefinitionExistsInCode: true }),
"OK",
);
});
Case studies
The deploy that migrated but never synced links
A team added a new defineLink() between the Product module and a custom Brand module, tested it locally where a full db:migrate had synced everything, and shipped it. Their production CI ran db:migrate too, but a pipeline refactor months earlier had quietly split link syncing into a separate step that nobody re-added. Every product in the storefront rendered with no brand, and the team spent an afternoon checking the Brand module's own data before noticing the pattern was total, not partial.
Running the detection script confirmed it in one call, brands existed, but zero of a hundred products resolved the relation. Running npx medusa db:sync-links against the backend fixed it in under a minute, no code changes needed.
The database snapshot that predated the link
After a rollback restored a database snapshot from before a link was introduced, forward migrations ran cleanly, but the pivot table for a newer defineLink() was never recreated because the rollback runbook only mentioned schema migrations, not link sync.
The detection script flagged LIKELY_UNMIGRATED_LINK immediately after the rollback, well before a customer noticed missing data on the storefront. A single db:migrate run brought the pivot table back, and a re-run of the check confirmed the relation resolved data again.
After this check runs, either the relation resolves data for at least some records, or you have a precise verdict telling you exactly what to run next. Nobody spends an afternoon second-guessing whether the Brand module itself is broken, because the script already ruled that out. And nothing gets patched by hand against a pivot table, since the actual fix is always the same short CLI command against the backend.
FAQ
Why does my Medusa module link always return an empty relation?
A module link created with defineLink() is backed by its own pivot table, separate from each module's own migrations. That table is only created or updated when db:sync-links runs, or as part of db:migrate. If a deploy runs migrations but skips the link sync, or the link file was added after the last migrate, the pivot table is missing or stale, so remote query expansions for that relation resolve empty for every record even though both linked modules independently have data.
Can I fix a missing link pivot table from the Admin API?
No. There is no admin route that creates or repairs a pivot table, because that requires running a migration command against the server process itself. The correct fix is operational: run npx medusa db:sync-links, or npx medusa db:migrate, against the deployed backend, then re-check the relation to confirm it is no longer empty.
How do I tell an unmigrated link apart from data that simply is not linked yet?
Compare two independent counts. If the linked module has records of its own but every parent record resolves the expanded relation as empty, that is the classic symptom of a missing pivot table. If the linked module itself has zero records, there is nothing to link yet, and that is not a migration bug, it just has not been populated.
Related field notes
Citations
On the problem:
- Medusa Documentation: Define Module Link. docs.medusajs.com/learn/fundamentals/module-links
- GitHub Issue: Linking not working in latest version. github.com/medusajs/medusa/issues/10798
- Medusa Documentation: Guide, Define Module Link Between Brand and Product. docs.medusajs.com/learn/customization/extend-features/define-link
On the solution:
- Medusa CLI Reference: db commands, including sync-links and migrate. docs.medusajs.com/resources/medusa-cli/commands/db
- Medusa Documentation: Guide, Query Product's Brands. docs.medusajs.com/learn/customization/extend-features/query-linked-records
- Medusa Documentation: Retrieve Custom Links from Medusa's API Route. docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this save you a confusing afternoon?
If this saved you from chasing a phantom data bug that was really a missed migration step, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi