- Extend Medusa Modules Safely: Add Fields Without Editing
Extend Medusa Modules Safely: Add Fields Without Editing
Step-by-step guide to add ProductTechSpec data via custom module, module link, workflow, admin route and widget

📚 Get Practical Development Guides
Join developers getting comprehensive guides, code examples, optimization tips, and time-saving prompts to accelerate their development workflow.
How to Extend Native Medusa Modules Without Editing Them
This guide shows the Medusa-native way to add extra fields to built-in resources such as product, customer, or order.
The short version is:
- you do not edit Medusa's native module schema directly
- you create your own custom module
- you link it to the native resource
- you expose it through workflows and API routes
- you surface it in the admin with a widget or custom page
We follow the exact path we used in this repo to extend Medusa product with a ProductTechSpec model.
The Core Idea
If you come from Prisma, Rails, Laravel, or a typical monolith, your first instinct is often:
"I just want to add some extra columns to
product."
That is not how Medusa wants you to think.
Medusa treats its core commerce modules as isolated building blocks. The point is not that they are "untouchable" in some magical sense. The point is that Medusa wants custom business data to live in your module, while the native model stays the canonical commerce entity.
So for a product extension:
productremains the canonical commerce record- your custom model owns your extra domain data
- a module link connects the two
In Medusa terms, this is an extension, not an in-place mutation of the native schema.
This matches the repo's Medusa backend guidance in .agents/skills/building-with-medusa/reference/module-links.md, which explicitly calls out links as the correct way to "extend commerce entities".
Why Medusa Works This Way
This design gives you a few important benefits:
- Module isolation
Your custom logic stays in your own module instead of being mixed into Medusa internals.
- Clear ownership
product owns commerce behavior.
Your extension model owns the custom business meaning, such as:
- technical specifications
- brands
- reviews
- compatibility data
- ERP sync metadata
- Safer upgrades
You are not patching Medusa's internal product schema and hoping upgrades still work.
- Reusable extension pattern
Once you understand custom module + link + workflow + route + widget, you can apply the same pattern to customers, orders, carts, price lists, or any other native resource.
When to Use Metadata Instead
Before we build a full linked model, there is one important nuance:
If your extra data is:
- small
- loosely structured
- rarely queried
- not business-critical
then metadata may be enough.
For example:
- a marketing label
- a temporary external reference
- a simple boolean feature flag
But if the data is:
- structured
- validated
- large
- edited by staff
- shown in custom UI
- important for business logic
then a proper linked model is the better choice.
Technical specifications are a good example of data that should usually be a linked model, not just product.metadata.
What We Built
In this repo, we extended native Medusa products with a new model:
ProductTechSpec
It stores structured technical and engineering information such as:
- product family
- equipment type
- manufacturer
- series
- model code
- warranty
- compliance JSON
- electrical JSON
- mechanical JSON
- service JSON
- compatibility JSON
- variant-specific specs JSON
And we linked it to the native Medusa product.
That implementation lives here:
- model:
apps/medusa-backend/src/modules/product-tech-spec/models/product-tech-spec.ts - module:
apps/medusa-backend/src/modules/product-tech-spec/ - link:
apps/medusa-backend/src/links/product-tech-spec-product.ts - workflow:
apps/medusa-backend/src/workflows/product-tech-spec/ - admin route:
apps/medusa-backend/src/api/admin/products/[id]/tech-spec/ - admin widget:
apps/medusa-backend/src/admin/widgets/product-tech-spec.tsx
The Pattern
This is the architecture we followed:
Native Medusa Product + Custom ProductTechSpec Module + Module Link + Workflow for mutations + Admin API route + Admin widget on product details page
That is the Medusa-native answer to:
"How do I add extra fields to product?"
Step 1: Create a Custom Module
The first step is not a route and not a widget.
It is always the data model.
We created a new module:
apps/medusa-backend/src/modules/product-tech-spec/
with a model like this:
// apps/medusa-backend/src/modules/product-tech-spec/models/product-tech-spec.ts
import { model } from "@medusajs/framework/utils"
const ProductTechSpec = model.define("product_tech_spec", {
id: model.id().primaryKey(),
product_id: model.text().index().unique(),
product_family: model.enum([
"generator_set",
"automatic_transfer_switch",
"controller",
"monitoring_module",
"installation_accessory",
"service_kit",
"spare_part",
]),
equipment_type: model.text().nullable(),
manufacturer: model.text().nullable(),
series: model.text().nullable(),
model_code: model.text().nullable(),
summary: model.text().nullable(),
datasheet_url: model.text().nullable(),
warranty_months: model.number().nullable(),
compliance: model.json().nullable(),
electrical: model.json().nullable(),
mechanical: model.json().nullable(),
service: model.json().nullable(),
compatibility: model.json().nullable(),
variant_specs: model.json().nullable(),
metadata: model.json().nullable(),
})
export default ProductTechSpec
Why include product_id?
Strictly speaking, the link itself is the real association.
But keeping product_id in the model is still practical when your custom record semantically "belongs to" the product and you want easy lookup or uniqueness at your own module layer.
This is also consistent with the repo's Medusa link guidance.
Step 2: Add the Service and Module Export
Medusa modules need a service and an index export.
// apps/medusa-backend/src/modules/product-tech-spec/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import ProductTechSpec from "./models/product-tech-spec"
class ProductTechSpecModuleService extends MedusaService({
ProductTechSpec,
}) {}
export default ProductTechSpecModuleService
// apps/medusa-backend/src/modules/product-tech-spec/index.ts
import { Module } from "@medusajs/framework/utils"
import ProductTechSpecModuleService from "./service"
export const PRODUCT_TECH_SPEC_MODULE = "productTechSpec"
export default Module(PRODUCT_TECH_SPEC_MODULE, {
service: ProductTechSpecModuleService,
})
Important rule
Use a camelCase module name.
This is correct:
"productTechSpec"
This is wrong:
"product-tech-spec"
Dashed module names tend to create runtime pain in Medusa.
Step 3: Register the Module
Add it to medusa-config.ts:
// apps/medusa-backend/medusa-config.ts
modules: [
{ resolve: "./src/modules/pickup-scheduling" },
{ resolve: "./src/modules/b2b" },
{ resolve: "./src/modules/review" },
{ resolve: "./src/modules/product-tech-spec" },
]
Do this before generating migrations.
Step 4: Define the Module Link
This is the critical piece.
We are not modifying the native product model. We are defining an association between our custom model and Medusa's native product.
// apps/medusa-backend/src/links/product-tech-spec-product.ts
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import ProductTechSpecModule from "../modules/product-tech-spec"
export default defineLink(
ProductTechSpecModule.linkable.productTechSpec,
ProductModule.linkable.product
)
Two important Medusa rules here
- Create one link per file
- The order matters later when you create or dismiss links
In our case the direction is:
productTechSpec -> product
That same order must be respected when using the link API.
Step 5: Generate and Run Migrations
This step is easy to skip if you are moving fast.
Do not skip it.
After adding the module:
pnpm exec medusa db:generate productTechSpec
pnpm exec medusa db:migrate
What this does:
- creates the
product_tech_spectable - syncs the module schema
- creates the link table for
productTechSpec <> product
Without this, the code compiles but the relationship does not exist in the database.
Step 6: Create a Workflow for Mutations
In this repo, we used an upsert workflow so the admin can create or update the tech spec through a single endpoint.
The workflow is:
// apps/medusa-backend/src/workflows/product-tech-spec/upsert-product-tech-spec.ts
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { upsertProductTechSpecStep } from "./steps/upsert-product-tech-spec"
export const upsertProductTechSpecWorkflow = createWorkflow(
"upsert-product-tech-spec",
function (input) {
const productTechSpec = upsertProductTechSpecStep(input)
return new WorkflowResponse(productTechSpec)
}
)
And the step resolves:
- the native product
- the custom module service
- the Medusa link service
Then it:
- verifies the product exists before writing anything
- creates or updates the
ProductTechSpec - dismisses and recreates the module link on update
- creates the module link on first insert
- defines compensation so the workflow can roll back failed writes cleanly
- returns the saved record
In other words, the workflow step is not just "save a row".
It is the place where the real mutation orchestration lives:
- native product existence check
- extension record create or update
- link synchronization
- rollback behavior
Why use a workflow?
Because in Medusa, mutations belong in workflows.
Routes should not contain business logic and direct mutation orchestration when a workflow can own it.
This keeps the architecture clean:
module -> workflow -> route -> admin UI
Step 7: Add Admin API Routes
Once the module exists, you need a way for the admin UI to read and update it.
We added:
/admin/products/:id/tech-spec
with:
GETto load the current tech specPOSTto upsert it
Route:
// apps/medusa-backend/src/api/admin/products/[id]/tech-spec/route.ts
export const GET = async (req, res) => {
const productTechSpecModule = req.scope.resolve(PRODUCT_TECH_SPEC_MODULE)
const [productTechSpec] = await productTechSpecModule.listProductTechSpecs(
{ product_id: req.params.id },
{ take: 1 }
)
res.json({ product_tech_spec: productTechSpec ?? null })
}
export const POST = async (req, res) => {
const { result } = await upsertProductTechSpecWorkflow(req.scope).run({
input: {
product_id: req.params.id,
...req.validatedBody,
},
})
res.status(200).json({
product_tech_spec: result,
})
}
And route-specific middleware registration:
// apps/medusa-backend/src/api/admin/products/[id]/tech-spec/middlewares.ts
import {
authenticate,
validateAndTransformBody,
} from "@medusajs/framework/http"
In the actual implementation, that file does three things:
- defines the Zod body schema
- exports the inferred TypeScript type used by
AuthenticatedMedusaRequest<T> - exports a
MiddlewareRoute[]array for theGETandPOSThandlers
That validates the payload and ensures only authenticated admin users can call it.
Important extra step: register those middlewares globally
This is the part that is easy to miss.
Creating apps/medusa-backend/src/api/admin/products/[id]/tech-spec/middlewares.ts is not enough by itself.
You must also import and spread that middleware array into the app-wide middleware registry:
// apps/medusa-backend/src/api/middlewares.ts
import { adminProductTechSpecMiddlewares } from "./admin/products/[id]/tech-spec/middlewares"
export default defineMiddlewares({
routes: [
...adminProductTechSpecMiddlewares,
],
})
Without that global registration step, your route file exists, but Medusa will not apply the auth and body validation middleware to /admin/products/:id/tech-spec.
Step 8: Add the Admin Widget
This is the part people often think is the extension mechanism.
It is not.
The widget is only the UI layer.
The real extension already happened in the backend through:
- custom module
- link
- workflow
- route
The widget simply makes the linked data editable inside the Medusa admin.
We added:
apps/medusa-backend/src/admin/widgets/product-tech-spec.tsx
and mounted it in:
export const config = defineWidgetConfig({
zone: "product.details.after",
})
That means the widget appears on the product detail page.
What the widget does
- fetches the tech spec on mount
- shows a summary on the product page
- opens a
Drawerto create or edit the tech spec - sends the payload to
/admin/products/:id/tech-spec
This follows the repo's admin pattern:
- display query loads immediately
- mutation calls custom admin route through the Medusa JS SDK
- cache is invalidated after save
Step 9: Seed Realistic Data
A guide is more useful when people can see real data in the UI.
So we also seeded ProductTechSpec records for the existing product catalog.
The seed source lives here:
apps/medusa-backend/src/scripts/seed/data/product-tech-specs.ts
and the catalog seed now creates linked tech specs after product creation.
That gives you believable examples for:
- standby generators
- prime generators
- ATS products
- controllers
- monitoring modules
- accessories
- service kits
- spare parts
This makes the guide easier to follow because the resulting UI is not empty.
What This Means Conceptually
At this point the pattern should be clear:
You are not doing this
Edit native product schema directly
You are doing this
Keep native product canonical Add ProductTechSpec as custom domain data Link ProductTechSpec to product Read/write through workflow + route Render in admin widget
That is the Medusa extension model.
Full Step-by-Step Summary
If you want the short operational checklist, this is it:
- Create a custom model for the data you want to add
- Wrap it in a Medusa module and service
- Register the module in
medusa-config.ts - Define a module link between your custom model and the native Medusa model
- Run
medusa db:generate <moduleName> - Run
medusa db:migrate - Create a workflow for create/update/delete mutations
- Add admin or store API routes
- Add an admin widget or custom page
- Seed sample data so you can verify the result quickly
Common Mistakes
Mistake 1: Trying to add random fields directly to native product
This fights Medusa's architecture.
Use a custom module plus link instead.
Mistake 2: Building only the widget
A widget is not a backend extension.
If the data model and route do not exist, the widget is just UI with nowhere real to store data.
Mistake 3: Forgetting migrations
If you define the model and link but do not run migrations, the relationship does not exist in the database.
Mistake 4: Putting mutation logic directly in the route
Use workflows for mutations.
Mistake 5: Treating metadata like a replacement for a real model
metadata is useful, but it is not a substitute for structured extension data when the shape matters.
When This Pattern Is the Right Choice
Use this pattern when you need to extend native Medusa modules with:
- technical specifications
- engineering data
- ERP attributes
- compliance data
- certification details
- B2B-specific state
- compatibility relationships
- review or enrichment models
If your extra data deserves its own name and lifecycle, it probably deserves its own module.
Final Takeaway
The correct mental model for Medusa is:
You do not usually customize native commerce models by editing them in place.
You extend them by composing around them.
For products, that means:
- native
productstays canonical - your custom model stores the extra meaning
- a module link joins them
- workflows and routes manage them
- widgets surface them in admin
That is exactly what we built with ProductTechSpec, and it is the pattern you should reuse whenever you want to extend Medusa without breaking its architecture.
Related Files in This Repo
If you want to inspect the working implementation directly, start here:
apps/medusa-backend/src/modules/product-tech-spec/models/product-tech-spec.tsapps/medusa-backend/src/modules/product-tech-spec/index.tsapps/medusa-backend/src/links/product-tech-spec-product.tsapps/medusa-backend/src/workflows/product-tech-spec/upsert-product-tech-spec.tsapps/medusa-backend/src/api/admin/products/[id]/tech-spec/route.tsapps/medusa-backend/src/admin/widgets/product-tech-spec.tsxapps/medusa-backend/src/scripts/seed/data/product-tech-specs.ts
If you want, the next article can be the follow-up:
How to Move from JSON-Based Tech Specs to Fully Structured Variant-Level Medusa Extensions
Frequently Asked Questions
Comments
No comments yet
Be the first to share your thoughts on this post!