Skip to main content

Medusa vs Saleor vs Vendure: Headless E-Commerce Platforms (2026)

·PkgPulse Team

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

FeatureMedusaSaleorVendure
LanguageTypeScript/Node.jsPython/DjangoTypeScript/NestJS
APIREST + JS SDKGraphQLGraphQL
ArchitectureModular (modules)Monolithic + appsPlugin-based
Admin UI✅ (React)✅ (React)✅ (Angular)
Multi-channel✅ (via channels)
Multi-warehouse
Multi-currency
PaymentsModule-basedApp-basedPlugin-based
Custom fields❌ (metadata)✅ (typed)
Workflows✅ (built-in)WebhooksEvent bus
SearchModule-basedBuilt-inPlugin-based
SubscriptionsVia appVia plugin
TypeScript SDK✅ (@medusajs/js-sdk)CommunityGenerated
HostingAny Node.js hostAny Python hostAny Node.js host
GitHub stars27K+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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.