Medusa vs Saleor vs Vendure: Headless E-Commerce Platforms (2026)
TL;DR
Medusa is the open-source headless commerce engine — modular architecture, Node.js/TypeScript, built-in modules for products, orders, payments, shipping, REST and JS SDK, extensible with custom modules. Saleor is the GraphQL-first commerce platform — Python/Django backend, powerful GraphQL API, multi-channel, multi-warehouse, apps ecosystem, dashboard included. Vendure is the TypeScript-native commerce framework — NestJS-based, GraphQL API, plugin architecture, admin UI, strongly typed from database to API. In 2026: Medusa for JS/TS-native modular commerce, Saleor for GraphQL-first multi-channel, Vendure for TypeScript purists with NestJS.
Key Takeaways
- Medusa: @medusajs/medusa ~40K weekly downloads — modular, JS SDK, REST API
- Saleor: 21K+ GitHub stars — GraphQL, Python/Django, multi-channel
- Vendure: @vendure-io/core ~15K weekly downloads — NestJS, GraphQL, plugins
- Medusa has the most modular architecture with swappable commerce modules
- Saleor provides the most mature GraphQL commerce API
- Vendure offers the strongest TypeScript experience end-to-end
Medusa
Medusa — open-source headless commerce:
Setup and products
# Create new Medusa project:
npx create-medusa-app@latest my-store
# Start development:
cd my-store
npx medusa develop
// src/api/store/products/route.ts — custom API route
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
import { Modules } from "@medusajs/framework/utils"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const productService = req.scope.resolve(Modules.PRODUCT)
const [products, count] = await productService.listAndCountProducts(
{
tags: { value: ["featured"] },
},
{
relations: ["variants", "variants.prices", "images", "tags"],
take: 20,
skip: 0,
order: { created_at: "DESC" },
}
)
res.json({ products, count })
}
Storefront SDK
import Medusa from "@medusajs/js-sdk"
const medusa = new Medusa({
baseUrl: "http://localhost:9000",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,
})
// List products:
const { products } = await medusa.store.product.list({
limit: 20,
fields: "+variants.calculated_price",
region_id: regionId,
})
// Get single product:
const { product } = await medusa.store.product.retrieve(productId, {
fields: "+variants.calculated_price,+variants.inventory_quantity",
region_id: regionId,
})
// Cart operations:
const { cart } = await medusa.store.cart.create({
region_id: regionId,
})
await medusa.store.cart.createLineItem(cart.id, {
variant_id: variantId,
quantity: 2,
})
// Update cart:
await medusa.store.cart.update(cart.id, {
email: "customer@example.com",
shipping_address: {
first_name: "John",
last_name: "Doe",
address_1: "123 Main St",
city: "San Francisco",
province: "CA",
postal_code: "94105",
country_code: "us",
},
})
// Add shipping method:
const { shipping_options } = await medusa.store.fulfillment.listCartOptions({
cart_id: cart.id,
})
await medusa.store.cart.addShippingMethod(cart.id, {
option_id: shipping_options[0].id,
})
// Complete checkout:
const { type, order } = await medusa.store.cart.complete(cart.id)
if (type === "order") {
console.log(`Order ${order.display_id} placed!`)
}
Custom module
// src/modules/loyalty/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import { LoyaltyPoints } from "./models/loyalty-points"
class LoyaltyService extends MedusaService({ LoyaltyPoints }) {
async awardPoints(customerId: string, points: number, reason: string) {
return await this.createLoyaltyPoints({
customer_id: customerId,
points,
reason,
awarded_at: new Date(),
})
}
async getBalance(customerId: string): Promise<number> {
const records = await this.listLoyaltyPoints({
customer_id: customerId,
})
return records.reduce((sum, r) => sum + r.points, 0)
}
async redeemPoints(customerId: string, points: number) {
const balance = await this.getBalance(customerId)
if (balance < points) {
throw new Error("Insufficient loyalty points")
}
return await this.createLoyaltyPoints({
customer_id: customerId,
points: -points,
reason: "redeemed",
awarded_at: new Date(),
})
}
}
export default LoyaltyService
// src/modules/loyalty/index.ts
import { Module } from "@medusajs/framework/utils"
import LoyaltyService from "./service"
export const LOYALTY_MODULE = "loyalty"
export default Module(LOYALTY_MODULE, { service: LoyaltyService })
Workflows
// src/workflows/order-completed.ts
import {
createWorkflow,
createStep,
StepResponse,
} from "@medusajs/framework/workflows-sdk"
import { Modules } from "@medusajs/framework/utils"
import { LOYALTY_MODULE } from "../modules/loyalty"
const awardLoyaltyPointsStep = createStep(
"award-loyalty-points",
async ({ order_id }, { container }) => {
const orderService = container.resolve(Modules.ORDER)
const loyaltyService = container.resolve(LOYALTY_MODULE)
const order = await orderService.retrieveOrder(order_id)
const points = Math.floor(Number(order.total) / 100) // 1 point per dollar
const record = await loyaltyService.awardPoints(
order.customer_id,
points,
`Order ${order.display_id}`
)
return new StepResponse(record, record.id)
},
async (recordId, { container }) => {
// Compensation (rollback):
const loyaltyService = container.resolve(LOYALTY_MODULE)
await loyaltyService.deleteLoyaltyPoints(recordId)
}
)
export const orderCompletedWorkflow = createWorkflow(
"order-completed-loyalty",
(input: { order_id: string }) => {
awardLoyaltyPointsStep(input)
}
)
Saleor
Saleor — GraphQL-first commerce:
GraphQL queries
import { GraphQLClient, gql } from "graphql-request"
const client = new GraphQLClient(process.env.SALEOR_API_URL!, {
headers: {
Authorization: `Bearer ${process.env.SALEOR_TOKEN}`,
},
})
// Fetch products:
const { products } = await client.request(gql`
query GetProducts($channel: String!, $first: Int!) {
products(channel: $channel, first: $first, sortBy: { field: RATING, direction: DESC }) {
edges {
node {
id
name
slug
description
pricing {
priceRange {
start {
gross {
amount
currency
}
}
}
}
thumbnail {
url
alt
}
variants {
id
name
sku
pricing {
price {
gross {
amount
currency
}
}
}
quantityAvailable
}
category {
name
slug
}
}
}
}
}
`, { channel: "default-channel", first: 20 })
// Search products:
const { products: results } = await client.request(gql`
query SearchProducts($query: String!, $channel: String!) {
products(channel: $channel, filter: { search: $query }, first: 10) {
edges {
node {
id
name
slug
pricing {
priceRange {
start { gross { amount currency } }
}
}
}
}
}
}
`, { query: "react t-shirt", channel: "default-channel" })
Checkout flow
// Create checkout:
const { checkoutCreate } = await client.request(gql`
mutation CreateCheckout($input: CheckoutCreateInput!) {
checkoutCreate(input: $input) {
checkout {
id
token
totalPrice { gross { amount currency } }
lines {
id
quantity
variant { name }
totalPrice { gross { amount currency } }
}
}
errors { field message code }
}
}
`, {
input: {
channel: "default-channel",
email: "customer@example.com",
lines: [
{ variantId: "UHJvZHVjdFZhcmlhbnQ6MQ==", quantity: 2 },
],
},
})
// Add shipping address:
await client.request(gql`
mutation UpdateCheckoutShipping($id: ID!, $address: AddressInput!) {
checkoutShippingAddressUpdate(id: $id, shippingAddress: $address) {
checkout {
id
shippingMethods {
id
name
price { amount currency }
}
}
errors { field message }
}
}
`, {
id: checkoutCreate.checkout.id,
address: {
firstName: "John",
lastName: "Doe",
streetAddress1: "123 Main St",
city: "San Francisco",
postalCode: "94105",
country: "US",
countryArea: "CA",
},
})
// Select shipping method:
await client.request(gql`
mutation SelectShipping($id: ID!, $methodId: ID!) {
checkoutDeliveryMethodUpdate(id: $id, deliveryMethodId: $methodId) {
checkout {
id
totalPrice { gross { amount currency } }
}
errors { field message }
}
}
`, {
id: checkoutCreate.checkout.id,
methodId: shippingMethodId,
})
// Complete payment:
const { checkoutComplete } = await client.request(gql`
mutation CompleteCheckout($id: ID!) {
checkoutComplete(id: $id) {
order {
id
number
status
total { gross { amount currency } }
}
errors { field message }
}
}
`, { id: checkoutCreate.checkout.id })
Saleor App (webhook handler)
// Saleor App — handle webhooks:
import { NextApiRequest, NextApiResponse } from "next"
import { verifyWebhookSignature } from "@saleor/app-sdk/verify-webhook"
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Verify Saleor webhook signature:
const isValid = await verifyWebhookSignature({
signature: req.headers["saleor-signature"] as string,
body: JSON.stringify(req.body),
secretKey: process.env.SALEOR_APP_SECRET!,
})
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" })
}
const event = req.body
switch (event.__typename) {
case "OrderCreated":
console.log(`New order: ${event.order.number}`)
// Send confirmation email, update inventory, etc.
await sendOrderConfirmation(event.order)
break
case "OrderFulfilled":
console.log(`Order fulfilled: ${event.order.number}`)
await sendShippingNotification(event.order)
break
case "ProductUpdated":
console.log(`Product updated: ${event.product.name}`)
await revalidateProductPage(event.product.slug)
break
}
res.status(200).json({ received: true })
}
Vendure
Vendure — TypeScript-native commerce:
Setup and GraphQL
# Create new Vendure project:
npx @vendure/create my-shop
cd my-shop
npm run dev
// Storefront GraphQL queries:
import { GraphQLClient, gql } from "graphql-request"
const client = new GraphQLClient("http://localhost:3000/shop-api")
// Fetch products:
const { products } = await client.request(gql`
query GetProducts {
products(options: { take: 20, sort: { createdAt: DESC } }) {
items {
id
name
slug
description
featuredAsset {
preview
}
variants {
id
name
sku
priceWithTax
currencyCode
stockLevel
}
facetValues {
name
facet {
name
}
}
}
totalItems
}
}
`)
// Search products:
const { search } = await client.request(gql`
query SearchProducts($term: String!) {
search(input: { term: $term, groupByProduct: true, take: 10 }) {
items {
productId
productName
slug
description
priceWithTax {
... on PriceRange {
min
max
}
}
productAsset {
preview
}
}
totalItems
}
}
`, { term: "headphones" })
Order flow
// Add to cart:
const { addItemToOrder } = await client.request(gql`
mutation AddToCart($variantId: ID!, $quantity: Int!) {
addItemToOrder(productVariantId: $variantId, quantity: $quantity) {
... on Order {
id
code
totalWithTax
lines {
id
productVariant { name sku }
quantity
linePriceWithTax
}
}
... on ErrorResult {
errorCode
message
}
}
}
`, { variantId: "1", quantity: 2 })
// Set shipping address:
await client.request(gql`
mutation SetShippingAddress($input: CreateAddressInput!) {
setOrderShippingAddress(input: $input) {
... on Order {
id
shippingAddress {
fullName
streetLine1
city
postalCode
country
}
}
}
}
`, {
input: {
fullName: "John Doe",
streetLine1: "123 Main St",
city: "San Francisco",
province: "CA",
postalCode: "94105",
countryCode: "US",
},
})
// Get shipping methods:
const { eligibleShippingMethods } = await client.request(gql`
query ShippingMethods {
eligibleShippingMethods {
id
name
description
priceWithTax
}
}
`)
// Set shipping method:
await client.request(gql`
mutation SetShipping($id: [ID!]!) {
setOrderShippingMethod(shippingMethodId: $id) {
... on Order {
id
totalWithTax
shippingWithTax
}
}
}
`, { id: [eligibleShippingMethods[0].id] })
// Add payment:
const { addPaymentToOrder } = await client.request(gql`
mutation AddPayment($input: PaymentInput!) {
addPaymentToOrder(input: $input) {
... on Order {
id
code
state
totalWithTax
}
... on ErrorResult {
errorCode
message
}
}
}
`, {
input: {
method: "stripe",
metadata: { paymentIntentId: stripePaymentIntentId },
},
})
Custom plugin
// src/plugins/loyalty/loyalty.plugin.ts
import { PluginCommonModule, VendurePlugin } from "@vendure/core"
import { LoyaltyService } from "./loyalty.service"
import { LoyaltyPoints } from "./loyalty-points.entity"
import { LoyaltyResolver } from "./loyalty.resolver"
import { LoyaltyShopApiExtension } from "./api-extensions"
@VendurePlugin({
imports: [PluginCommonModule],
entities: [LoyaltyPoints],
providers: [LoyaltyService],
shopApiExtensions: {
schema: LoyaltyShopApiExtension,
resolvers: [LoyaltyResolver],
},
configuration: (config) => {
config.customFields.Order.push({
name: "loyaltyPointsEarned",
type: "int",
defaultValue: 0,
})
return config
},
})
export class LoyaltyPlugin {}
// src/plugins/loyalty/loyalty.service.ts
import { Injectable } from "@nestjs/common"
import { RequestContext, TransactionalConnection, Order } from "@vendure/core"
import { LoyaltyPoints } from "./loyalty-points.entity"
@Injectable()
export class LoyaltyService {
constructor(private connection: TransactionalConnection) {}
async awardPoints(ctx: RequestContext, order: Order): Promise<LoyaltyPoints> {
const points = Math.floor(order.totalWithTax / 100)
const record = new LoyaltyPoints({
customer: order.customer,
points,
reason: `Order #${order.code}`,
orderId: order.id,
})
return this.connection.getRepository(ctx, LoyaltyPoints).save(record)
}
async getBalance(ctx: RequestContext, customerId: string): Promise<number> {
const result = await this.connection
.getRepository(ctx, LoyaltyPoints)
.createQueryBuilder("lp")
.select("SUM(lp.points)", "total")
.where("lp.customerId = :customerId", { customerId })
.getRawOne()
return result?.total ?? 0
}
}
// src/plugins/loyalty/loyalty-points.entity.ts
import { DeepPartial, VendureEntity, Customer } from "@vendure/core"
import { Entity, Column, ManyToOne } from "typeorm"
@Entity()
export class LoyaltyPoints extends VendureEntity {
constructor(input?: DeepPartial<LoyaltyPoints>) {
super(input)
}
@ManyToOne(() => Customer)
customer: Customer
@Column()
points: number
@Column()
reason: string
@Column({ nullable: true })
orderId: string
}
Event handlers
// src/plugins/notifications/notification.plugin.ts
import { OnModuleInit } from "@nestjs/common"
import {
EventBus,
OrderStateTransitionEvent,
PluginCommonModule,
VendurePlugin,
} from "@vendure/core"
@VendurePlugin({
imports: [PluginCommonModule],
})
export class NotificationPlugin implements OnModuleInit {
constructor(private eventBus: EventBus) {}
onModuleInit() {
// Listen for order state changes:
this.eventBus.ofType(OrderStateTransitionEvent).subscribe((event) => {
if (event.toState === "PaymentSettled") {
this.sendOrderConfirmation(event.order)
}
if (event.toState === "Shipped") {
this.sendShippingNotification(event.order)
}
if (event.toState === "Delivered") {
this.sendDeliveryConfirmation(event.order)
}
})
}
private async sendOrderConfirmation(order: any) {
console.log(`Order confirmed: ${order.code}`)
// Send email via your preferred service
}
private async sendShippingNotification(order: any) {
console.log(`Order shipped: ${order.code}`)
}
private async sendDeliveryConfirmation(order: any) {
console.log(`Order delivered: ${order.code}`)
}
}
Feature Comparison
| Feature | Medusa | Saleor | Vendure |
|---|---|---|---|
| Language | TypeScript/Node.js | Python/Django | TypeScript/NestJS |
| API | REST + JS SDK | GraphQL | GraphQL |
| Architecture | Modular (modules) | Monolithic + apps | Plugin-based |
| Admin UI | ✅ (React) | ✅ (React) | ✅ (Angular) |
| Multi-channel | ✅ | ✅ | ✅ (via channels) |
| Multi-warehouse | ✅ | ✅ | ✅ |
| Multi-currency | ✅ | ✅ | ✅ |
| Payments | Module-based | App-based | Plugin-based |
| Custom fields | ✅ | ❌ (metadata) | ✅ (typed) |
| Workflows | ✅ (built-in) | Webhooks | Event bus |
| Search | Module-based | Built-in | Plugin-based |
| Subscriptions | ✅ | Via app | Via plugin |
| TypeScript SDK | ✅ (@medusajs/js-sdk) | Community | Generated |
| Hosting | Any Node.js host | Any Python host | Any Node.js host |
| GitHub stars | 27K+ | 21K+ | 6K+ |
When to Use Each
Use Medusa if:
- Want a Node.js/TypeScript-native commerce engine
- Need modular architecture where you swap entire commerce modules
- Prefer REST API with a first-party JS SDK for storefront development
- Building custom commerce flows with workflow orchestration
Use Saleor if:
- Want a mature GraphQL-first commerce platform
- Need multi-channel, multi-warehouse from day one
- Building with a Python/Django backend team
- Prefer an established commerce platform with an apps ecosystem
Use Vendure if:
- Want end-to-end TypeScript with NestJS patterns (DI, modules, decorators)
- Need strongly typed custom fields and plugin architecture
- Prefer GraphQL with a familiar NestJS development experience
- Building enterprise commerce with strict type safety requirements
Methodology
Download data from npm registry and GitHub (March 2026). Feature comparison based on @medusajs/medusa v2.x, Saleor v3.x, and @vendure-io/core v3.x.
Compare e-commerce platforms and developer tooling on PkgPulse →