Most Medusa v2 content is launch hype. This is what actually happens when you migrate a production store with custom plugins, 4,200 products, and real orders flowing through it.
Medusa.js v1 dropped in October 2021. I had a DTC apparel client sitting on WooCommerce who was hitting the ceiling — multi-currency was a mess, the plugin ecosystem was brittle, and every customisation felt like fighting the platform. I picked up Medusa within weeks of that first release and never looked back.
Three years later, Medusa 2.0 shipped. And I had to migrate everything I'd built.
This isn't a tutorial. It's what actually happened.
Why I Bet on Medusa v1 Early
In late 2021, if you wanted headless e-commerce, your options were Shopify's Storefront API (expensive, limited customisation), Saleor (complex GraphQL-heavy setup), or rolling your own. Medusa was different: Node.js, PostgreSQL, dead-simple plugin system, and you could actually read the source code and understand what was happening.
The first project was a DTC apparel brand moving off WooCommerce. I wrote custom plugins for multi-currency pricing and a loyalty points module, built a Python ETL pipeline that moved 4,200 products and full order history with zero downtime, and deployed on Railway with the PostgreSQL add-on, storefront on Vercel.
It worked. Surprisingly well for a v1 framework.
What v1 Was Like in Production
The plugin system was straightforward. You'd extend services, add custom endpoints, and subscribe to events. The mental model was clear:
// v1 plugin: extend a core service
class MyPricingService extends TransactionBaseService {
async calculatePrice(variantId, context) {
const variant = await this.productVariantService_.retrieve(variantId)
// multi-currency logic here
return adjustedPrice
}
}But it had real limitations:
- Tight coupling. Plugins reached deep into core services. When Medusa updated internals, plugins broke silently.
- No module boundaries. Everything shared one dependency container. Two plugins touching the same service could conflict in ways that were hard to debug.
- Migration pain. Database migrations from plugins were fragile. One bad migration on a production store and you're hand-writing SQL at 2 AM.
I built a tiered B2B pricing engine, multi-vendor catalogs, and split-payment order routing on v1. All of it worked, but all of it was coupled to internal APIs that I knew would change.
When the v2 architecture was announced, I wasn't surprised. The plugin system was always going to hit a wall.
What Actually Changed in v2
The headline change is Modules replace Plugins. But that undersells how different it is.
In v1, a plugin was a bag of services, subscribers, and migrations that got injected into a shared container. In v2, a module is an isolated unit with a defined interface:
// v2 module: self-contained with explicit interface
import { Module } from "@medusajs/framework/utils"
import LoyaltyModuleService from "./service"
export const LOYALTY_MODULE = "loyaltyModuleService"
export default Module(LOYALTY_MODULE, {
service: LoyaltyModuleService,
})The key differences that mattered in practice:
- Isolation. Modules don't share a dependency container. They communicate through links and workflows, not by reaching into each other's internals.
- Workflows. Business logic that used to live in service methods now lives in explicit, step-by-step workflows with compensation (rollback) logic built in.
- Links. Instead of extending core database tables, you define links between your module's data and core entities. Cleaner, but a different mental model.
- Admin UI extensions. The admin dashboard is extensible now — no more maintaining a separate admin panel for custom features.
The Migration: Feature Flags and Zero Downtime
The client I migrated had been on Medusa v1 for two years. Live orders, live inventory, active customers. "Just rebuild it" wasn't an option.
Here's what I actually did:
Step 1: Audit every custom plugin
I listed every v1 plugin, every custom service override, every subscriber. The apparel client had:
- Multi-currency pricing plugin
- Loyalty points module
- Custom shipping calculator
- Webhook integrations (inventory sync, email provider)
Each one needed to be rebuilt as a v2 module.
Step 2: Rebuild as modules in parallel
I rebuilt each plugin as a v2 module on a separate branch, running against a copy of the production database. The v2 module architecture made this cleaner than expected — the isolation meant I could test each module independently.
The loyalty module was the most work. In v1, it was tightly coupled to the order service via event subscribers. In v2, I rebuilt it as a standalone module with a workflow:
// v2 workflow: explicit steps with compensation
import { createWorkflow, createStep } from "@medusajs/framework/workflows-sdk"
const awardLoyaltyPointsStep = createStep(
"award-loyalty-points",
async ({ orderId, points }, { container }) => {
const loyaltyService = container.resolve("loyaltyModuleService")
const entry = await loyaltyService.awardPoints(orderId, points)
return entry
},
// compensation: undo if a later step fails
async (entry, { container }) => {
const loyaltyService = container.resolve("loyaltyModuleService")
await loyaltyService.revokePoints(entry.id)
}
)The compensation pattern was new to me. In v1, if something failed mid-order, you wrote manual cleanup logic (or didn't, and dealt with inconsistent state). v2 workflows handle this structurally.
Step 3: Feature flags
This was the critical decision. Instead of a big-bang cutover, I deployed the v2 modules behind feature flags:
- New orders could be processed through either the v1 or v2 pipeline
- Feature flags controlled which pipeline was active per storefront region
- Both pipelines wrote to the same database (via a compatibility layer I maintained during the transition)
This let the client's team test the v2 pipeline with real orders in a single region before rolling it out globally.
Step 4: Zero-downtime cutover
After two weeks of parallel running with no issues:
- Flipped the feature flag globally
- Ran the v2 data migration for historical data
- Removed the v1 plugin code
- Cleaned up the compatibility layer
No downtime. No data loss. The client didn't even send a company-wide email about it.
What's Actually Better in v2
After living with both versions in production:
Workflows are the biggest win. Complex order flows (split payments across vendors, loyalty point calculations, inventory reservations) are explicit and debuggable. In v1, this logic was scattered across event subscribers, and debugging meant tracing through a chain of async handlers.
Module isolation prevents the "two plugins conflicting" problem. I haven't had a single case of modules stepping on each other, which was a regular occurrence in v1.
The admin extensions are underrated. Being able to add custom UI directly in the Medusa admin instead of maintaining a separate Next.js dashboard for merchants saved significant maintenance overhead.
What's harder: the learning curve. v2's module + workflow + link model is more concepts to hold in your head than v1's "just extend the service." For simple stores, v1 was faster to get started with. v2 is the right architecture for anything that needs to scale, but the ramp-up is real.
What I'd Tell Someone Starting the Migration Today
-
Don't migrate everything at once. Pick your simplest plugin, rebuild it as a module, deploy it behind a flag. Get comfortable with the new patterns before tackling the complex ones.
-
Workflows are not optional. The temptation is to put business logic in API routes like you did in v1. Resist it. Workflows give you compensation, observability, and the ability to reuse steps across different flows.
-
Budget time for the data migration. The schema changes between v1 and v2 are significant. Write your ETL scripts early, run them against a production copy repeatedly, and measure the time. My 4,200-product migration took about 12 minutes — acceptable for a maintenance window, but you need to know the number before you commit to a cutover plan.
-
Feature flags are worth the extra work. The parallel-running approach added maybe two weeks of development time but eliminated the "flip the switch and pray" risk entirely.
-
Read the Medusa source code. Seriously. The v2 codebase is well-structured and the module interfaces are self-documenting. When the docs don't cover your use case, the source code will.
I've been building on Medusa since the first month it shipped. v2 is the framework v1 wanted to be. The migration isn't trivial, but it's less painful than most framework migrations I've been through — and the architecture on the other side is genuinely better.
If you're sitting on a v1 store wondering whether to make the jump: do it. Just do it with feature flags.