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?
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 — 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:
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.
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
Generate the schema migration:
bash
pnpm payload migrate:create
Apply the migration:
bash
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.
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
Generate and apply migration:
bash
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
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.
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.tsimport { postgresAdapter } from'@payloadcms/db-postgres'exportdefaultbuildConfig({
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:
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
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:
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.
code
### 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 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 validation
beforeChange - Modify data before database save
afterChange - Update related data after save
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:
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 existenceconst 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 recordsconst 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 changeawait 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
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
code
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.
Solution: Ensure variant options match the parent product's variantOptionTypes exactly.
Problem: SKU conflicts
code
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
code
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.