3 MIN READ

Medusa Plugins: Building and Publishing Reusable Commerce Packages

When a module should become a plugin, how the package is structured, and what changes when your code has to run in someone else's application.

BY RAHUL MEHTAUPDATED
Illustration for “Medusa Plugins: Building and Publishing Reusable Commerce Packages” — Plugins

A module is code that lives in your application. A plugin is code that lives in someone else's. That difference sounds administrative and is actually the whole design problem: everything a plugin touches has to be configurable, because you do not know what the host application looks like.

When a plugin is the right shape

Honest test: are you going to install this in a second project within six months?

SituationBuild
Your store's loyalty rulesA module
A Klaviyo integration you use on every clientA plugin
One client's ERP connectorA module
A generic ERP connector with per-client configA plugin
Admin widget showing your own KPIA module + admin extension

Premature plugin-isation is a real cost. You pay for configurability, documentation and versioning before you know which parts actually vary. Build the module, use it, and extract the plugin when the second project asks for it.

What a plugin can contain

Effectively everything a Medusa application can:

text
my-plugin/
├── src/
│   ├── modules/           # commerce domains
│   ├── workflows/         # business processes
│   ├── api/               # routes, both admin and store
│   ├── subscribers/       # event handlers
│   ├── jobs/              # scheduled work
│   ├── links/             # links to core entities
│   └── admin/             # dashboard widgets and routes
├── package.json
└── tsconfig.json

Scaffold with the CLI rather than assembling this by hand:

bash
npx create-medusa-app@latest --plugin my-plugin

Then develop against a real application:

bash
# in the plugin
npx medusa plugin:develop

# in the consuming app
npx medusa plugin:add @your-org/my-plugin

That link-based loop is worth setting up early; publishing to npm to test a change gets old quickly.

Options are the public API

Everything environment-specific arrives through options:

medusa-config.ts (consuming app)ts
module.exports = defineConfig({
  plugins: [
    {
      resolve: "@your-org/medusa-plugin-reviews",
      options: {
        apiKey: process.env.REVIEWS_API_KEY,
        autoPublish: false,
        moderationEmail: "reviews@example.com",
      },
    },
  ],
})

And are received in the module service constructor:

src/modules/reviews/service.tsts
import { MedusaService } from "@medusajs/framework/utils"
import { Review } from "./models/review"

type Options = {
  apiKey: string
  autoPublish?: boolean
  moderationEmail?: string
}

export default class ReviewsModuleService extends MedusaService({ Review }) {
  protected options_: Options

  constructor({}, options: Options) {
    super(...arguments)

    if (!options?.apiKey) {
      throw new Error("[reviews] `apiKey` is required")
    }

    this.options_ = { autoPublish: false, ...options }
  }
}

Two rules that separate a good plugin from an irritating one. Validate options in the constructor — failing at boot with a clear message beats failing at 2am with undefined is not a function. And default everything that can be defaulted, so the minimal configuration is genuinely minimal.

Never extend the host's data model

The most common plugin mistake is assuming the host application's schema. Your reviews plugin does not know whether products have a rating field, and it must not add one.

Link instead:

src/links/product-review.tsts
import ProductModule from "@medusajs/medusa/product"
import ReviewsModule from "../modules/reviews"
import { defineLink } from "@medusajs/framework/utils"

export default defineLink(
  ProductModule.linkable.product,
  {
    linkable: ReviewsModule.linkable.review,
    isList: true,
  }
)

Now the host can query product.reviews.* through the query layer without your plugin having touched their tables.

Admin extensions

Plugins can contribute dashboard UI. A widget injected into an existing page:

src/admin/widgets/product-reviews.tsxtsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { Container, Heading } from "@medusajs/ui"

const ProductReviewsWidget = () => (
  <Container className="divide-y p-0">
    <div className="flex items-center justify-between px-6 py-4">
      <Heading level="h2">Reviews</Heading>
    </div>
  </Container>
)

export const config = defineWidgetConfig({
  zone: "product.details.after",
})

export default ProductReviewsWidget

Use @medusajs/ui components rather than your own styling. A plugin that looks foreign inside the dashboard reads as broken even when it works. More in customising the Medusa admin.

