Developing an E-commerce Store on Vendure
Vendure is a headless commerce framework on NestJS and TypeScript with GraphQL API. Unlike commercetools, it's an open-source solution with self-hosted deployment: you control the infrastructure, data, and can modify the core through plugins without forks. Architecture is based on NestJS modules, making extension predictable.
When Vendure is the Right Choice
- Need self-hosted: data stays in your infrastructure
- Require deep customization of business logic (taxes, shipping, promos)
- TypeScript is your team's primary language
- Budget doesn't allow for SaaS platforms ($500+/month on commercetools)
- Need control over database schema (PostgreSQL/MySQL)
Project Architecture
vendure-project/
├── src/
│ ├── vendure-config.ts # Main config
│ ├── plugins/ # Custom plugins
│ │ ├── loyalty/
│ │ ├── b2b-pricing/
│ │ └── erp-sync/
│ ├── email-handlers/ # Email templates
│ └── payment-handlers/ # Payment handlers
├── storefront/ # Next.js / Nuxt
└── docker-compose.yml
Vendure Configuration
// src/vendure-config.ts
import { VendureConfig } from "@vendure/core";
import { defaultEmailHandlers, EmailPlugin } from "@vendure/email-plugin";
import { AssetServerPlugin } from "@vendure/asset-server-plugin";
import { AdminUiPlugin } from "@vendure/admin-ui-plugin";
export const config: VendureConfig = {
apiOptions: {
port: 3000,
adminApiPath: "admin-api",
shopApiPath: "shop-api",
adminApiPlayground: process.env.NODE_ENV === "development",
},
authOptions: {
tokenMethod: ["bearer", "cookie"],
superadminCredentials: {
identifier: process.env.SUPERADMIN_USERNAME!,
password: process.env.SUPERADMIN_PASSWORD!,
},
cookieOptions: {
secret: process.env.COOKIE_SECRET!,
},
},
dbConnectionOptions: {
type: "postgres",
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
database: process.env.DB_NAME,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
synchronize: false, // only migrations in production
migrations: ["dist/migrations/*.js"],
},
paymentOptions: {
paymentMethodHandlers: [stripePaymentHandler, yookassaPaymentHandler],
},
taxOptions: {
taxCalculationStrategy: new CustomTaxCalculationStrategy(),
},
shippingOptions: {
shippingCalculators: [defaultShippingCalculator, tieredShippingCalculator],
shippingEligibilityCheckers: [defaultShippingEligibilityChecker],
fulfillmentHandlers: [manualFulfillmentHandler],
},
plugins: [
AssetServerPlugin.init({
route: "assets",
assetUploadDir: path.join(__dirname, "../static/assets"),
}),
EmailPlugin.init({
devMode: process.env.NODE_ENV === "development",
handlers: defaultEmailHandlers,
templatePath: path.join(__dirname, "../email/templates"),
transport: {
type: "smtp",
host: process.env.SMTP_HOST!,
port: 587,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
},
}),
AdminUiPlugin.init({
route: "admin",
port: 3002,
}),
LoyaltyPlugin,
B2bPricingPlugin,
ErpSyncPlugin,
],
};
Channels Model and Multi-Tenancy
Vendure supports Channels — multi-tenancy analog. One instance serves multiple stores with separate catalog, prices, and orders:
// Channel is created via Admin API or seed script
await channelService.create(ctx, {
code: "ru-channel",
token: "ru-token-abc123",
defaultCurrencyCode: CurrencyCode.RUB,
defaultLanguageCode: LanguageCode.ru,
defaultTaxZone: taxZoneRU,
defaultShippingZone: shippingZoneRU,
pricesIncludeTax: false,
});
Each Shop API request must include the vendure-token: <channel-token> header.
Checkout Flow via Shop API
# 1. Add item to order
mutation AddToOrder($productVariantId: ID!, $quantity: Int!) {
addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
... on Order {
id
code
totalWithTax
lines {
id
quantity
productVariant { name sku }
unitPriceWithTax
}
}
... on ErrorResult {
errorCode
message
}
}
}
# 2. Set shipping address
mutation SetShippingAddress($input: CreateAddressInput!) {
setOrderShippingAddress(input: $input) {
... on Order { id shippingAddress { fullName streetLine1 city } }
... on NoActiveOrderError { errorCode message }
}
}
# 3. Get shipping methods and select
query GetShippingMethods {
eligibleShippingMethods {
id
name
price
priceWithTax
description
}
}
mutation SetShippingMethod($id: [ID!]!) {
setOrderShippingMethod(shippingMethodId: $id) {
... on Order { id shipping shippingWithTax }
}
}
Payment Integration (YooKassa)
// src/payment-handlers/yookassa.handler.ts
import { CreatePaymentResult, PaymentMethodHandler, LanguageCode } from "@vendure/core";
export const yookassaPaymentHandler = new PaymentMethodHandler({
code: "yookassa",
description: [{ languageCode: LanguageCode.ru, value: "YooKassa" }],
args: {
shopId: { type: "string" },
secretKey: { type: "string", ui: { component: "password-form-input" } },
},
async createPayment(ctx, order, amount, args, metadata): Promise<CreatePaymentResult> {
const yookassa = new YooKassa({
shopId: args.shopId,
secretKey: args.secretKey,
});
const payment = await yookassa.createPayment({
amount: {
value: (amount / 100).toFixed(2),
currency: order.currencyCode,
},
capture: true,
confirmation: {
type: "redirect",
return_url: `${process.env.SHOP_URL}/checkout/confirmation`,
},
description: `Order #${order.code}`,
metadata: { vendure_order_id: order.id },
});
return {
amount,
state: "Authorized",
transactionId: payment.id,
metadata: { confirmationUrl: payment.confirmation.confirmation_url },
};
},
async settlePayment(ctx, order, payment, args) {
// YooKassa with capture=true — payment is charged automatically
return { success: true };
},
async refundPayment(ctx, order, payment, args, lines, adjustment) {
const yookassa = new YooKassa({ shopId: args.shopId, secretKey: args.secretKey });
const refund = await yookassa.createRefund(payment.transactionId, {
amount: { value: (adjustment / 100).toFixed(2), currency: order.currencyCode },
});
return { state: "Settled", transactionId: refund.id };
},
});
Performance and Scaling
Vendure supports Worker/Server separation: heavy tasks (email, export, indexing) are processed in a separate Worker process via Bull:
// server.ts
import { bootstrap } from "@vendure/core";
bootstrap(config);
// worker.ts — launched separately
import { bootstrapWorker } from "@vendure/core";
bootstrapWorker(config);
For Production: 2+ server instances (load balanced), 1+ worker, Redis for queues.
Development Stages and Timeframes
| Stage | Timeframe |
|---|---|
| Installation, configuration, DB, migrations | 2–3 days |
| Catalog import (Products, Variants, Assets) | 3–7 days |
| Custom plugins (taxes, shipping, promos) | 5–10 days |
| Storefront (Next.js + GraphQL) | 10–20 days |
| Payment integrations (2–3 providers) | 4–6 days |
| Admin UI customization | 2–4 days |
| Total | 26–50 days |







