Skip to main content

Building a CMS Integration Plugin

A CMS integration plugin allows you to automatically synchronize your Vendure product catalog with an external Content Management System.

This is done in a way that establishes Vendure as the source of truth for the ecommerce's data.

This guide demonstrates how to build a production-ready CMS integration plugin. The principles covered here are designed to be CMS-agnostic, however we do have working examples for various platforms.

Platfroms covered in the guide:

Working Example Repository

info

This guide provides a high-level overview of building a CMS integration plugin. For complete implementations, refer to working examples repository

The code examples in this guide are simplified for educational purposes. The actual implementations contain additional features like error handling, retry logic, and performance optimizations.

Prerequisites

  • Node.js 20+ with npm package manager
  • An existing Vendure project created with the Vendure create command
  • An access key to a CMS platform that provides an API

Core Concepts

This plugin leverages several key Vendure concepts:

  • EventBus: Provides real-time notifications when entities are created, updated, or deleted.
  • Job Queues: Ensures that synchronization tasks are performed reliably and asynchronously, with retries on failure.
  • Plugin API: The foundation for extending Vendure with custom capabilities.

How It Works

The CMS integration follows a simple event-driven flow:

This ensures reliable, asynchronous synchronization with built-in retry capabilities.

Plugin Structure and Types

First, let's use the Vendure CLI to scaffold the basic plugin structure:

npx vendure add -p CmsPlugin

This command will create the basic plugin structure. Next, we'll generate the required services:

# Generate the main sync service
npx vendure add -s CmsSyncService --selected-plugin CmsPlugin

# Generate the CMS-specific service (replace with your CMS)
npx vendure add -s CmsSpecificService --selected-plugin CmsPlugin
# Explained later in the Event-Driven Synchronization Section

Now we start by defining the main plugin class, its services, and the configuration types.

Plugin Definition

The CmsPlugin class registers the necessary services (CmsSyncService, CmsSpecificService) and sets up any Admin API extensions.

src/plugins/cms/cms.plugin.ts
import { VendurePlugin, PluginCommonModule, Type, OnModuleInit } from '@vendure/core';
import { CmsSyncService } from './services/cms-sync.service';
import { CmsSpecificService } from './services/cms-specific.service';
import { PluginInitOptions, CMS_PLUGIN_OPTIONS } from './types';
// ...

@VendurePlugin({
imports: [PluginCommonModule],
providers: [
{ provide: CMS_PLUGIN_OPTIONS, useFactory: () => CmsPlugin.options },
CmsSyncService,
CmsSpecificService, // The service for the specific CMS platform
],
// ...
})
export class CmsPlugin {
static options: PluginInitOptions;

static init(options: PluginInitOptions): Type<CmsPlugin> {
this.options = options;
return CmsPlugin;
}
}

Configuration Types

The plugin's configuration options are defined in a types.ts file.

Create this file in your plugin directory to define the interfaces. These options will be passed to the plugin from your vendure-config.ts.

note

This would be created for you automatically when you run the CLI command npx vendure add

src/plugins/cms/types.ts
import { ID, InjectionToken } from '@vendure/core';

export interface PluginInitOptions {
cmsApiKey?: string;
CmsSpecificOptions?: any;
retryAttempts?: number;
retryDelay?: number;
}

export interface SyncJobData {
entityType: 'Product' | 'ProductVariant' | 'Collection';
entityId: ID;
operationType: 'create' | 'update' | 'delete';
timestamp: string;
retryCount: number;
}

export interface SyncResponse {
success: boolean;
message?: string;
error?: string;
}

Event-Driven Synchronization

The plugin uses Vendure's EventBus to capture changes in real-time.

In the onModuleInit lifecycle hook, we create job queues and subscribe to entity events.

Creating Job Queues and Subscribing to Events

You can also scaffold job queue handlers using the CLI:

npx vendure add -j CmsProductSync --name productSyncQueue --selected-service CmsSyncService

This creates a productSyncQueue in the CmsSyncService. The service will be responsible for setting up the queues and processing the jobs. It will expose public methods to trigger new jobs.

src/plugins/cms/services/cms-sync.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { JobQueue, JobQueueService /* ... other imports */ } from '@vendure/core';
import { SyncJobData } from '../types';

