BuildWithMatija
Get In Touch
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
  1. Home
  2. Blog
  3. Medusa.js
  4. 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

30th April 2026·Updated on:2nd May 2026·MŽMatija Žiberna·
Medusa.js
Extend Medusa Modules Safely: Add Fields Without Editing

📚 Get Practical Development Guides

Join developers getting comprehensive guides, code examples, optimization tips, and time-saving prompts to accelerate their development workflow.

No spam. Unsubscribe anytime.

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:

  • product remains 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:

  1. Module isolation

Your custom logic stays in your own module instead of being mixed into Medusa internals.

  1. 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
  1. Safer upgrades

You are not patching Medusa's internal product schema and hoping upgrades still work.

  1. 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

  1. Create one link per file
  2. 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_spec table
  • 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:

  • GET to load the current tech spec
  • POST to 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 the GET and POST handlers

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 Drawer to 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:

  1. Create a custom model for the data you want to add
  2. Wrap it in a Medusa module and service
  3. Register the module in medusa-config.ts
  4. Define a module link between your custom model and the native Medusa model
  5. Run medusa db:generate <moduleName>
  6. Run medusa db:migrate
  7. Create a workflow for create/update/delete mutations
  8. Add admin or store API routes
  9. Add an admin widget or custom page
  10. 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 product stays 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.ts
  • apps/medusa-backend/src/modules/product-tech-spec/index.ts
  • apps/medusa-backend/src/links/product-tech-spec-product.ts
  • apps/medusa-backend/src/workflows/product-tech-spec/upsert-product-tech-spec.ts
  • apps/medusa-backend/src/api/admin/products/[id]/tech-spec/route.ts
  • apps/medusa-backend/src/admin/widgets/product-tech-spec.tsx
  • apps/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

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

No comments yet

Be the first to share your thoughts on this post!

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Table of Contents

  • How to Extend Native Medusa Modules Without Editing Them
  • The Core Idea
  • Why Medusa Works This Way
  • When to Use Metadata Instead
  • What We Built
  • The Pattern
  • Step 1: Create a Custom Module
  • Why include `product_id`?
  • Step 2: Add the Service and Module Export
  • Important rule
  • Step 3: Register the Module
  • Step 4: Define the Module Link
  • Two important Medusa rules here
  • Step 5: Generate and Run Migrations
  • Step 6: Create a Workflow for Mutations
  • Why use a workflow?
  • Step 7: Add Admin API Routes
  • Important extra step: register those middlewares globally
  • Step 8: Add the Admin Widget
  • What the widget does
  • Step 9: Seed Realistic Data
  • What This Means Conceptually
  • You are not doing this
  • You are doing this
  • Full Step-by-Step Summary
  • Common Mistakes
  • Mistake 1: Trying to add random fields directly to native product
  • Mistake 2: Building only the widget
  • Mistake 3: Forgetting migrations
  • Mistake 4: Putting mutation logic directly in the route
  • Mistake 5: Treating `metadata` like a replacement for a real model
  • When This Pattern Is the Right Choice
  • Final Takeaway
  • Related Files in This Repo
On this page:
  • How to Extend Native Medusa Modules Without Editing Them
  • The Core Idea
  • Why Medusa Works This Way
  • When to Use Metadata Instead
  • What We Built