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?
| Situation | Build |
|---|---|
| Your store's loyalty rules | A module |
| A Klaviyo integration you use on every client | A plugin |
| One client's ERP connector | A module |
| A generic ERP connector with per-client config | A plugin |
| Admin widget showing your own KPI | A 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:
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.jsonScaffold with the CLI rather than assembling this by hand:
npx create-medusa-app@latest --plugin my-pluginThen develop against a real application:
# in the plugin
npx medusa plugin:develop
# in the consuming app
npx medusa plugin:add @your-org/my-pluginThat 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:
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:
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:
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:
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 ProductReviewsWidgetUse @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
{
"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:
| Change | Breaking? |
|---|---|
| Renaming an option | Yes |
| Making an optional option required | Yes |
| Changing a default value | Yes, usually |
| Adding a migration that drops a column | Yes |
| Changing an emitted event's name | Yes |
| Adding a new optional option | No |
| Adding a new event | No |
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 requiredbeats 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:migrateis 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:
- One sentence on what it does.
- Install and minimal config — the smallest thing that works, in one block.
- A screenshot if it adds admin UI. People decide from this.
- Full option reference as a table with types and defaults.
- Migration note — that
medusa db:migrateis required. - Events emitted, so consumers can extend without forking.
- 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.
Medusa Project Structure: Where Every Kind of Code Belongs
Medusa loads code by convention. A directory-by-directory guide to what goes where, and the decision tree for placing any new piece of logic.
Customising the Medusa Admin: Widgets, Routes and UI Extensions
The dashboard is a React app you can extend. Injecting widgets into existing pages, adding new routes, and the conventions that keep extensions from looking bolted on.
The Medusa Container: Dependency Injection Without the Ceremony
Everything in Medusa is resolved from a container. What is registered, how scoping works per request, and how to avoid the two mistakes that cause leaks.