@Injectable()
export class CmsSyncService implements OnModuleInit {
private productSyncQueue: JobQueue<SyncJobData>;
private variantSyncQueue: JobQueue<SyncJobData>;
private collectionSyncQueue: JobQueue<SyncJobData>;

constructor(
private jobQueueService: JobQueueService,
// ... other dependencies
) {}

async onModuleInit() {
this.productSyncQueue = await this.jobQueueService.createQueue({
name: 'cms-product-sync',
process: async job => {
return this.syncProductToCms(job.data);
},
});

this.variantSyncQueue = await this.jobQueueService.createQueue({
name: 'cms-variant-sync',
process: async job => {
return this.syncVariantToCms(job.data);
},
});

this.collectionSyncQueue = await this.jobQueueService.createQueue({
name: 'cms-collection-sync',
process: async job => {
return this.syncCollectionToCms(job.data);
},
});
}

triggerProductSync(data: SyncJobData) {
return this.productSyncQueue.add(data);
}

triggerVariantSync(data: SyncJobData) {
return this.variantSyncQueue.add(data);
}

triggerCollectionSync(data: SyncJobData) {
return this.collectionSyncQueue.add(data);
}

// ... other methods for the actual sync logic (e.g. syncProductToCms)
}

Next, in the CmsPlugin, we subscribe to the EventBus and call the new service method to add a job to the queue whenever a relevant event occurs.

src/plugins/cms/cms.plugin.ts
import { OnModuleInit, EventBus, ProductEvent, VendurePlugin } from '@vendure/core';
import { CmsSyncService } from './services/cms-sync.service';

@VendurePlugin({
// ...
providers: [CmsSyncService /* ... */],
})
export class CmsPlugin implements OnModuleInit {
constructor(
private eventBus: EventBus,
private cmsSyncService: CmsSyncService,
) {}

async onModuleInit() {
// Listen for Product events
this.eventBus.ofType(ProductEvent).subscribe(event => {
const syncData = this.extractSyncData(event);
this.cmsSyncService.triggerProductSync(syncData);
});

// Similar listeners for ProductVariantEvent and CollectionEvent...
}
// ...
}

Implementing the Sync Logic

The sync logic is split into two services: a generic service to fetch data, and a specific service to communicate with the CMS.

CmsSyncService orchestrates the synchronization logic. It acts as the bridge between Vendure's internal systems and your CMS platform, handling data fetching, relationship resolution, and error management.

tip

Separating orchestration logic from CMS-specific API calls allows for better testability and maintainability. The sync service handles Vendure-specific operations while CMS services focus on API communication.

Core Responsibilities

The sync service handles several critical functions:

  • Entity Data Fetching: Retrieves complete entity data with necessary relations
  • Translation Management: Handles Vendure's multi-language support
  • Relationship Resolution: Manages complex entity relationships
  • Error Handling: Provides consistent error handling and logging

Service Structure and Dependencies

The service follows Vendure's dependency injection pattern and requires several core Vendure services:

src/plugins/cms/services/cms-sync.service.ts
@Injectable()
export class CmsSyncService implements OnApplicationBootstrap {
constructor(
@Inject(CMS_PLUGIN_OPTIONS) private options: PluginInitOptions,
private readonly connection: TransactionalConnection,
private readonly channelService: ChannelService,
private readonly collectionService: CollectionService,
private readonly requestContextService: RequestContextService,
// Your CMS-specific service
private readonly CmsSpecificService: CmsSpecificService,
private processContext: ProcessContext,
) {}

async onApplicationBootstrap() {
// Ensure this logic only runs on the Vendure worker, and not the server
if (this.processContext.isWorker) {
// This is where you would add any necessary setup or initialization logic
// for example, ensuring that the CMS has compatible content types.
}
}
}

Product Synchronization

Product sync demonstrates the complete workflow from job processing to CMS communication:

