- How to Build E‑commerce with Payload CMS: Collections, Products, Variants
How to Build E‑commerce with Payload CMS: Collections, Products, Variants
Build an e‑commerce in Payload CMS — collections, products, and variants.

When I started building my e-commerce platform, I thought I'd begin simple: just products with basic information. But as any developer who's built a real-world e-commerce system knows, "simple" doesn't stay simple for long.
Customers wanted product categories. Then they wanted product variants (different colors, sizes, materials). Then they wanted rich product descriptions, image galleries, technical specifications, and inventory tracking. What started as a basic product list evolved into a sophisticated e-commerce database that needed to handle complex relationships while maintaining data integrity.
Our challenge: how do we build a scalable e‑commerce system from the database up—using modern tools—without painting ourselves into a corner? In this guide, we take that journey together and make the trade‑offs explicit so the system stays enjoyable to build today and dependable to run tomorrow.
What We're Building
In the pages ahead, we’ll assemble a complete e‑commerce data layer together. We start with collections that organize products and carry rich, SEO‑friendly content. We add a comprehensive products model that keeps editors productive with a clear, tabbed interface. We introduce an inheritance‑based variant system so each product can declare its option types and every variant is validated against that structure. Around it all, we adopt a migration‑first workflow to evolve the schema safely and add focused hooks that keep data healthy by enforcing SKU uniqueness, synchronizing relationships, and filling useful display values automatically.
Why This Architecture Matters
Many tutorials stop at a single products table, but real stores demand more. We need categorization that grows with your catalogue and content strategy; variants that go beyond color and size to support whatever options your products require; integrity guarantees so SKUs don’t collide and relationships remain consistent; and production‑grade safety so database changes are reviewed, reversible, and won’t take a live site down.
Tech Stack & Key Decisions
We’ll use Payload CMS for a modern, code‑first developer experience with strong TypeScript support, and PostgreSQL (via Neon) for a fast, serverless database that pairs well with Vercel. We’ll manage change with migrations—not auto‑push—so every schema update is versioned, reviewable, and reversible, and we’ll rely on TypeScript across the stack for predictable, self‑documenting code.
🎯 Why Neon PostgreSQL? Free serverless PostgreSQL with 10 instances, seamless Vercel integration, and all the power of PostgreSQL without server management.
Database Architecture Overview
Before we dive into implementation, let's understand the complete system architecture we're building:
Entity Relationships
Collections (1) ──→ (many) Products (1) ──→ (many) ProductVariants
↑ ↑ ↑
Categories Main Products Color/Size/etc
Rich Content Complex Structure Dynamic Options
Collections serve as product categories with rich content capabilities (descriptions, images, SEO). Products are the main catalog items with comprehensive information organized in tabs. ProductVariants provide flexible options (color, size, material, etc.) with validation.
Why This Structure Works
- Scalable Categorization - Collections can grow from simple categories to complex content hubs
- Flexible Variants - Products define what variant types they support, variants inherit this structure
- Data Integrity - Foreign keys and validation hooks keep everything in sync
- Admin Experience - Organized, intuitive interface for content managers
Core Design Principles
- Migration-First Development: Every schema change is version controlled and reversible
- Inheritance-Based Variants: Products define variant structure, variants validate against it
- Automatic Data Maintenance: Hooks handle relationship updates and cleanup
- Production Safety: Never break existing data, always have rollback plans
Building the E-commerce System
Step 1: Collections - Product Categorization
What We're Building
Collections serve as the foundation of our product organization system. Think of them as sophisticated product categories that can hold rich content, images, and SEO information.
Why Start with Collections
Collections are the foundation that everything else depends on. Products need to belong to collections, so we build this first. It's also the simplest entity, making it perfect for understanding Payload patterns.
Collections Implementation
Now we'll build the Collections entity that serves as our product categorization system. This collection will handle rich content, SEO-friendly URLs, and admin organization features that real e-commerce sites require.
// src/collections/Collections/index.ts
import { superAdminOrTenantAdminAccess } from '@/access/superAdminOrTenantAdmin';
import { CollectionConfig, Access } from 'payload';
import { slugField } from '@/fields/slug';
import {
HeadingFeature,
FixedToolbarFeature,
HorizontalRuleFeature,
InlineToolbarFeature,
lexicalEditor
} from '@payloadcms/richtext-lexical';
// Allow anyone to read collections (public facing)
const anyone: Access = () => true;
export const Collections: CollectionConfig = {
slug: 'collections',
labels: {
singular: 'Collection',
plural: 'Collections',
},
admin: {
useAsTitle: 'title',
description: 'Manage product collections and categories.',
group: 'Catalog', // Groups related collections in admin sidebar
defaultColumns: ['title', 'slug', 'isActive', 'updatedAt'],
},
access: {
read: anyone, // Public can read collections
create: superAdminOrTenantAdminAccess,
update: superAdminOrTenantAdminAccess,
delete: superAdminOrTenantAdminAccess,
},
fields: [
// Auto-generated slug for URLs
slugField('title', {
label: 'Slug / URL Path',
unique: true,
index: true,
admin: {
description: 'Auto-generated from title, or set manually for SEO',
position: 'sidebar',
}
}),
{
name: 'title',
type: 'text',
label: 'Collection Title',
required: true,
localized: true, // Support multiple languages if needed
},
{
name: 'description',
type: 'richText',
label: 'Collection Description',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
]
},
}),
},
{
name: 'image',
type: 'upload',
relationTo: 'media', // References media collection
label: 'Collection Image',
admin: {
description: 'Featured image representing this collection',
}
},
{
name: 'isActive',
type: 'checkbox',
label: 'Active',
defaultValue: true,
admin: {
description: 'Whether this collection is visible on the website',
position: 'sidebar',
}
},
{
name: 'sortOrder',
type: 'number',
label: 'Sort Order',
admin: {
description: 'Display order (lower numbers appear first)',
position: 'sidebar',
}
}
],
};
Understanding the Collections Code
Slug Field Helper (slugField
):
- Automatically generates URL-friendly slugs from the title
- Can be manually overridden for SEO purposes
- Creates database index for fast lookups
- Reusable across different collections
Rich Text Editor (Lexical):
- Modern, extensible rich text editor
- Supports headings, formatting, and horizontal rules
- Stores content as structured JSON (not HTML)
- Enables consistent styling across your site
Access Control Pattern:
- Public read access (
anyone
) for frontend display - Admin only write access for content management
- Flexible pattern that works across all collections
Admin UI Organization:
group: 'Catalog'
organizes related collections in sidebarposition: 'sidebar'
for secondary fieldsdefaultColumns
customizes the list viewuseAsTitle
determines what shows in relationship fields
Testing Your Collections
- Generate the schema migration:
pnpm payload migrate:create
- Apply the migration:
pnpm payload migrate
- Test in admin UI:
- Create a few sample collections
- Test the rich text editor
- Verify slug auto-generation
- Check the sort order functionality
Step 2: Products - The Heart of Your E-commerce
What We're Building
Products are the core of any e-commerce system. Our product entity needs to handle everything from basic information to complex variant relationships, while providing an intuitive admin experience.
Why This Complexity
Real e-commerce products aren't simple. They need:
- Basic Information including title, description, and pricing
- Technical Specifications such as dimensions, materials, and features
- Media Management for main images and galleries
- Variant Preparation defining what types of variants are supported
- Inventory Tracking covering stock status and SKU management
The key insight: Products define what types of variants they can have, then variants inherit and validate against this structure.
Products Implementation
Here we'll create the comprehensive Products collection that forms the heart of our e-commerce system. This includes multi-tab organization, variant preparation, and all the fields needed for a professional product catalog.
// src/collections/Products.ts
import { superAdminOrTenantAdminAccess } from '@/access/superAdminOrTenantAdmin';
import { slugField } from '@/fields/slug';
import { CollectionConfig } from 'payload';
export const Products: CollectionConfig = {
slug: 'products',
labels: {
singular: 'Product',
plural: 'Products',
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'sku', 'collection', 'hasVariants', 'inStock'],
description: 'Manage your product catalog.',
group: 'Catalog',
listSearchableFields: ['title', 'sku', 'manufacturer'],
},
access: {
read: () => true, // Public read access
create: superAdminOrTenantAdminAccess,
update: superAdminOrTenantAdminAccess,
delete: superAdminOrTenantAdminAccess,
},
fields: [
// Sidebar Fields (always visible)
slugField('title', {
label: 'URL Slug',
unique: true,
index: true,
admin: {
description: 'Auto-generated from title, manually editable for SEO',
position: 'sidebar',
}
}),
{
name: 'collection',
type: 'relationship',
relationTo: 'collections',
required: true,
admin: {
position: 'sidebar',
description: 'Which collection this product belongs to',
},
},
{
name: 'hasVariants',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
description: 'Does this product have variants (color, size, etc.)?',
},
},
// The brilliant part: Define variant structure on the product
{
name: 'variantOptionTypes',
type: 'array',
admin: {
description: 'Define what variant types this product supports (e.g., Color, Size)',
condition: (data) => data.hasVariants === true, // Only show if hasVariants is true
position: 'sidebar',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
placeholder: 'e.g., color, size, material',
description: 'Database field name (lowercase, no spaces)',
},
},
{
name: 'label',
type: 'text',
required: true,
admin: {
placeholder: 'e.g., Color, Size, Material',
description: 'User-friendly label for admin interface',
},
},
],
},
// Main Content organized in tabs
{
type: 'tabs',
tabs: [
// Tab 1: Basic Information
{
label: 'Basic Information',
fields: [
{
type: 'row', // Display fields side by side
fields: [
{
name: 'title',
type: 'text',
label: 'Product Title',
required: true,
admin: { width: '50%' },
},
{
name: 'sku',
type: 'text',
label: 'SKU Code',
required: true,
unique: true,
admin: { width: '50%' },
},
],
},
{
type: 'row',
fields: [
{
name: 'manufacturer',
type: 'text',
label: 'Manufacturer',
admin: { width: '50%' },
},
{
name: 'type',
type: 'select',
label: 'Product Type',
options: [
{ label: 'Electronics', value: 'electronics' },
{ label: 'Clothing', value: 'clothing' },
{ label: 'Home & Garden', value: 'home-garden' },
],
admin: { width: '50%' },
},
],
},
{
name: 'shortDescription',
type: 'textarea',
label: 'Short Description',
maxLength: 160,
admin: {
placeholder: 'Brief description for search results (max 160 chars)',
},
},
{
name: 'longDescription',
type: 'richText',
label: 'Detailed Description',
},
// Show related variants (read-only relationship)
{
name: 'productVariants',
type: 'join',
collection: 'product-variants',
on: 'product', // Join on the product field in variants
admin: {
description: 'Variants for this product - manage in ProductVariants section',
},
},
],
},
// Tab 2: Pricing & Availability
{
label: 'Pricing & Inventory',
fields: [
{
type: 'row',
fields: [
{
name: 'price',
type: 'number',
label: 'Base Price (€)',
min: 0,
admin: {
width: '50%',
step: 0.01,
description: 'Base price - variants can override this',
},
},
{
name: 'inStock',
type: 'checkbox',
label: 'In Stock',
defaultValue: true,
admin: { width: '50%' },
},
],
},
],
},
// Tab 3: Technical Specifications
{
label: 'Specifications',
fields: [
{
name: 'technicalSpecs',
type: 'array',
label: 'Technical Specifications',
minRows: 0,
maxRows: 20,
admin: {
description: 'Add technical specifications as key-value pairs',
},
fields: [
{
type: 'row',
fields: [
{
name: 'label',
type: 'text',
label: 'Specification Name',
required: true,
admin: {
width: '40%',
placeholder: 'e.g., Weight, Dimensions, Material',
},
},
{
name: 'value',
type: 'text',
label: 'Value',
required: true,
admin: {
width: '60%',
placeholder: 'e.g., 2.5 kg, 30x20x10 cm, Aluminum',
},
},
],
},
],
},
],
},
// Tab 4: Media & Marketing
{
label: 'Images & Marketing',
fields: [
{
name: 'image',
type: 'upload',
label: 'Main Product Image',
relationTo: 'media',
required: false,
admin: {
description: 'Primary image shown in product listings',
},
},
{
name: 'gallery',
type: 'upload',
relationTo: 'media',
hasMany: true, // Multiple images
label: 'Product Gallery',
},
{
name: 'highlights',
type: 'array',
label: 'Key Features',
minRows: 0,
maxRows: 6,
admin: {
description: 'Bullet points highlighting key features',
},
fields: [
{
name: 'highlight',
type: 'text',
label: 'Feature',
required: true,
maxLength: 100,
},
],
},
],
},
],
},
],
};
Understanding the Products Code
Relationship to Collections:
{
name: 'collection',
type: 'relationship',
relationTo: 'collections',
required: true,
}
This creates a foreign key relationship. Each product must belong to exactly one collection, but collections can have many products.
The Variant Strategy - Key Innovation:
{
name: 'variantOptionTypes',
type: 'array',
admin: {
condition: (data) => data.hasVariants === true,
},
fields: [
{ name: 'name', type: 'text' }, // e.g., "color"
{ name: 'label', type: 'text' }, // e.g., "Color"
],
}
Why this is good:
- Products define what variant types they support
- Variants validate against this structure
- Flexible: T-shirts can have "color, size" while electronics have "storage, color"
- Consistent: All variants of a product follow the same structure
Tab Organization:
- Basic Information: Core product data that everyone needs
- Pricing & Inventory: Business critical information
- Specifications: Technical details for detailed product pages
- Images & Marketing: Visual content and feature highlights
Join Fields for Relationships:
{
name: 'productVariants',
type: 'join',
collection: 'product-variants',
on: 'product',
}
This shows related variants in the product admin without storing duplicate data. It's a read only view of the relationship.
Testing Your Products
- Generate and apply migration:
pnpm payload migrate:create pnpm payload migrate
- Test the admin interface:
- Create a product and assign it to a collection
- Try enabling variants and defining option types
- Test the tab navigation and field organization
- Upload images and add technical specifications
Step 3: Product Variants - The Advanced System
What We're Building
Product variants handle the complexity of products that come in different options - colors, sizes, materials, storage capacities, etc. Our system is designed to be both flexible and validated.
The Inheritance Strategy
Here's the key insight that makes our variant system powerful:
- Products define what variant option types they support
- Variants inherit this structure and must conform to it
- Validation ensures variants can't have invalid options
Example:
- T-Shirt Product defines:
[{name: "color", label: "Color"}, {name: "size", label: "Size"}]
- T-Shirt Variants must have exactly color and size options
- Electronics Product defines:
[{name: "storage", label: "Storage"}, {name: "color", label: "Color"}]
- Electronics Variants must have exactly storage and color options
ProductVariants Implementation
Now we'll build the sophisticated ProductVariants collection that handles all product variations with dynamic validation. This system automatically validates variant options against the parent product's defined structure and manages cross-collection SKU uniqueness.
// src/collections/ProductVariants.ts
import { superAdminOrTenantAdminAccess } from '@/access/superAdminOrTenantAdmin';
import { CollectionConfig } from 'payload';
export const ProductVariants: CollectionConfig = {
slug: 'product-variants',
labels: {
singular: 'Product Variant',
plural: 'Product Variants',
},
admin: {
useAsTitle: 'displayName', // Auto-generated from product + options
group: 'Catalog',
defaultColumns: ['displayName', 'variantSku', 'price', 'inStock'],
description: 'Manage specific variants of products (colors, sizes, etc.)',
},
access: {
read: () => true,
create: superAdminOrTenantAdminAccess,
update: superAdminOrTenantAdminAccess,
delete: superAdminOrTenantAdminAccess,
},
fields: [
{
name: 'product',
type: 'relationship',
relationTo: 'products',
required: true,
admin: {
position: 'sidebar',
description: 'Which product this variant belongs to',
},
},
{
name: 'displayName',
type: 'text',
admin: {
readOnly: true,
description: 'Auto-generated from product name + variant options',
},
},
// Dynamic variant options that inherit from parent product
{
name: 'variantOptions',
type: 'array',
admin: {
description: 'Variant option values - must match parent product types',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Must match a variant option type from parent product',
placeholder: 'e.g., color, size, storage',
},
},
{
name: 'value',
type: 'text',
required: true,
admin: {
description: 'The specific value for this variant',
placeholder: 'e.g., "Red", "Large", "256GB"',
},
},
],
},
{
name: 'variantSku',
type: 'text',
required: true,
unique: true,
admin: {
description: 'Unique SKU for this specific variant',
placeholder: 'e.g., TSHIRT-RED-L, PHONE-256GB-BLACK',
},
},
{
name: 'price',
type: 'number',
min: 0,
admin: {
step: 0.01,
description: 'Variant-specific price (overrides product base price)',
},
},
{
name: 'inStock',
type: 'checkbox',
defaultValue: true,
label: 'In Stock',
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Variant-specific image (optional)',
},
},
],
// The magic happens in hooks - this is where validation and automation live
hooks: {
beforeChange: [
// Auto-generate display name from product + variant options
async ({ data, operation, req }) => {
if (operation === 'create' || operation === 'update') {
let productTitle = '';
// Fetch parent product to get title
if (data.product) {
try {
const product = await req.payload.findByID({
collection: 'products',
id: typeof data.product === 'string' ? data.product : data.product.id,
});
productTitle = product.title || '';
} catch (error: any) {
console.error('Error fetching product for displayName:', error);
if (error.status === 404) {
req.payload.logger.warn(`Variant references non-existent product`);
}
}
}
// Build display name from product title + variant options
const parts = [];
if (productTitle) parts.push(productTitle);
if (data.variantOptions && Array.isArray(data.variantOptions)) {
data.variantOptions.forEach(option => {
if (option.value && option.value.trim()) {
parts.push(option.value);
}
});
}
data.displayName = parts.join(' - ');
}
return data;
},
],
beforeValidate: [
// Validate SKU uniqueness across ALL collections
async ({ data, operation, req, originalDoc }) => {
if (data?.variantSku && (operation === 'create' || operation === 'update')) {
try {
// Check against other product variants
const existingVariants = await req.payload.find({
collection: 'product-variants',
where: {
and: [
{
variantSku: {
equals: data.variantSku,
},
},
// Exclude current document if updating
...(operation === 'update' && originalDoc?.id ? [{
id: {
not_equals: originalDoc.id,
},
}] : []),
],
},
limit: 1,
});
if (existingVariants.totalDocs > 0) {
throw new Error(`Variant SKU "${data.variantSku}" already exists`);
}
// Check against main product SKUs
const existingProducts = await req.payload.find({
collection: 'products',
where: {
sku: {
equals: data.variantSku,
},
},
limit: 1,
});
if (existingProducts.totalDocs > 0) {
throw new Error(`SKU "${data.variantSku}" already exists as a product SKU`);
}
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
throw error;
}
console.error('Error validating variant SKU:', error);
}
}
// Validate variant options against parent product's variant option types
if (data?.variantOptions && data.product) {
try {
const product = await req.payload.findByID({
collection: 'products',
id: typeof data.product === 'string' ? data.product : data.product.id,
});
const allowedOptionNames = product.variantOptionTypes?.map(t => t.name) || [];
for (const option of data.variantOptions) {
if (!allowedOptionNames.includes(option.name)) {
throw new Error(
`Invalid variant option "${option.name}". Allowed options: ${allowedOptionNames.join(', ')}`
);
}
}
} catch (error: any) {
if (error instanceof Error && error.message.includes('Invalid variant option')) {
throw error;
}
if (error.status === 404) {
req.payload.logger.warn(`Cannot validate: Product not found`);
} else {
console.error('Error validating variant options:', error);
}
}
}
return data;
},
],
afterChange: [
// Update parent product's hasVariants flag
async ({ doc, operation, req }) => {
if (operation === 'create' || operation === 'update') {
try {
const productId = typeof doc.product === 'string' ? doc.product : doc.product.id;
await req.payload.update({
collection: 'products',
id: productId,
data: {
hasVariants: true,
},
});
} catch (error) {
console.error('Error updating product hasVariants flag:', error);
}
}
return doc;
},
],
afterDelete: [
// Check if parent product still has variants and update hasVariants flag
async ({ doc, req }) => {
try {
const productId = typeof doc.product === 'string' ? doc.product : doc.product.id;
const remainingVariants = await req.payload.find({
collection: 'product-variants',
where: {
product: {
equals: productId,
},
},
limit: 1,
});
await req.payload.update({
collection: 'products',
id: productId,
data: {
hasVariants: remainingVariants.totalDocs > 0,
},
});
} catch (error) {
console.error('Error updating product hasVariants after variant deletion:', error);
}
},
],
},
};
Understanding the Variants Code
Dynamic Variant Options:
{
name: 'variantOptions',
type: 'array',
fields: [
{ name: 'name', type: 'text' }, // Must match parent product's variantOptionTypes
{ name: 'value', type: 'text' }, // The specific value (e.g., "Red", "Large")
],
}
This flexible structure allows any combination of variant types while enforcing validation against the parent product.
Cross-Collection SKU Validation:
The beforeValidate
hook ensures SKU uniqueness across both products and variants:
- Checks against existing variant SKUs
- Checks against existing product SKUs
- Prevents conflicts during updates
Inheritance Validation:
// In beforeValidate hook
const allowedOptionNames = product.variantOptionTypes?.map(t => t.name) || [];
for (const option of data.variantOptions) {
if (!allowedOptionNames.includes(option.name)) {
throw new Error(`Invalid variant option "${option.name}"`);
}
}
This ensures variants can only have option types that their parent product supports.
Automatic Relationship Management:
- When variants are created, product's
hasVariants
becomestrue
- When variants are deleted, checks remaining variants and updates
hasVariants
- Display names are auto generated from product title + variant options
Real-World Example
T-Shirt Product Setup:
- Create product "Premium Cotton T-Shirt"
- Enable variants and define option types:
[{name: "color", label: "Color"}, {name: "size", label: "Size"}]
T-Shirt Variant Creation:
- Select the t-shirt product
- Add variant options:
[{name: "color", value: "Red"}, {name: "size", value: "Large"}]
- System auto-generates:
displayName: "Premium Cotton T-Shirt - Red - Large"
- Validation ensures you can't add invalid options like "storage" or "weight"
Testing Your Variants
-
Create a product with variants enabled:
- Add variant option types (e.g., color, size)
-
Create variants:
- Try creating valid variants (matching parent option types)
- Try creating invalid variants (should fail validation)
-
Test SKU uniqueness:
- Try duplicate variant SKUs (should fail)
- Try variant SKU matching product SKU (should fail)
-
Test automatic updates:
- Verify
hasVariants
updates on parent product - Delete all variants and verify
hasVariants
becomes false
- Verify
Step 4: Database Migrations - Production-Safe Schema Evolution
What We're Implementing
Database migrations are the professional way to evolve your database schema. Instead of letting Payload auto-push changes (which is dangerous in production), we create controlled, reviewable, and reversible migrations.
Why Migrations Matter
The Problem with Dev Mode:
# DON'T DO THIS IN PRODUCTION
payload dev # Auto-pushes schema changes, can break production
Problems with auto push:
- No control over exact SQL being executed
- Can't review changes before they run
- No rollback plan if something goes wrong
- Creates schema conflicts between environments
The Migration Solution:
# THE RIGHT WAY
payload migrate:create # Generate controlled migration
payload migrate # Apply with full control and rollback support
Setting Up Migration-First Development
1. Configure your database adapter with push: false
:
Here's how to set up your Payload configuration to use migrations instead of auto-push mode:
// payload.config.ts
import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI,
},
push: false, // CRITICAL: Disable auto-push, use migrations only
}),
// ... rest of config
})
2. Register all collections:
Next, ensure all your collections are properly registered in your Payload configuration:
// payload.config.ts
import { Collections } from '@/collections/Collections'
import { Products } from '@/collections/Products'
import { ProductVariants } from '@/collections/ProductVariants'
const allCollections: CollectionConfig[] = [
Collections,
Products,
ProductVariants,
// ... other collections (Media, Users, etc.)
];
export default buildConfig({
collections: allCollections,
// ... rest of config
})
Migration Workflow
Phase 1: Schema Migration (Create Tables)
# Generate migration for new schema
pnpm payload migrate:create
This generates SQL migration files like this comprehensive example that creates all three tables with proper relationships:
// src/migrations/20241201_143022.ts
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
-- Create Collections table
CREATE TABLE IF NOT EXISTS "collections" (
"id" serial PRIMARY KEY,
"slug" varchar UNIQUE,
"title" varchar NOT NULL,
"description" jsonb,
"image_id" integer,
"is_active" boolean DEFAULT true,
"sort_order" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now(),
"created_at" timestamp(3) with time zone DEFAULT now()
);
-- Create Products table
CREATE TABLE IF NOT EXISTS "products" (
"id" serial PRIMARY KEY,
"slug" varchar UNIQUE,
"collection_id" integer NOT NULL,
"has_variants" boolean DEFAULT false,
"variant_option_types" jsonb,
"title" varchar NOT NULL,
"sku" varchar UNIQUE NOT NULL,
-- ... other fields
);
-- Create ProductVariants table
CREATE TABLE IF NOT EXISTS "product_variants" (
"id" serial PRIMARY KEY,
"product_id" integer NOT NULL,
"display_name" varchar,
"variant_options" jsonb,
"variant_sku" varchar UNIQUE NOT NULL,
-- ... other fields
);
-- Foreign key constraints
ALTER TABLE "products" ADD CONSTRAINT "products_collection_id_collections_id_fk"
FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE SET NULL;
ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_products_id_fk"
FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE;
-- Indexes for performance
CREATE INDEX "products_collection_idx" ON "products"("collection_id");
CREATE INDEX "variants_product_idx" ON "product_variants"("product_id");
CREATE INDEX "variants_sku_idx" ON "product_variants"("variant_sku");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "product_variants" CASCADE;
DROP TABLE "products" CASCADE;
DROP TABLE "collections" CASCADE;
`)
}
Apply the migration:
pnpm payload migrate
Phase 2: Data Migration (Populate Initial Data) If you need to populate initial data or migrate existing data:
pnpm payload migrate:create
# Answer "Yes" when it asks about creating a blank migration
Here's an example data migration that creates a default collection and assigns existing products to it:
// src/migrations/20241201_144500.ts
export async function up({ payload }: MigrateUpArgs): Promise<void> {
// Create default collection
const defaultCollection = await payload.create({
collection: 'collections',
data: {
title: 'All Products',
slug: 'all-products',
description: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Default collection for all products' }
]
}
]
}
},
isActive: true,
sortOrder: 1
}
});
console.log(`Created default collection: ${defaultCollection.id}`);
}
Migration Best Practices
✅ Do:
- Always review generated SQL before applying
- Test migrations in development first
- Keep migrations small and focused
- Write meaningful down migrations
- Use transactions (Payload does this automatically)
❌ Don't:
- Use dev mode (
push: true
) in production - Apply migrations directly to production without testing
- Skip writing down migrations
- Make multiple unrelated changes in one migration
Already Started with Dev Mode? No Problem!
If you've been using push: true
in development and need to transition to migrations for production, don't panic! This is a common scenario and there's a safe way to make the transition.
The Problem:
- You've been using dev mode (
push: true
) - Your schema has evolved through automatic pushes
- Now you need proper migrations for production deployment
- Your database schema is ahead of your migration history
The Solution: Check out our detailed guide: From Push Mode to Migrations: A Safe Transition Guide
This guide covers:
- How to safely transition from push mode to migrations
- Creating a baseline migration from your existing schema
- Avoiding data loss during the transition
- Setting up proper migration workflows for your team
Quick Summary:
- Create baseline migration from your current schema
- Mark as applied without running the SQL
- Switch to migrations-only (
push: false
) - Generate new migrations for future changes
Don't let push mode stop you from adopting proper migration practices – the transition is easier than you think!
Rollback Strategy
Check migration status:
pnpm payload migrate:status
Rollback last migration:
pnpm payload migrate:down
Rollback multiple migrations:
pnpm payload migrate:down --count=3
Rollback to specific migration:
pnpm payload migrate:down --to=20241201_143022
Production Deployment
Safe production deployment process:
# 1. Backup database (always!)
pg_dump $DATABASE_URL > backup.sql
# 2. Apply migrations
pnpm payload migrate
# 3. Build application
pnpm build
# 4. Start production server
pnpm start
For detailed migration examples and troubleshooting, see our comprehensive migration guide which covers:
- Adding relationships to existing data
- Complex data transformations
- Handling migration conflicts
- Production deployment strategies
Step 5: Hooks and Business Logic - Automatic Data Maintenance
What Are Hooks
Hooks are functions that run at specific points in Payload's data lifecycle. They let you automate business logic, validate data, and keep relationships in sync without manual intervention.
Hook Lifecycle
Data Flow:
beforeValidate
- Clean and prepare data before validationbeforeChange
- Modify data before database saveafterChange
- Update related data after saveafterDelete
- Clean up when data is deleted
Products Hooks - Relationship Management
Here's how we implement hooks in the Products collection to automatically maintain the relationship with variants:
// In src/collections/Products.ts
hooks: {
afterChange: [
async ({ doc, operation, req }) => {
// Update hasVariants based on existing variants
if (operation === 'create' || operation === 'update') {
try {
const variants = await req.payload.find({
collection: 'product-variants',
where: {
product: {
equals: doc.id,
},
},
limit: 1,
});
const hasVariants = variants.totalDocs > 0;
// Update product if hasVariants doesn't match reality
if (doc.hasVariants !== hasVariants) {
await req.payload.update({
collection: 'products',
id: doc.id,
data: {
hasVariants,
},
});
}
} catch (error) {
console.error('Error updating hasVariants:', error);
}
}
return doc;
},
],
afterDelete: [
async ({ doc, req }) => {
// Delete all associated variants when product is deleted
try {
await req.payload.delete({
collection: 'product-variants',
where: {
product: {
equals: doc.id,
},
},
});
} catch (error) {
console.error('Error deleting product variants:', error);
}
},
],
},
What These Hooks Do:
- afterChange: Keeps
hasVariants
field in sync with actual variants - afterDelete: Prevents orphaned variants when products are deleted
ProductVariants Hooks - Advanced Validation and Automation
Here are the comprehensive hooks for ProductVariants that handle validation, SKU uniqueness, and automatic relationship updates:
// In src/collections/ProductVariants.ts (key parts explained)
hooks: {
beforeChange: [
// Auto-generate display names
async ({ data, operation, req }) => {
if (operation === 'create' || operation === 'update') {
// Get product title
const product = await req.payload.findByID({
collection: 'products',
id: data.product,
});
// Build display name: "Product Title - Color - Size"
const parts = [product.title];
data.variantOptions?.forEach(option => {
if (option.value) parts.push(option.value);
});
data.displayName = parts.join(' - ');
}
return data;
},
],
beforeValidate: [
// Cross-collection SKU validation
async ({ data, operation, req, originalDoc }) => {
if (data?.variantSku) {
// Check against other variants
const existingVariants = await req.payload.find({
collection: 'product-variants',
where: {
variantSku: { equals: data.variantSku },
// Exclude current document if updating
...(operation === 'update' ? { id: { not_equals: originalDoc.id } } : {})
},
limit: 1,
});
if (existingVariants.totalDocs > 0) {
throw new Error(`Variant SKU "${data.variantSku}" already exists`);
}
// Check against product SKUs
const existingProducts = await req.payload.find({
collection: 'products',
where: { sku: { equals: data.variantSku } },
limit: 1,
});
if (existingProducts.totalDocs > 0) {
throw new Error(`SKU "${data.variantSku}" conflicts with product SKU`);
}
}
// Validate variant options against parent product
if (data?.variantOptions && data.product) {
const product = await req.payload.findByID({
collection: 'products',
id: data.product,
});
const allowedOptions = product.variantOptionTypes?.map(t => t.name) || [];
for (const option of data.variantOptions) {
if (!allowedOptions.includes(option.name)) {
throw new Error(
`Invalid option "${option.name}". Allowed: ${allowedOptions.join(', ')}`
);
}
}
}
return data;
},
],
afterChange: [
// Update parent product's hasVariants flag
async ({ doc, operation, req }) => {
if (operation === 'create' || operation === 'update') {
await req.payload.update({
collection: 'products',
id: doc.product,
data: { hasVariants: true },
});
}
return doc;
},
],
afterDelete: [
// Check if parent still has variants
async ({ doc, req }) => {
const remainingVariants = await req.payload.find({
collection: 'product-variants',
where: { product: { equals: doc.product } },
limit: 1,
});
await req.payload.update({
collection: 'products',
id: doc.product,
data: { hasVariants: remainingVariants.totalDocs > 0 },
});
},
],
},
Understanding Hook Patterns
Error Handling Strategy:
Here's the recommended pattern for handling errors in hooks:
try {
// Payload operation
await req.payload.update(/* ... */);
} catch (error) {
console.error('Descriptive error message:', error);
// Don't re-throw unless it's a validation error
}
- Log errors for debugging
- Only re throw validation errors that should block the operation
- Let data operations continue even if related updates fail
Performance Considerations:
Here's how to write efficient hooks that don't slow down your application:
// ✅ Good: Limit queries when checking existence
const variants = await req.payload.find({
collection: 'product-variants',
where: { product: { equals: doc.id } },
limit: 1, // Only need to know if any exist
});
// ❌ Bad: Don't fetch all related records
const variants = await req.payload.find({
collection: 'product-variants',
where: { product: { equals: doc.id } },
// No limit - could return thousands of records
});
Conditional Logic:
Use conditional checks to optimize hook performance:
// Only run expensive operations when necessary
if (operation === 'create' || operation === 'update') {
// Only check during create/update, not on every read
}
if (doc.hasVariants !== hasVariants) {
// Only update when there's actually a change
await req.payload.update(/* ... */);
}
Hook Benefits
Automatic Data Integrity:
- SKUs stay unique across all collections
- Relationships stay in sync automatically
- Display names update when data changes
Validation Beyond Schema:
- Cross collection validation
- Business rule enforcement
- Complex relationship constraints
User Experience:
- Automatic field population
- Consistent data formatting
- Error prevention
Testing Your E-commerce System
Complete System Testing
Now that you have all three collections implemented, let's test the complete system:
1. Create the full data structure:
# Generate and apply all migrations
pnpm payload migrate:create
pnpm payload migrate
2. Test Collections:
- Create several collections (Electronics, Clothing, Home & Garden)
- Test rich text descriptions with headings and formatting
- Upload collection images
- Verify slug auto-generation
3. Test Products:
- Create products in different collections
- Enable variants on some products and define option types
- Fill out all tabs (basic info, pricing, specifications, images)
- Test the join field showing related variants
4. Test Product Variants:
- Create variants for products with defined option types
- Try creating invalid variants (should fail validation)
- Test SKU uniqueness across products and variants
- Verify automatic display name generation
5. Test Hooks and Automation:
- Verify
hasVariants
updates when variants are added/removed - Test cascade deletion (delete product → variants are deleted)
- Test cross-collection SKU validation
Common Pitfalls and Solutions
Problem: Migration conflicts
Error: It looks like you've run Payload in dev mode
Solution: Always use push: false
and migrations only. If you see this, answer "yes" to proceed.
Problem: Variant validation errors
Invalid variant option "weight". Allowed options: color, size
Solution: Ensure variant options match the parent product's variantOptionTypes
exactly.
Problem: SKU conflicts
Variant SKU "SHIRT-001" already exists as a product SKU
Solution: This is working correctly! Our validation prevents conflicts. Use a different SKU.
Problem: Orphaned variants
Cannot validate variant options: Product not found
Solution: Clean up orphaned variants or restore the missing product.
Performance Testing
Large Dataset Testing:
- Create 100+ products across multiple collections
- Test admin UI performance with many variants
- Verify database queries are optimized (check query logs)
Index Verification:
-- Check that indexes exist for performance
\d+ products -- Should show index on collection_id
\d+ product_variants -- Should show index on product_id
Conclusion
In this guide, I walked through a production‑ready foundation for an e‑commerce system: scalable collections with rich content, a products model that stays friendly to editors, and an inheritance‑based variants layer that validates against the parent product. I pair this architecture with a migration‑first workflow so every schema change remains reviewable and reversible, and with focused hooks that keep relationships in sync, enforce SKU uniqueness, and generate meaningful display values automatically.
This is the approach I use on real projects because it starts small, scales cleanly, and protects data integrity as teams and catalogs grow. If you’re ready for the next step, I’ll show how I build the Next.js frontend for product browsing and variant selection, layer in full‑text search with filters and facets, harden access control, and wire up CI/CD with performance guardrails like caching and image pipelines. Let me know what you’d like to see next on Build with Matija, and I’ll shape the follow‑up accordingly.
The foundation is solid—let’s build something great on top of it.
Final Thoughts
We’ve built something substantial together. What began as “let’s add product categories” evolved into a complete, production‑ready data model for real stores: collections that scale with content, products that stay friendly to editors, and variants that inherit structure and validate cleanly. Along the way, we anchored everything in a migration‑first workflow and a set of targeted hooks so the system stays consistent as it grows.
This build isn’t just about Payload or PostgreSQL—it’s about habits that make software last. The principles we used here travel well across stacks and will serve you whether you’re shipping a focused catalogue or operating a large marketplace.
What's Your Next Move?
If you’re continuing this build, tell me a bit about your catalogue, timeline, and constraints—what you’re selling, how big you expect to get, and where you feel the rough edges right now. I’ll point you to the next most valuable step and often turn your questions into follow‑ups on Build with Matija. If this guide helped, consider following along so you don’t miss the deep dives to come.
Coming Up Next
Based on your feedback, I’m lining up deep dives into a polished Next.js frontend for browsing and variant selection, full‑text search with filters and facets, robust authentication and permissions for multi‑tenant setups, and performance and scaling playbooks for high‑traffic stores. If one of these would help you right now, say the word and I’ll prioritize it.