---
title: "How to Build E‑commerce with Payload CMS: Collections, Products, Variants"
slug: "how-to-build-ecommerce-with-payload-cms"
published: "2025-08-10"
updated: "2026-03-26"
validated: "2026-03-26"
categories:
  - "Payload"
tags:
  - "payload cms ecommerce"
  - "headless ecommerce"
  - "product variants"
  - "sku management"
  - "payload collections"
  - "cms product catalog"
  - "variant validation"
  - "ecommerce data model"
  - "payload products"
  - "headless cms shop"
llm-intent: "reference"
audience-level: "beginner"
framework-versions:
  - "unspecified"
status: "stable"
llm-purpose: "Build an e‑commerce backend with Payload CMS—collections, products, validated variants, SKU uniqueness, SEO‑friendly slugs."
llm-prereqs:
  - "General familiarity with the article topic"
llm-outputs:
  - "Completed outcome: Build an e‑commerce backend with Payload CMS—collections, products, validated variants, SKU uniqueness, SEO‑friendly slugs."
---

**Summary Triples**
- (collections, organize, products, categories and related data to keep the catalog scalable)
- (products model, provides, editor-friendly, tabbed UI and rich fields (images, specs, SEO))
- (variants, validated_by, an inheritance-based system that enforces declared product option types)
- (SKU uniqueness, enforced_with, server-side hooks that validate uniqueness across the catalog)
- (slugs, generated_as, SEO-friendly unique slugs with collision handling)
- (migration-first workflow, ensures, safe schema evolution and repeatable database changes)
- (hooks, used_for, synchronizing relationships, auto-filling display values, and enforcing constraints)
- (data integrity, maintained_by, validations at source (collection schemas + hooks) rather than client-side checks)
- (scalability, improved_by, separating concerns into collections and minimizing heavy joins in queries)
- (testing, recommended, unit tests for hooks and migrations, plus integration tests for critical APIs)

### {GOAL}
Build an e‑commerce backend with Payload CMS—collections, products, validated variants, SKU uniqueness, SEO‑friendly slugs.

### {PREREQS}
- General familiarity with the article topic

### {STEPS}
1. Plan the data model
2. Create Collections
3. Create Products and relate to Collections
4. Declare allowed variant options on Products
5. Create Product Variants
6. Validate variant options against parent product
7. Enforce SKU uniqueness across products and variants
8. Generate human‑readable variant names
9. Keep hasVariants accurate
10. Make SEO‑friendly slugs and URLs
11. Add Product JSON‑LD to PDPs

<!-- llm:goal="Build an e‑commerce backend with Payload CMS—collections, products, validated variants, SKU uniqueness, SEO‑friendly slugs." -->
<!-- llm:prereq="General familiarity with the article topic" -->
<!-- llm:output="Completed outcome: Build an e‑commerce backend with Payload CMS—collections, products, validated variants, SKU uniqueness, SEO‑friendly slugs." -->

# How to Build E‑commerce with Payload CMS: Collections, Products, Variants
> Build an ecommerce backend with Payload CMS — covers the official @payloadcms/plugin-ecommerce, plus a full custom collections, products, variants, and SKU validation walkthrough.
Matija Žiberna · 2025-08-10

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.

Before we dive into the custom data model, there's one question worth answering first: **does Payload have an official ecommerce plugin?**

## Does Payload Have an Official Ecommerce Plugin?