async syncProductToCms(jobData: SyncJobData): Promise<SyncResponse> {
try {
// Fetch fresh product data from database with translations
const product = await this.connection.rawConnection
.getRepository(Product)
.findOne({
where: { id: jobData.entityId },
relations: { translations: true },
});

if (!product) {
throw new Error(`Product with ID ${jobData.entityId} not found`);
}

const operationType = jobData.operationType;
const defaultLanguageCode = await this.getDefaultLanguageCode();

// Get product slug using translation utilities
const productSlug = this.translationUtils.getSlugByLanguage(
product.translations,
defaultLanguageCode,
);

// Delegate to CMS-specific service
await this.CmsSpecificService.syncProduct({
product,
defaultLanguageCode,
operationType,
productSlug,
});

return {
success: true,
message: `Product ${jobData.operationType} synced successfully`,
timestamp: new Date(),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
Logger.error(`Product sync failed: ${errorMessage}`, error.stack);
return {
success: false,
message: `Product sync failed: ${errorMessage}`,
};
}
}

Relationship Handling

The service includes methods to resolve entity relationships. For example, finding collections that contain a specific variant:

async findCollectionsForVariant(
variantId: string | number,
): Promise<Collection[]> {
try {
const variant = await this.connection.rawConnection
.getRepository(ProductVariant)
.findOne({
where: {
id: variantId,
},
relations: ["collections"],
});

return variant?.collections || [];

} catch (error) {
Logger.error(
`Failed to find collections for variant ${variantId}`,
String(error),
);
return [];
}
}
return collectionsWithVariant;
} catch (error) {
// Variants can have no collecitons, therefore, sync anyways.
Logger.error(`Failed to find collections for variant ${variantId}`, String(error));
return [];
}
}
Additional Entity Types

The service can also includes syncVariantToCms() and syncCollectionToCms() methods that follow the same pattern as the product sync shown above.

These implementations are omitted from this guide for brevity, but they handle their respective entity types with similar data fetching, relationship resolution, and error handling patterns.

The complete implementations can be found in the working example repositories.

This sync service provides the foundation for handling all Vendure-specific complexity while delegating CMS API communication to specialized services.

Platform specific setup

Working Example

The complete, production-ready Storyblok implementation can be found in the Storyblok integration example. Refer to it for a minimal working implementation.

Setting up Storyblok Space

1. Create a Storyblok Account and Space

  1. Sign up at storyblok.com if you don't have an account
  2. Create a new Space (equivalent to a project in Storyblok)
  3. Choose a suitable plan based on your needs

2. Get Your API Credentials

  1. Navigate to Settings → Access Tokens in your Storyblok space
  2. Create a new Management API Token with write permissions
  3. Note down your Space ID (found in Settings → General)

3. Configure Environment Variables

Add these variables to your .env file:

STORYBLOK_API_KEY=your_management_api_token
STORYBLOK_SPACE_ID=your_space_id

The Storyblok Service

StoryblokService handles all Storyblok-specific operations including API communication, content type management, and data transformation. Key features include:

  • Content Type Management: Automatically creates Vendure-specific content types (components) in Storyblok
  • Story Management: CRUD operations for stories representing products, variants, and collections
  • Relationship Handling: Manages references between products, variants, and collections

Basic Service Structure

The service follows Vendure's standard dependency injection pattern and implements OnApplicationBootstrap to ensure Storyblok is properly configured before handling sync operations.

src/plugins/cms/services/storyblok.service.ts
// Define component types as constants for consistency and maintainability
const COMPONENT_TYPE = {
product: 'vendure_product',
product_variant: 'vendure_product_variant',
collection: 'vendure_collection',
};

@Injectable()
export class StoryblokService implements OnApplicationBootstrap {
constructor(@Inject(CMS_PLUGIN_OPTIONS) private options: PluginInitOptions) {}

async onApplicationBootstrap() {
// This is where you would add any necessary setup or initialization logic
// for example, ensuring that the CMS has compatible content types.
}

// Main entry point for product synchronization
// The operationType determines which CRUD operation to perform
async syncProduct({ product, defaultLanguageCode, operationType }) {
switch (operationType) {
case 'create':
return this.createStoryFromProduct(product, defaultLanguageCode);
case 'update':
return this.updateStoryFromProduct(product, defaultLanguageCode);
case 'delete':
return this.deleteStoryFromProduct(product, defaultLanguageCode);
}
}
}

Making API Requests

All Storyblok API communication is centralized through a single method that handles authentication, error handling, and response parsing. This approach ensures consistent behavior across all operations.

