Headless Commerce with Next.js 16 and Medusa v2: Building a Multi-Vendor Marketplace
Over the last six years building production-grade e-commerce systems for clients across the US, Europe, and the Middle East, I have seen one architectural pattern consistently outperform the rest when it comes to scale, flexibility, and developer velocity: headless commerce. And in 2025, the combination of Next.js 16 and Medusa v2 has become my default stack for any serious multi-vendor marketplace project.
In this guide, I will walk you through the exact architecture, code patterns, and integration strategy I use to ship production marketplaces. Whether you are a solo developer or leading a team, this is the playbook.
Why Headless Commerce Is the Right Choice for Marketplaces
Traditional monolithic e-commerce platforms like Shopify, WooCommerce, or Magento were designed for single-store scenarios. The moment you introduce multiple vendors, each needing independent product catalogs, payout schedules, and custom storefronts, those platforms become bottlenecks.
Headless commerce decouples the frontend presentation layer from the backend commerce logic. For a multi-vendor marketplace, this means:
- Independent vendor storefronts rendered from a single Next.js app using dynamic routes and segments
- Custom checkout flows per vendor or region without touching backend code
- API-first integrations with payment providers, ERPs, and shipping carriers
- Performance at scale through Server Components, streaming, and edge rendering in Next.js 16
When I rebuilt a fashion marketplace for a client in Dubai last year, switching from a monolithic WooCommerce setup to a headless Medusa + Next.js stack reduced their Time to First Byte from 1.8s to 220ms and tripled their conversion rate. That is the kind of impact we are talking about.
The Architecture at a Glance
Before we dive into code, let me lay out the high-level architecture I recommend:
┌──────────────────────┐ ┌──────────────────────┐
│ Next.js 16 Store │ HTTP │ Medusa v2 Server │
│ (App Router, RSC) │ ──────► │ (Commerce Engine) │
│ /vendor/[handle] │ │ Postgres + Redis │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
│ Webhooks │ Events
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Stripe Connect│ ◄──────────────►│ Subscribers │
│ (Payments) │ Webhooks │ (Custom) │
└──────────────┘ └──────────────┘
Three core services:
- Medusa v2 — the commerce engine handling products, orders, customers, vendors, and split-payment logic
- Next.js 16 — the storefront and vendor dashboards with React Server Components and Server Actions
- Stripe Connect — handles vendor onboarding, KYC, and split payments with destination charges
Setting Up Medusa v2 for Multi-Vendor
Medusa v2 introduces a fully modular architecture that is perfect for marketplace extensions. The first thing we need is a custom Vendor module.
Create the module in your Medusa project:
npx medusa module:create vendor
This generates a scaffold. Now define your Vendor data model in src/modules/vendor/models/vendor.ts:
import { model } from "@medusajs/framework/utils"
const Vendor = model.define("vendor", {
id: model.id().primaryKey(),
handle: model.text().unique(),
name: model.text(),
email: model.text().unique(),
stripe_account_id: model.text().nullable(),
commission_rate: model.number().default(10),
status: model.enum(["active", "pending", "suspended"]).default("pending"),
products: model.hasMany(() => Product),
})
export default Vendor
Then register the module in medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"
module.exports = defineConfig({
modules: [
{
resolve: "./src/modules/vendor",
},
],
// ... rest of config
})
Medusa v2's event system makes it easy to react to vendor lifecycle events. For example, send a welcome email when a new vendor is approved:
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default function vendorApprovedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const notificationService = container.resolve("notificationModuleService")
notificationService.createNotifications({
to: "vendor@example.com",
channel: "email",
template: "vendor-approved",
data: { vendorId: event.data.id },
})
}
export const config: SubscriberConfig = {
event: "vendor.approved",
}
Building the Vendor Storefront with Next.js 16
Next.js 16 brings substantial improvements for commerce workloads: the new Server Actions are stable, Partial Prerendering (PPR) is production-ready, and the App Router is now the default. I use these features to build vendor-specific storefronts that are blazingly fast.
A typical route structure for a multi-vendor marketplace looks like this:
app/
├── (storefront)/
│ ├── page.tsx # Marketplace home
│ ├── products/
│ │ └── [handle]/
│ │ └── page.tsx # Product detail
│ └── vendors/
│ └── [handle]/
│ ├── page.tsx # Vendor landing
│ └── products/page.tsx # Vendor's products
├── (vendor-dashboard)/
│ ├── dashboard/page.tsx
│ ├── products/page.tsx
│ └── payouts/page.tsx
└── api/
└── webhooks/stripe/route.ts
Here is how I render a vendor storefront using a Server Component in Next.js 16:
// app/(storefront)/vendors/[handle]/page.tsx
import { medusa } from "@/lib/medusa"
import { notFound } from "next/navigation"
export async function generateMetadata({ params }: Props) {
const { handle } = await params
const { vendor } = await medusa.client.fetch(`/store/vendors/${handle}`)
if (!vendor) return {}
return {
title: `${vendor.name} | Marketplace`,
description: vendor.bio,
openGraph: { images: [vendor.banner] },
}
}
export default async function VendorPage({ params }: Props) {
const { handle } = await params
const { vendor, products } = await medusa.client.fetch(
`/store/vendors/${handle}?expand=products`
)
if (!vendor) notFound()
return (
<main className="container mx-auto px-4 py-8">
<section className="vendor-hero">
<img src={vendor.banner} alt={vendor.name} />
<h1 className="text-4xl font-bold">{vendor.name}</h1>
<p>{vendor.bio}</p>
</section>
<section className="product-grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</section>
</main>
)
}
Notice how we use generateMetadata for SEO — this is critical for marketplace discoverability. Every vendor page needs to rank independently on Google.
Implementing Stripe Connect for Split Payments
This is where most multi-vendor projects fail or get expensive. Stripe Connect is the gold standard for marketplace payments, and the destination charges model is the cleanest approach.
The flow is:
- Customer pays the platform (your Stripe account)
- Stripe automatically splits the payment to the vendor's connected account
- The platform retains its commission
- Webhooks notify Medusa of successful payouts
In your Medusa server, create a Stripe service wrapper:
// src/modules/stripe-connect/services/stripe-connect.ts
import Stripe from "stripe"
import { MedusaService } from "@medusajs/framework/utils"
class StripeConnectService extends MedusaService({}) {
private stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
})
async createConnectedAccount(vendor: { email: string; country: string }) {
return this.stripe.accounts.create({
type: "express",
email: vendor.email,
country: vendor.country,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
})
}
async createOnboardingLink(accountId: string, returnUrl: string) {
return this.stripe.accountLinks.create({
account: accountId,
refresh_url: returnUrl,
return_url: returnUrl,
type: "account_onboarding",
})
}
async createPaymentIntent(order: { total: number; vendorStripeIds: string[]; platformFee: number }) {
return this.stripe.paymentIntents.create({
amount: Math.round(order.total * 100),
currency: "usd",
application_fee_amount: Math.round(order.platformFee * 100),
transfer_data: { destination: order.vendorStripeIds[0] },
})
}
}
export default StripeConnectService
Then expose a Next.js Server Action for vendor onboarding:
// app/(vendor-dashboard)/onboarding/actions.ts
"use server"
import { medusa } from "@/lib/medusa"
import { stripe } from "@/lib/stripe"
export async function startVendorOnboarding(vendorId: string) {
const { vendor } = await medusa.client.fetch(`/admin/vendors/${vendorId}`)
let accountId = vendor.stripe_account_id
if (!accountId) {
const account = await stripe.accounts.create({
type: "express",
email: vendor.email,
})
accountId = account.id
await medusa.client.fetch(`/admin/vendors/${vendorId}`, {
method: "POST",
body: { stripe_account_id: accountId },
})
}
const link = await stripe.accountLinks.create({
account: accountId,
refresh_url: `${process.env.NEXT_PUBLIC_URL}/onboarding/refresh`,
return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
type: "account_onboarding",
})
return { url: link.url }
}
And handle webhooks for payout events:
// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers"
import { medusa } from "@/lib/medusa"
import { stripe } from "@/lib/stripe"
export async function POST(req: Request) {
const body = await req.text()
const sig = (await headers()).get("stripe-signature")!
let event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response("Webhook error", { status: 400 })
}
switch (event.type) {
case "account.updated":
await medusa.client.fetch("/admin/webhooks/stripe", {
method: "POST",
body: event,
})
break
case "payment_intent.succeeded":
await medusa.client.fetch("/admin/orders/update-payment", {
method: "POST",
body: { payment_intent_id: event.data.object.id },
})
break
}
return new Response("ok")
}
Performance and SEO Best Practices
Headless does not automatically mean fast. Here are the optimizations I always apply:
- Enable Partial Prerendering in
next.config.jsfor product listing pages — the static shell streams instantly while dynamic prices and stock render in the background - Use Medusa's built-in caching with Redis to cache product queries per region
- Generate sitemaps per vendor so Google indexes each vendor's catalog independently
- Add structured data (JSON-LD) for
Product,Organization, andBreadcrumbListon every vendor and product page - Image optimization with
next/imageand Medusa's signed-URL CDN for product images
For a real production example: I reduced the Largest Contentful Paint on a 5,000-product marketplace from 4.2s to 1.1s simply by enabling PPR and lazy-loading the vendor product grid.
Deployment and Infrastructure
For a serious marketplace, my recommended stack is:
- Medusa v2 → Railway, Render, or AWS ECS with PostgreSQL (managed) and Redis (Upstash)
- Next.js 16 → Vercel for the storefront, or Cloudflare Pages if you need edge rendering at lower cost
- Stripe → production mode with Connect enabled, webhook endpoints behind a stable URL
- Monitoring → Sentry for errors, Logflare for logs, and Stripe Dashboard for payment analytics
Always deploy your Medusa server before your storefront. Medusa runs database migrations on boot, and your Next.js app will fail health checks if the API is not ready.
Final Thoughts
A multi-vendor marketplace is one of the most complex e-commerce systems you can build. The combination of Next.js 16 and Medusa v2 gives you a developer experience that is genuinely enjoyable, while Stripe Connect handles the hardest part — money — with industry-leading compliance and reliability.
If you are considering this stack for a client project or your own SaaS, my advice is to start small: ship a single-vendor version first, validate the architecture, then enable multi-vendor through the module pattern I outlined above. Trying to build everything at once is the most common reason marketplace projects fail.
Need help architecting your own headless commerce platform? I have shipped 168+ projects on Fiverr and I would love to help you build something exceptional. Let's talk.
Frequently Asked Questions
What is headless commerce and why is it ideal for marketplaces? Headless commerce separates the frontend presentation layer from the backend commerce engine through APIs. For multi-vendor marketplaces, this enables independent vendor storefronts, custom checkout flows per vendor, easier integration with third-party services, and superior performance through modern rendering techniques like React Server Components and edge caching.
How does Medusa v2 support multi-vendor marketplaces? Medusa v2 provides a modular architecture that allows you to build custom modules like a Vendor module. You can extend core data models, hook into Medusa's event system for vendor lifecycle events, and use Subscribers to trigger side effects like email notifications, analytics, or third-party API calls. Medusa also has a built-in Sales Channel feature that maps perfectly to multi-vendor setups.
What is Stripe Connect and how does it handle split payments? Stripe Connect is a payment orchestration platform designed for marketplaces and platforms. The destination charges model lets your platform act as the merchant of record, charge the customer, and automatically route a portion of the payment to the vendor's connected Stripe account while retaining your platform commission. Stripe handles KYC, tax compliance, and 190+ country payouts.
How does Next.js 16 improve headless commerce storefronts? Next.js 16 brings Partial Prerendering (PPR) for hybrid static/dynamic rendering, stable Server Actions for mutations, improved caching primitives, and faster build times. For commerce, this translates to instant page loads for product catalogs while still serving real-time pricing and stock data, all without sacrificing SEO.
What is the cost of building a headless commerce marketplace? Costs vary based on scope, but a production-ready multi-vendor marketplace built on Next.js and Medusa typically requires a development investment of $15,000 to $80,000+ depending on the number of features. Ongoing infrastructure costs are usually $200 to $1,000 per month for a small to mid-sized marketplace, excluding Stripe transaction fees of 2.9% + 30c per successful charge plus Connect platform fees.
How to Build a Multi-Vendor Marketplace: Step-by-Step
Step 1: Set up the Medusa v2 backend Initialize a new Medusa v2 project, configure PostgreSQL and Redis, and run the dev server. Create a vendor module with custom data models for vendor profiles, Stripe account IDs, and commission rates.
Step 2: Create the vendor management module Build a custom Vendor module in Medusa with a data model including handle, name, email, stripe_account_id, commission_rate, and status. Register the module in medusa-config.ts and add admin API routes for vendor CRUD operations.
Step 3: Build the Next.js 16 storefront Scaffold a Next.js 16 app with the App Router. Implement vendor-specific dynamic routes, product listings, and SEO-optimized metadata generation. Use React Server Components to fetch data directly from Medusa without exposing API keys to the client.
Step 4: Integrate Stripe Connect for split payments Create Stripe connected accounts for each vendor, implement onboarding using Stripe-hosted account links, and configure destination charges with application fees for every payment. Set up webhooks to sync payment status back to Medusa.
Step 5: Deploy to production Deploy Medusa to Railway or Render with managed PostgreSQL and Redis. Deploy the Next.js storefront to Vercel or Cloudflare Pages. Configure Stripe webhooks with the production URL and run end-to-end tests before going live.
