---
title: "Extend Medusa Modules Safely: Add Fields Without Editing"
slug: "extend-medusa-modules-without-editing"
published: "2026-04-30"
updated: "2026-05-02"
categories:
  - "Medusa.js"
tags:
  - "extend Medusa modules"
  - "ProductTechSpec"
  - "Medusa module link"
  - "Medusa workflow"
  - "admin widget"
  - "medusa migrations"
  - "extend product fields"
  - "Medusa backend extension"
  - "module link pattern"
  - "admin route"
  - "custom module Medusa"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "medusa"
  - "@medusajs/framework"
  - "typescript"
  - "node.js"
  - "pnpm"
status: "stable"
llm-purpose: "Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…"
llm-prereqs:
  - "Access to Medusa"
  - "Access to @medusajs/framework"
  - "Access to TypeScript"
  - "Access to Node.js"
  - "Access to pnpm"
llm-outputs:
  - "Completed outcome: Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…"
---

**Summary Triples**
- (Native Medusa schema, should_not_be_edited, Preserve as canonical commerce model; add custom data in your own module)
- (Custom module, owns, extra domain data (e.g., ProductTechSpec))
- (Module link, connects, custom module record to a native resource (e.g., ProductTechSpec -> product))
- (ProductTechSpec table, is_added_via, a migration in your custom module (separate DB table))
- (Link column, uses, a foreign key (e.g., product_id) referencing product.id to associate records)
- (Workflows, expose, operations that create/update extension data during product lifecycle)
- (API routes, are_created_to, CRUD-manage extension data and surface it to clients/admin)
- (Admin widget/route, surfaces, ProductTechSpec data inside the Medusa Admin UI without changing core admin)
- (Validation, should_be_done_with, Zod (or similar) schemas in module routes/workflows)
- (Benefits, include, module isolation, clear ownership, easier upgrades of Medusa core)

### {GOAL}
Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…

### {PREREQS}
- Access to Medusa
- Access to @medusajs/framework
- Access to TypeScript
- Access to Node.js
- Access to pnpm

### {STEPS}
1. Create custom ProductTechSpec module
2. Add service and index exports
3. Register the module in medusa-config
4. Define a module link to product
5. Generate and run migrations
6. Implement a workflow for mutations
7. Add admin API routes and middlewares
8. Create admin widget to surface data
9. Seed realistic ProductTechSpec data

<!-- llm:goal="Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…" -->
<!-- llm:prereq="Access to Medusa" -->
<!-- llm:prereq="Access to @medusajs/framework" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to pnpm" -->
<!-- llm:output="Completed outcome: Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…" -->

# Extend Medusa Modules Safely: Add Fields Without Editing
> Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…
Matija Žiberna · 2026-04-30

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

2. **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

3. **Safer upgrades**

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

4. **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:

```text
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:

```text
apps/medusa-backend/src/modules/product-tech-spec/
```

with a model like this:

```ts
// 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.

```ts
// 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
```

```ts
// 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:

```ts
"productTechSpec"
```

This is wrong:

```ts
"product-tech-spec"
```

Dashed module names tend to create runtime pain in Medusa.

---

## Step 3: Register the Module

Add it to `medusa-config.ts`:

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

```ts
// 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:

```text
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:

```bash
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:

```ts
// 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:

```text
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:

```text
/admin/products/:id/tech-spec
```

with:

- `GET` to load the current tech spec
- `POST` to upsert it

Route:

```ts
// 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 })
}
```

```ts
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:

```ts
// 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:

```ts
// 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:

```text
apps/medusa-backend/src/admin/widgets/product-tech-spec.tsx
```

and mounted it in:

```ts
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:

```text
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

```text
Edit native product schema directly
```

### You are doing this

```text
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**

## LLM Response Snippet
```json
{
  "goal": "Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…",
  "responses": [
    {
      "question": "What does the article \"Extend Medusa Modules Safely: Add Fields Without Editing\" cover?",
      "answer": "Extend Medusa modules: add ProductTechSpec fields without editing core schemas — create a module, link to product, run migrations, add workflows, admin…"
    }
  ]
}
```