private async makeStoryblokRequest({ method, endpoint, data }) {
// Construct the full API URL using the configured space ID
const url = `https://mapi.storyblok.com/v1/spaces/${this.options.storyblokSpaceId}/${endpoint}`;

const response = await fetch(url, {
method,
headers: {
Authorization: this.options.storyblokApiKey,
'Content-Type': 'application/json',
},
// Only include request body for POST/PUT operations
body: data ? JSON.stringify(data) : undefined,
});

if (!response.ok) {
// Provide clear error messages for debugging API issues
throw new Error(`Storyblok API error: ${response.status}`);
}
return response.json();
}

Data Transformation

Transformation methods convert Vendure entities into the format expected by Storyblok's API. The content object structure must match the component schema defined in Storyblok.

private async transformProductData(product, defaultLanguageCode, productSlug?) {
// Extract the translation for the default language
// Vendure stores translations in an array, so we need to find the correct one
const translation = product.translations.find(
t => t.languageCode === defaultLanguageCode
);

if (!translation) {
return undefined; // Skip if no translation exists
}

// Find all variant story UUIDs for this product using relationship handling
const variantStoryIds = await this.findVariantStoriesForProductUuids(
product.id,
defaultLanguageCode,
productSlug,
);

return {
story: {
name: translation?.name, // Story name in Storyblok
slug: translation?.slug, // URL slug for the story
content: {
component: COMPONENT_TYPE.product, // Must match the component name in Storyblok
vendureId: product.id.toString(), // Store Vendure ID for reference
variants: variantStoryIds, // Array of story UUIDs for product variants
},
},
publish: 1, // Auto-publish the story (1 = published, 0 = draft)
};
}

Relationship Handling

One of the most important aspects of CMS integration is maintaining relationships between entities. Products have variants, variants belong to collections, and these relationships need to be reflected in the CMS. Storyblok uses story UUIDs to create references between content pieces.

  • Finding Related Entities

First, we need methods to query the Vendure database for related entities:

// Find all product variants for a given product ID from the database
private async findProductVariants(productId: string | number): Promise<ProductVariant[]> {
try {
return await this.connection.rawConnection
.getRepository(ProductVariant)
.find({
where: { productId: productId as any },
relations: ['translations'], // Include translations for slug generation
order: { id: 'ASC' },
});
} catch (error) {
Logger.error(`Failed to find variants for product ${productId}`, String(error));
return [];
}
}
  • Batch Story Lookups

We can also use batch lookups to find multiple stories at once:

// Batch lookup method for efficient story retrieval
private async findStoriesBySlugs(slugs: string[]): Promise<Map<string, any>> {
const storyMap = new Map<string, any>();
if (slugs.length === 0) return storyMap;

try {
// Storyblok supports comma-separated slugs for batch lookup
const slugsParam = slugs.join(',');
const response = await this.makeStoryblokRequest({
method: 'GET',
endpoint: `stories?by_slugs=${slugsParam}`,
});

if (response.stories) {
for (const story of response.stories) {
storyMap.set(story.slug, story);
}
}
} catch (error) {
Logger.error(`Failed to find stories by slugs: ${slugs.join(', ')}`, String(error));
}

return storyMap;
}
  • Building Relationships

Finally, we combine database queries with CMS lookups to build relationships:

// Find variant stories using batch lookup for efficiency
private async findVariantStoriesForProductUuids(
productId: string | number,
defaultLanguageCode: LanguageCode,
productSlug?: string | null,
): Promise<string[]> {
if (!productSlug) return [];
// Get all variants for this product from Vendure database
const variants = await this.findProductVariants(productId);
// Generate slugs for all variants (convention: product-slug-variant-id)
const variantSlugs = variants.map(
(variant) => `${productSlug}-variant-${variant.id}`,
);
if (variantSlugs.length === 0) return [];
// Batch lookup all variant stories and extract UUIDs
const storiesMap = await this.findStoriesBySlugs(variantSlugs);
const storyUuids: string[] = [];

for (const [slug, story] of storiesMap) {
if (story?.uuid) {
storyUuids.push(story.uuid.toString()); // Storyblok uses UUIDs for references
}
}
return storyUuids;
}
// Example: Transform variant data with relationships
private async transformVariantData(
variant: ProductVariant,
defaultLanguageCode: LanguageCode,
variantSlug: string,
collections?: Collection[],
) {
const translation = variant.translations.find(
t => t.languageCode === defaultLanguageCode
);
if (!translation) return undefined;

// Find parent product and collection references using the same batch lookup patterns
const parentProductStoryUuid = await this.findParentProductStoryUuid(variant, defaultLanguageCode);
const collectionStoryUuids = await this.findCollectionStoryUuids(collections, defaultLanguageCode);

return {
story: {
name: translation.name,
slug: variantSlug,
content: {
component: COMPONENT_TYPE.product_variant,
vendureId: variant.id.toString(),
parentProduct: parentProductStoryUuid ? [parentProductStoryUuid] : [],
collections: collectionStoryUuids,
},
},
publish: 1,
};
}