Publishing and versioning

package.jsonjson
{
  "name": "@your-org/medusa-plugin-reviews",
  "version": "1.0.0",
  "keywords": ["medusa-plugin", "medusa-v2"],
  "peerDependencies": {
    "@medusajs/framework": "^2.0.0"
  }
}

Medusa goes in peerDependencies, never dependencies — two copies of the framework in one process is a genuinely bad afternoon. Semver applies to your options and your exported types: renaming an option is a breaking change even if nothing else moved.

Ship migrations with the plugin and document that consumers must run medusa db:migrate after installing.

Practical guidance

Document the minimal config first. The README's first code block should be the smallest thing that works.

Log with a prefix. [reviews] in every message. Consumers debugging their application should immediately see whose code is talking.

Do not swallow errors. A plugin that silently no-ops on a bad credential wastes hours of someone's day.

Test against a real application. Unit tests on the service are not enough; install it into a scaffolded app and run the flows.

Versioning and breaking changes

A plugin's public surface is larger than its exported functions. All of these are breaking changes even when no exported symbol moved:

ChangeBreaking?
Renaming an optionYes
Making an optional option requiredYes
Changing a default valueYes, usually
Adding a migration that drops a columnYes
Changing an emitted event's nameYes
Adding a new optional optionNo
Adding a new eventNo

Publish a migration note with every major version, and keep the previous major receiving fixes for a reasonable window. Consumers upgrade plugins during a busy week or not at all.

What makes a plugin pleasant to install

We have installed enough of these to have opinions:

  • The README's first block is the minimal working config. Not the exhaustive option table — that comes after.
  • Errors name the plugin. [reviews] apiKey is required beats a stack trace into an anonymous module.
  • It fails at boot, not at runtime. Validate options in the constructor.
  • Migrations are documented. State plainly that medusa db:migrate is required after install.
  • It does nothing until configured. A plugin that starts polling an API on boot because a default was truthy is a bad neighbour.
  • Peer dependencies, not dependencies. Two copies of the framework in one process is a bad afternoon.

Documenting a plugin

The README is the product for anyone evaluating it. What ours contain, in order:

  1. One sentence on what it does.
  2. Install and minimal config — the smallest thing that works, in one block.
  3. A screenshot if it adds admin UI. People decide from this.
  4. Full option reference as a table with types and defaults.
  5. Migration note — that medusa db:migrate is required.
  6. Events emitted, so consumers can extend without forking.
  7. Compatibility — which Medusa versions.

The order matters as much as the content. An evaluator who has to read three paragraphs before finding the install command usually goes and looks at the alternative.


We build plugins for our own repeat integrations and occasionally for clients who want to own one. Ask if that is useful.

Frequently asked questions

What is the difference between a Medusa plugin and a module?

A module is a commerce domain inside a single application. A plugin is a distributable npm package that can contain modules, workflows, API routes, subscribers and admin extensions, installed across multiple projects. Extract a plugin from a working module rather than starting with one.

How do I install a Medusa plugin?

Install the package, then add it to the `plugins` array in `medusa-config.ts` with any required options, and run `npx medusa db:migrate` if it ships migrations. During development, `medusa plugin:develop` and `medusa plugin:add` give you a live-linked loop without publishing.

Can a plugin add fields to Medusa's core entities?

It should not. A plugin cannot assume the host's schema. Define your own models in the plugin's module and relate them to core entities with a module link, which gives consumers queryable nested data without modifying their tables.

Do Medusa v1 plugins work in v2?

No. The architecture changed substantially — modules, workflows and links replaced v1's entity and service inheritance. Version 1 plugins need rewriting rather than porting.

How should plugin configuration be handled?

Through the options object passed in `medusa-config.ts` and received in the module service constructor. Validate required options at construction so misconfiguration fails at boot with a clear message, and default everything optional.

Can a plugin add pages to the admin dashboard?

Yes. Files under `src/admin/widgets` inject components into existing pages via a zone, and files under `src/admin/routes` add entirely new pages with their own navigation entries. Build them with `@medusajs/ui` so they match the rest of the dashboard.

[ Keep reading ]