Most Medusa.js tutorials stop right after npx create-medusa-app. You get a screenshot of the admin panel, a "congrats, you're headless now" sign-off, and zero guidance on what happens when a real product catalog, real shipping rules, and a real payment gateway enter the picture.
I spent 48 hours building a working Medusa.js storefront for a client selling specialty coffee equipment — 340 SKUs, three shipping zones, Stripe + PayPal, and a wholesale tier. This is the build log I wish existed before I started.
If you're evaluating Medusa.js as a Shopify alternative and want operator-level detail, not a vendor pitch, read on.
Why Medusa.js and Not Something Easier
I'll be honest: Medusa.js (v2.0, released October 2024) is not easier than Shopify. Full stop. The setup alone assumes you're comfortable with Node.js, PostgreSQL, and at least one cloud provider. If you're a solo founder with no dev background, stop here and look at something like Swell or even WooCommerce.
But if you have dev chops — or a budget for one developer — the economics flip hard. Shopify Advanced runs $299/month plus 0.5% transaction fees on external gateways. For our client doing ~$45K/month in revenue, that's roughly $3,800/year in platform fees before apps. Medusa.js is MIT-licensed. Hosting on a $24/month DigitalOcean droplet (2 vCPU, 4 GB RAM) plus a $15/month managed Postgres instance covers the infrastructure. Total: ~$468/year. That delta funds actual product development.
The other reason: Medusa v2 finally ships with a proper module system. Cart, pricing, inventory, and fulfillment are now independent modules you can swap out. That matters when your client's wholesale pricing logic would require a $79/month Shopify app that still doesn't quite fit.
The Stack I Actually Used
Here's what ended up in production:
- Backend: Medusa.js v2.0.6 on Node 20, PostgreSQL 15
- Storefront: Next.js 14 (App Router) with the official
@medusajs/medusa-jsSDK v2 - Payments: Stripe (medusa-payment-stripe v3.0.2), PayPal (medusa-payment-paypal v3.0.1)
- Search: MeiliSearch 1.6 via the official Medusa plugin
- Email: Resend via a custom Medusa subscriber (not the built-in SendGrid plugin — more on that below)
- Hosting: DigitalOcean App Platform for the backend, Vercel for the storefront
- CDN/Images: Cloudflare R2 for product images, ~$0.015/GB storage
I deliberately skipped the official Next.js starter (create-next-app --example with-medusa). It's opinionated in ways that fight you once you need custom checkout flows. I scaffolded a fresh Next.js 14 app and wired the Medusa SDK manually. Takes an extra two hours up front; saves you two days of fighting starter assumptions later.
Hours 0-12: Backend Setup and the Parts That Actually Broke
Installing Medusa itself is fine:
npx create-medusa-app@latest coffee-backend --no-boilerplate
cd coffee-backend
npm install
The first real problem hit at hour 3: the Stripe plugin's webhook configuration. The v3 plugin requires you to register the webhook endpoint in Stripe's dashboard before starting the server, and the error message when you don't is a generic 500 with no pointer to the cause. The fix is in medusa-config.ts:
module.exports = defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL,
http: {
storeCors: process.env.STORE_CORS,
adminCors: process.env.ADMIN_CORS,
authCors: process.env.AUTH_CORS,
jwtSecret: process.env.JWT_SECRET,
cookieSecret: process.env.COOKIE_SECRET,
},
},
modules: [
{
resolve: "@medusajs/medusa/payment-stripe",
options: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, // ← must exist before first boot
},
},
],
});
Set STRIPE_WEBHOOK_SECRET from the Stripe dashboard first. Obvious in hindsight. Not obvious at midnight.
The second gotcha: MeiliSearch indexing. Medusa's MeiliSearch plugin indexes on product publish events, not on bulk import. We imported 340 products via CSV through the admin panel, and exactly zero of them appeared in search. You have to trigger a manual reindex:
curl -X POST http://localhost:9000/admin/products/reindex \
-H "Authorization: Bearer YOUR_JWT"
That endpoint isn't in the v2 docs yet as of January 2025. I found it by reading the plugin source on GitHub.
Hours 12-28: The Storefront and Checkout Flow
The storefront is where headless either pays off or punishes you. With Shopify, checkout is a black box you theme. With Medusa, you own every line of the checkout flow — which means you also own every bug in it.
I built a five-step checkout: cart → contact info → shipping → payment → confirmation. The Medusa cart API is clean here. Creating and updating a cart looks like this in a Next.js server action:
// app/actions/cart.ts
"use server";
import Medusa from "@medusajs/medusa-js";
const client = new Medusa({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
maxRetries: 3,
});
export async function addToCart(variantId: string, quantity: number, cartId?: string) {
if (!cartId) {
const { cart } = await client.carts.create();
cartId = cart.id;
}
const { cart } = await client.carts.lineItems.create(cartId, {
variant_id: variantId,
quantity,
});
return cart;
}
The wholesale pricing tier was the interesting part. Medusa v2's pricing module supports price lists natively — no app required. I created a "wholesale" price list in the admin, tagged wholesale customers with a customer group, and the API returns the correct price automatically when the customer is authenticated. That alone justified the migration from Shopify for this client.
One thing I'd do differently: don't use React Context for cart state. I started with it, hit hydration issues with Next.js App Router, and switched to Zustand with persistence to localStorage. Cleaner, zero hydration drama.
Hours 28-40: Email, Shipping, and the SendGrid Problem
The official Medusa SendGrid plugin (v2) has a known issue where order confirmation emails don't fire when using the Stripe payment provider — there's an open GitHub issue (#9847) that's been sitting since November 2024. Rather than wait, I wrote a custom subscriber using Resend's Node SDK. It's maybe 40 lines:
// src/subscribers/order-placed.ts
import { SubscriberArgs, SubscriberConfig } from "@medusajs/medusa";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export default async function orderPlacedHandler({ event: { data } }: SubscriberArgs) {
const { id: orderId } = data;
// fetch full order from service, build email HTML, send
await resend.emails.send({
from: "orders@yourcoffeeshop.com",
to: data.email,
subject: `Order confirmed — #${orderId.slice(-8).toUpperCase()}`,
html: buildOrderEmail(data),
});
}
export const config: SubscriberConfig = {
event: "order.placed",
};
For shipping, I used three Medusa fulfillment providers: manual (for local pickup), a custom FedEx integration using medusa-fulfillment-fedex (community package, works but the rate-fetching is slow — budget 800ms per shipping options call), and flat-rate for international. Shipping zones map cleanly to Medusa's region/shipping option model.
Hours 40-48: Performance, SEO, and Going Live
Headless commerce lives or dies on Core Web Vitals. A slow storefront erases the flexibility advantage fast.
My final Lighthouse scores on the PDP (product detail page):
- Performance: 94
- LCP: 1.8s (Cloudflare R2 + Next.js Image with
priorityon hero image) - CLS: 0.02
- INP: 68ms
Three things drove those numbers:
-
Static generation for PDPs. I used
generateStaticParamsto pre-render all 340 product pages at build time. Medusa's product list endpoint supports pagination — fetch in batches of 100. -
No layout shift on cart open. Reserve the cart drawer width in CSS before JS loads. Sounds trivial; took me 45 minutes to track down the CLS source.
-
Image optimization pipeline. All product images go through Cloudflare R2 → Cloudflare Image Resizing → Next.js
<Image>. Serving WebP at the right size, not 4MB PNGs from the admin upload.
For SEO, Medusa doesn't generate sitemaps. I wrote a 30-line Next.js route handler at /sitemap.xml that fetches all published products and collections from the Medusa API and outputs a valid XML sitemap. Add it to Google Search Console on day one.
How Medusa.js Compares to the Alternatives
| Medusa.js v2 | Swell.is | WooCommerce | Shopify Advanced | |
|---|---|---|---|---|
| Monthly cost (infra/platform) | ~$39 self-hosted | $299 (Growth) | ~$20-50 hosting | $299 + fees |
| Checkout ownership | Full | Full | Full | Partial (Shopify Checkout) |
| Custom pricing logic | Native modules | API-driven | Plugin-dependent | App-dependent |
| Dev complexity | High | Medium | Low-Medium | Low |
| Vendor lock-in | None (MIT) | Moderate | Low (WP ecosystem) | High |
| Time to first sale | 2-5 days | 1-2 days | 1 day | Hours |
Swell is worth a look if you want API-first without the self-hosting overhead. WooCommerce still wins on raw time-to-launch for non-technical teams. Shopify Advanced makes sense if you're doing over $500K/month and need enterprise support contracts — below that, the fee math doesn't favor it.
If you're weighing other Shopify alternatives beyond these three, I've covered the broader landscape in the SMB platform comparison on this blog.
What I'd Do Differently on the Next Build
Four honest lessons:
Skip the official Next.js starter. I said this above but it bears repeating. Scaffold fresh.
Set up staging early. I deployed to production at hour 46 and found a tax calculation bug (EU VAT) that only surfaces with live Stripe keys. A proper staging environment with mirrored env vars would have caught it.
Budget more time for the admin UX conversation. The Medusa admin is functional but sparse compared to Shopify. My client needed a 30-minute walkthrough just to understand how regions map to shipping options. Factor in training time.
Use Medusa's event system for everything async. Order confirmation, inventory updates, wholesale approval emails — all of it should be subscribers, not inline API calls. The architecture forces good habits once you lean into it.
What to Do Tomorrow
If you're seriously evaluating Medusa.js as your next storefront platform, here's the concrete next step: spin up a local instance with npx create-medusa-app@latest today, import 10 real products from your current catalog, and try to complete a test checkout with Stripe in test mode. That 2-hour exercise will tell you more about fit than any comparison article — including this one.
If you hit the Stripe webhook issue I described, you'll know you're in the right place. The problems are solvable. The economics, for the right store, are genuinely compelling.
For a deeper look at how headless commerce architecture decisions affect long-term maintenance costs, see headless commerce for SMBs: when it's worth it.
Building something on Medusa.js and stuck on a specific integration? Drop the error in the comments — I check them.