// Additional relationship methods like findParentProductStoryUuid and findCollectionStoryUuids
// follow similar patterns and are available in the working example repository.

CRUD Operations

These methods handle the basic Create, Read, Update, and Delete operations for stories in Storyblok. They follow REST API conventions and leverage the centralized request method for consistent behavior.

// Create a new story in Storyblok
private async createStoryFromProduct(product, defaultLanguageCode) {
// Transform Vendure product data into Storyblok story format
const data = this.transformProductData(product, defaultLanguageCode);

// POST to the stories endpoint to create a new story
return this.makeStoryblokRequest({
method: 'POST',
endpoint: 'stories',
data,
});
}

// Find an existing story by its slug
private async findStoryBySlug(slug: string) {
// Use Storyblok's by_slugs query parameter for efficient lookup
const response = await this.makeStoryblokRequest({
method: 'GET',
endpoint: `stories?by_slugs=${slug}`,
});

// Return the matching story or undefined if not found
return response.stories.find((story: any) => story.slug === slug);
}

// Additional CRUD methods like updateStoryFromProduct and deleteStoryFromProduct
// follow similar patterns with PUT and DELETE HTTP methods respectively.
// Full implementations are available in the working example repository.

Final Plugin Configuration

src/vendure-config.ts
CmsPlugin.init({
cmsApiKey: process.env.STORYBLOK_API_KEY,
storyblokSpaceId: process.env.STORYBLOK_SPACE_ID,
}),

This setup provides a complete Storyblok CMS integration that automatically creates the necessary content types and syncs your Vendure catalog with structured content in Storyblok.

The complete implementations can be found in the working example repositories.

Admin API Integration

To allow for manual synchronization through graphQL mutations, we can extend the Admin API with new mutations. Let's use the CLI to generate the API extensions:

# Generate API extensions for the CMS plugin
npx vendure add -a CmsSyncAdminResolver --selected-plugin CmsPlugin

Extending the GraphQL API

src/plugins/cms/api/api-extensions.ts
export const adminApiExtensions = gql`
extend type Mutation {
syncProductToCms(productId: ID!): SyncResponse!
syncCollectionToCms(collectionId: ID!): SyncResponse!
}
// ...
`;

Implementing the Resolver

The resolver for these mutations re-uses the existing CmsSyncService to add a job to the queue.

src/plugins/cms/api/cms-sync-admin.resolver.ts
@Resolver()
export class CmsSyncAdminResolver {
constructor(private cmsSyncService: CmsSyncService) {}

@Mutation()
@Allow(Permission.UpdateCatalog)
async syncProductToCms(@Args() args: { productId: ID }): Promise<SyncResponse> {
// This creates the data payload for the job
const syncData: SyncJobData = {
entityType: 'Product',
entityId: args.productId,
operationType: 'update', // Manual syncs are usually 'update'
timestamp: new Date().toISOString(),
retryCount: 0,
};
// The service method adds the job to the queue
await this.cmsSyncService.triggerProductSync(syncData);

return {
success: true,
message: `Successfully queued sync for product ${args.productId}.`,
};
}

// ... resolver for collection sync
}

Final Configuration

Finally, add the plugin to your vendure-config.ts file with the appropriate configuration for your chosen CMS platform.

src/vendure-config.ts
import { VendureConfig } from '@vendure/core';
import { CmsPlugin } from './plugins/cms/cms.plugin';

export const config: VendureConfig = {
// ... other config
plugins: [
// ... other plugins
CmsPlugin.init({
// Configure based on your chosen CMS platform
// See platform-specific tabs above for exact configuration
cmsApiKey: process.env.CMS_API_KEY,
// Additional CMS-specific options...
}),
],
};

Refer to the platform-specific configuration examples in the tabs above for the exact environment variables and options needed for your chosen CMS.

For complete, production-ready implementations, see the working examples: