Skip to main content

Guide

Medusa vs Saleor vs Vendure (2026)

Compare Medusa, Saleor, and Vendure for headless e-commerce. Product management, checkout flows, payment processing, and which open-source commerce platform.

·PkgPulse Team·
0

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

Payment Integration and Checkout Architecture

Payment processing is one of the most consequential architectural decisions in e-commerce, and the three platforms take different approaches. Medusa v2's module system means payment providers are swappable — the official @medusajs/payment-stripe module handles Stripe payment intents, and community modules cover Adyen, PayPal, and other providers. The checkout flow in Medusa is managed through the cart-to-order state machine, and payment is collected at the complete step where the cart converts to a confirmed order. Saleor processes payments through its app ecosystem — Stripe, Adyen, and Braintree apps are available from the Saleor App Store, and they communicate with Saleor's payment gateway interface through a standardized webhook contract. This app-based approach decouples payment logic from core commerce, making it easier to swap providers without core code changes. Vendure uses a plugin-based payment provider system similar to Medusa's module approach, with official Stripe and Braintree plugins and the ability to implement custom payment handlers for regional payment methods.

TypeScript Coverage and Type Safety in Commerce

End-to-end TypeScript correctness is a meaningful differentiator between these platforms in 2026. Vendure has the strongest TypeScript story because its entire codebase — from database entities to GraphQL resolvers to the admin UI — is written in TypeScript with NestJS decorators, and the plugin system inherits these types naturally. When you create a custom entity or extend an existing one with customFields, the TypeScript types update throughout the system including in the generated GraphQL schema. Medusa v2 has significantly improved its TypeScript coverage in the major rewrite, with the @medusajs/framework package providing strongly typed service interfaces and workflow step types. The JS SDK (@medusajs/js-sdk) is fully typed for storefront consumption. Saleor's TypeScript experience for storefront developers depends on GraphQL code generation — the standard approach is to define queries and run graphql-codegen to generate TypeScript types for each query's response, which is effective but requires maintaining the code generation step as an explicit part of the development workflow.

Multi-Region and Internationalization

All three platforms support international commerce, but with different architectural approaches to regions, currencies, and taxes. Medusa organizes regions as first-class entities with their own currency, tax rates, shipping options, and payment providers. A single product can have different prices per region through price lists, enabling market-specific pricing strategies. Saleor's channel system serves a similar purpose — each channel represents a market segment with its own currency, warehouses, and shipping zones, and the same product can have different pricing, availability, and descriptions per channel. This makes Saleor particularly well-suited for complex multi-region deployments where the same product catalog needs to appear differently across markets. Vendure's zone and tax category system handles the compliance layer, with zone-specific tax providers that integrate with regional tax services like Avalara for US sales tax or the built-in EU VAT handling.

Storefront Performance and Edge Compatibility

Modern headless commerce requires storefronts that perform well on the edge and generate static pages efficiently. Medusa's storefront SDK works in both Node.js and browser contexts, making it compatible with Next.js App Router server components and edge functions. The REST API design means storefront pages can be statically generated with product data fetched at build time and revalidated incrementally as inventory or pricing changes. Saleor's GraphQL API is particularly efficient for storefronts because you can request exactly the fields needed for each page — a product listing page can request only name, thumbnail, and price, while a product detail page fetches the full variant matrix and description. This reduces over-fetching significantly compared to REST APIs. Vendure's Shop API also uses GraphQL and supports the same field-selection optimization, though its default configuration is more opinionated about shop-API field availability than Saleor's more permissive approach.

Community Ecosystem and Plugin Availability

The maturity of each platform's plugin and extension ecosystem affects how much custom development is required for a production deployment. Medusa's module registry has grown substantially since v2's release, covering loyalty programs, reviews, wishlists, and multi-vendor marketplace features. The Medusa module pattern makes it possible to install, configure, and combine these without modifying core code. Saleor's app ecosystem operates through the Saleor Cloud App Store and includes integrations with major analytics, CRM, shipping, and tax platforms — importantly, these apps run as separate services that communicate with Saleor through webhooks and the app API, so third-party apps don't require access to your Saleor instance's internal database. Vendure's plugin registry is smaller but growing, with a focus on production-ready plugins maintained by the core team and a clear contribution guide for community plugins that follow the NestJS module pattern.

Inventory and Order Management Architecture

The order management system is the operational core of any commerce platform, and the differences between these platforms' approaches affect how your team handles fulfillment, returns, and inventory synchronization. Medusa v2's commerce modules include dedicated inventory and stock location modules that track inventory across multiple warehouses and fulfillment centers, a requirement for any merchant managing physical goods across more than one location. Inventory reservations are created automatically when orders are placed and released when orders are cancelled, preventing overselling without requiring manual stock management. Saleor's warehousing system similarly supports multiple warehouses with allocation rules per shipping zone, and the warehouse configuration integrates with Saleor's shipping method system to determine which warehouse fulfills each order. Vendure's stock management is handled through the StockLocation entity with configurable allocation strategies, allowing custom logic for determining which location fulfills an order when multiple locations have inventory. All three platforms support backorders and pre-orders through configuration, but the mechanism differs: Medusa uses explicit allow-backorder settings per product variant, while Saleor and Vendure configure backorder behavior per warehouse allocation policy.


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 →

See also: AVA vs Jest and Cal.com vs Calendly vs Nylas 2026, change-case vs camelcase vs slugify.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.