Yes. [`@payloadcms/plugin-ecommerce`](https://payloadcms.com/docs/ecommerce/overview) is an official Payload plugin, currently in Beta (v3.x). Install it with:

```bash
pnpm add @payloadcms/plugin-ecommerce
```

Or scaffold the full official ecommerce template — Next.js frontend, Stripe, cart, guest checkout, and automated tests — in one command:

```bash
pnpx create-payload-app my-project -t ecommerce
```

The plugin handles the core primitives out of the box: products with variants, carts for authenticated and guest users, orders and transactions, customer addresses, Stripe payments via an adapter pattern, and multi-currency pricing. What it doesn't cover natively: shipping, taxes, and subscriptions — those you implement using the plugin's collections and hooks.

**So why does this article still walk through building it from scratch?**

The plugin is Beta, and production teams frequently need to extend or replace parts of it. Understanding the data model underneath is what makes that possible — whether you're customizing the plugin, adding a feature it doesn't support yet, or starting from a blank schema because your requirements don't fit the defaults. If the plugin fits your needs as-is, head to the [complete plugin setup guide](/blog/payloadcms-plugin-ecommerce-stripe-cart-orders-guide) — it covers installation, the Stripe adapter, the React hooks, and a working cart flow end to end. The rest of this article builds the same foundation from the ground up.

## 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

1. **Scalable Categorization** - Collections can grow from simple categories to complex content hubs
2. **Flexible Variants** - Products define what variant types they support, variants inherit this structure
3. **Data Integrity** - Foreign keys and validation hooks keep everything in sync
4. **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.

```typescript
// 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
- For detailed implementation of slugs and SKUs, see my guide on [implementing slugs and SKUs in Payload CMS](https://www.buildwithmatija.com/blog/payload-cms-slugs-and-skus)

**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 sidebar
- `position: 'sidebar'` for secondary fields
- `defaultColumns` customizes the list view
- `useAsTitle` determines what shows in relationship fields

### Testing Your Collections

1. **Generate the schema migration:**
```bash
pnpm payload migrate:create
```

2. **Apply the migration:**
```bash
pnpm payload migrate
```

3. **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.

```typescript
// 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:**
```typescript
{
  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:**
```typescript
{
  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
- For a deeper dive into building Shopify-style variant systems, see my complete guide: [Build a Shopify-Style Variant System in Payload CMS](https://www.buildwithmatija.com/blog/shopify-variant-system-payload-cms)
**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:**
```typescript
{
  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

1. **Generate and apply migration:**
```bash
pnpm payload migrate:create
pnpm payload migrate
```

2. **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:
1. **Products define** what variant option types they support
2. **Variants inherit** this structure and must conform to it
3. **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.

```typescript
// 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:**
```typescript
{
  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:**
```typescript
// 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` becomes `true`
- 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:**
1. Create product "Premium Cotton T-Shirt"
2. Enable variants and define option types:
   ```
   [{name: "color", label: "Color"}, {name: "size", label: "Size"}]
   ```

**T-Shirt Variant Creation:**
1. Select the t-shirt product
2. Add variant options:
   ```
   [{name: "color", value: "Red"}, {name: "size", value: "Large"}]
   ```
3. System auto-generates: `displayName: "Premium Cotton T-Shirt - Red - Large"`
4. Validation ensures you can't add invalid options like "storage" or "weight"

### Testing Your Variants

1. **Create a product with variants enabled:**
   - Add variant option types (e.g., color, size)

2. **Create variants:**
   - Try creating valid variants (matching parent option types)
   - Try creating invalid variants (should fail validation)

3. **Test SKU uniqueness:**
   - Try duplicate variant SKUs (should fail)
   - Try variant SKU matching product SKU (should fail)

4. **Test automatic updates:**
   - Verify `hasVariants` updates on parent product
   - Delete all variants and verify `hasVariants` becomes false

## 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:**
```bash
# 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:**
```bash
# 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:

```typescript
// 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:

```typescript
// 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)**
```bash
# 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:

```typescript
// 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:**
```bash
pnpm payload migrate
```

**Phase 2: Data Migration (Populate Initial Data)**
If you need to populate initial data or migrate existing data:

```bash
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:

```typescript
// 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**](https://www.buildwithmatija.com/blog/payloadcms-postgres-push-to-migrations)

For understanding how to structure your collections properly for long-term maintainability, see my guide on [Payload collection structure best practices](https://www.buildwithmatija.com/blog/payload-cms-collection-structure-best-practices).


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:**
1. **Create baseline migration** from your current schema
2. **Mark as applied** without running the SQL
3. **Switch to migrations-only** (`push: false`)
4. **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:**
```bash
pnpm payload migrate:status
```

**Rollback last migration:**
```bash
pnpm payload migrate:down
```
> **Note:** In Payload v3, `migrate:down` rolls back the last batch only. The `--count` and `--to` flags are not supported in the current CLI. For a full migration strategy including baseline creation and environment transitions, see the [push to migrations guide](https://www.buildwithmatija.com/blog/payloadcms-postgres-push-to-migrations).
```

### Production Deployment

**Safe production deployment process:**
```bash
# 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](https://buildwithmatija.com/blog/payload-migrate) 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:**
1. `beforeValidate` - Clean and prepare data before validation
2. `beforeChange` - Modify data before database save
3. `afterChange` - Update related data after save
4. `afterDelete` - 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:

```typescript
// 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:

```typescript
// 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:

```typescript
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:

```typescript
// ✅ 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:

```typescript
// 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:**
```bash
# 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 — dev mode prompt**
```
It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.
If you'd like to run migrations, data loss will occur. Would you like to proceed?
```
**Solution:** This is an interactive prompt, not a crash. Payload detected that the schema was auto-pushed in dev mode. Answer "yes" to proceed, then set `push: false` in your database adapter config and use the migration workflow going forward.

**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:**
```sql
-- 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.


If you're building an ecommerce system with Payload CMS and want a senior Payload specialist to review the data model or work with your team, [I work with a small number of clients at a time](/payload-cms-developer).

## LLM Response Snippet
```json
{
  "goal": "Build an e‑commerce backend with Payload CMS—collections, products, validated variants, SKU uniqueness, SEO‑friendly slugs.",
  "responses": [
    {
      "question": "What does the article \"How to Build E‑commerce with Payload CMS: Collections, Products, Variants\" cover?",
      "answer": "Build an e‑commerce backend with Payload CMS—collections, products, validated variants, SKU uniqueness, SEO‑friendly slugs."
    }
  ]
}
```