Build a Shopify-Style Variant System in Payload CMS
From “just add dropdowns” to a fully dynamic product variant engine — here's how I cloned Shopify's logic using Payload CMS and Next.js.

How a "quick feature request" turned into building a Shopify-inspired variant system that automatically adapts to any product attributes — no code changes required.
The "Simple" Request That Wasn't
Picture this: You're deep in flow, putting the finishing touches on an e-commerce project built with Payload CMS and Next.js. The client has been happy with the progress, and you're feeling confident about the launch timeline. Then comes the message that every developer knows too well: "Hey, can we add one small feature? Just some dropdown menus for product variants like Shopify has."
That was my Tuesday morning three months ago.
I'd spent weeks building a clean product catalog system. Each product had its price, description, images — the standard stuff. Everything was working beautifully. How hard could adding some color and size dropdowns be, right?
Famous last words.
What started as a "quick dropdown implementation" quickly revealed itself as a fundamental architectural challenge. The real question wasn't "how do I add color and size dropdowns?" — it was "how do I build a system that won't break when they inevitably want to add material, finish, storage capacity, or any other product attribute next month?"
I'd been building e-commerce systems the wrong way: hardcoding variant fields directly into database schemas and UI components. It worked fine until the client wanted flexibility. Then it became a maintenance nightmare where every new attribute meant schema changes, component updates, and full deployments.
After studying how Shopify's variant system actually works under the hood, I discovered something fascinating: they treat variant attributes as data, not schema. This simple shift in thinking led me to build a completely generic variant system that automatically adapts to any product attributes you throw at it — without touching a single line of code.
The Real Problem: Schema-Based vs Data-Driven Variants
Let me show you exactly what I was dealing with. Most e-commerce tutorials teach you to build variants like this:
// The old way: hardcoded schema fields
const ProductVariant = {
color: 'Red',
size: 'Large',
price: 29.99,
sku: 'SHIRT-RED-L'
}
This approach seems logical at first. You create database fields for each variant attribute, then build UI components that know about these specific fields. But here's what happens when your client comes back with new requirements:
Week 1: "Can we add material options?"
- Add
material
field to database schema - Update all variant creation forms
- Modify the UI components to handle material dropdowns
- Redeploy everything
Week 3: "We need finish options too."
- Add
finish
field to database schema - Update forms again
- Add another dropdown to the UI
- Another deployment
Month 2: "We're launching a new product line with completely different attributes."
- Realize your hardcoded system can't handle it
- Consider rebuilding everything from scratch
I was stuck in this exact cycle. Each new attribute meant touching multiple parts of the codebase, risking bugs in existing functionality, and increasing complexity exponentially.
The breakthrough came when I studied Shopify's architecture more carefully. They solved this decades ago by treating variant attributes as flexible data structures rather than fixed database fields. Products define what types of variants they support, and individual variants provide values for those types. The UI dynamically generates appropriate controls based on what variant types actually exist.
This data-driven approach means adding new variant types becomes a content operation instead of a development task. Store owners can create entirely new product categories with novel variant combinations without waiting for developer cycles.
My Initial (Failed) Attempts
Before finding the right solution, I tried several approaches that seemed promising but ultimately hit walls:
Attempt 1: The Enum Approach I thought I could solve the flexibility problem by using TypeScript enums for variant options:
enum Color {
RED = 'red',
BLUE = 'blue',
GREEN = 'green'
}
enum Size {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large'
}
This felt cleaner than magic strings, but I quickly realized it had the same fundamental problem: adding new options meant code changes. When the client wanted to add "Extra Large" or "Navy Blue", I still had to update enums, recompile, and redeploy.
Attempt 2: The Configuration File Approach Next, I tried moving variant definitions into a configuration file:
// variants.config.ts
export const VARIANT_DEFINITIONS = {
clothing: {
color: ['Red', 'Blue', 'Green'],
size: ['Small', 'Medium', 'Large']
},
electronics: {
storage: ['128GB', '256GB', '512GB'],
color: ['Silver', 'Gold', 'Black']
}
}
This was better than enums because non-developers could theoretically update the configuration. But it still required deployments for changes, and the system couldn't handle products that didn't fit neatly into predefined categories.
Attempt 3: The JSON Field Approach Then I tried storing variant attributes as JSON in the database:
const ProductVariant = {
attributes: {
color: 'Red',
size: 'Large',
material: 'Cotton'
},
price: 29.99,
sku: 'SHIRT-RED-L-COTTON'
}
This was closer to the right idea, but I still had hardcoded UI components that expected specific attribute names. The backend was flexible, but the frontend was still brittle.
Each failed attempt taught me something important, but it wasn't until I really studied Shopify's approach that I understood the core principle: the UI itself needs to be as dynamic as the data structure.
Exploring the Right Approach
After my failed attempts, I stepped back and studied how established e-commerce platforms handle this problem. Shopify's variant system became my north star because it elegantly solves the exact challenge I was facing.
Here's how Shopify actually works:
Products define variant structure:
{
"product": {
"options": [
{"name": "Color", "values": ["Red", "Blue", "Green"]},
{"name": "Size", "values": ["Small", "Medium", "Large"]}
]
}
}
Individual variants provide combinations:
{
"variant": {
"option1": "Red",
"option2": "Large",
"price": 29.99,
"sku": "SHIRT-RED-L"
}
}
The beauty of this approach is that it separates structure definition from value assignment. Products declare what types of variants they support, while individual variants provide specific combinations of those options.
This led me to consider three architectural approaches:
Approach 1: Direct Schema Implementation
Replicate Shopify's exact structure in Payload CMS with option1
, option2
, etc. fields.
- Pros: Battle-tested approach, matches Shopify's proven model
- Cons: Limited to a fixed number of variant options, position-dependent attribute ordering
Approach 2: Named Attribute Fields
Use meaningful field names but keep them as separate database columns.
- Pros: More readable than numbered options, easier to query
- Cons: Still requires schema changes for new attributes, maintains the original problem
Approach 3: Fully Dynamic Attribute Arrays Store variant attributes as arrays of name-value pairs, with complete dynamic discovery.
- Pros: Unlimited flexibility, zero-maintenance scaling, self-documenting structure
- Cons: More complex initial implementation, requires careful type handling
I chose Approach 3 because it solved the root problem rather than just making it more manageable. The extra complexity in the implementation was worth eliminating the maintenance burden entirely.
The key insight was that the UI components needed to be just as dynamic as the data structure. Instead of hardcoding dropdowns for "color" and "size", I needed components that could generate appropriate controls for whatever attributes actually existed in the data.
Step 1: Designing the Payload CMS Schema
Let me walk you through building the foundation. The schema design is crucial because it needs to support infinite flexibility while remaining intuitive for content editors.
I started with the Products collection, adding a simple flag to indicate whether a product has variants:
// src/collections/Products.ts
export const Products: CollectionConfig = {
slug: 'products',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'hasVariants',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Whether this product has variants'
},
},
// ... other standard product fields
]
}
The hasVariants
flag serves a crucial purpose: it tells our frontend whether to render the variant selector at all. Products without variants display normally, while products with variants show the dynamic selector interface. This prevents the UI from trying to generate variant controls for simple products that don't need them.
But here's where it gets interesting. I needed a way for content editors to define what types of variant attributes each product supports. This is where Shopify's approach really shines — products declare their own variant structure:
// src/collections/Products.ts - Enhanced version
export const Products: CollectionConfig = {
slug: 'products',
fields: [
{
name: 'hasVariants',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Whether this product has variants'
},
},
{
name: 'variantOptionTypes',
type: 'array',
admin: {
description: 'Define what variant option types this product supports',
condition: (data) => data.hasVariants === true,
},
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 shown in UI',
},
},
],
},
// ... other product fields
],
}
This variantOptionTypes
array is the secret sauce. It allows content editors to define exactly what variant attributes each product supports, without any developer involvement. The condition
ensures this field only appears when hasVariants
is true, keeping the interface clean for simple products.
The separation between name
and label
is important for internationalization and UI flexibility. The name
is the internal identifier used in code and databases, while label
is what users see in the interface.
💡 Tip: Using conditional field visibility keeps the admin interface clean and prevents content editors from accidentally configuring variant options on products that don't need them.
Now for the ProductVariants collection, which is where individual variant combinations live:
// src/collections/ProductVariants.ts
export const ProductVariants: CollectionConfig = {
slug: 'product-variants',
fields: [
{
name: 'product',
type: 'relationship',
relationTo: 'products',
required: true,
},
{
name: 'variantOptions',
type: 'array',
admin: {
description: 'Variant option values - must match parent product variant types',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Must match a variant option type defined in parent product',
},
},
{
name: 'value',
type: 'text',
required: true,
admin: {
description: 'The specific value for this variant (e.g., "Red", "Large")',
},
},
],
},
{
name: 'variantSku',
type: 'text',
required: true,
unique: true,
},
{
name: 'price',
type: 'number',
min: 0,
},
{
name: 'inStock',
type: 'checkbox',
defaultValue: true,
},
// ... other variant-specific fields
],
}
The variantOptions
array is where the magic happens. Each variant stores its attributes as an array of name-value pairs, rather than having dedicated fields for specific attributes. This means a t-shirt variant might have [{name: "color", value: "Red"}, {name: "size", value: "Large"}]
, while a building material variant could have [{name: "material", value: "Steel"}, {name: "finish", value: "Matte"}, {name: "thickness", value: "5mm"}]
.
The variantSku
field is crucial for order processing and inventory management. Each variant gets its own unique SKU, which becomes the primary identifier for fulfillment systems.
⚠️ Common Bug: Make sure the name
values in variantOptions
exactly match the name
values defined in the parent product's variantOptionTypes
. Mismatches will cause the variant to not appear in selection dropdowns.
This schema design eliminates the need for hardcoded fields entirely. Whether you're selling simple t-shirts or complex industrial equipment, the same schema structure handles any combination of variant attributes.
Step 2: Building the Dynamic Discovery System
Now that we have flexible data structures, we need code that can work with them intelligently. The key challenge is building functions that can examine variant data and automatically figure out what attributes exist, without any hardcoded knowledge of specific field names.
Let me start with the core interfaces that define how our system thinks about variants:
// src/lib/variants.ts
export interface VariantAttribute {
name: string
label: string
values: string[]
}
export interface VariantSelection {
[attributeName: string]: string | undefined
}
The VariantAttribute
interface represents what we discover about each variant attribute: its internal name, display label, and all possible values. The VariantSelection
interface represents a user's current selection state, like { color: "Red", size: "Large" }
.
The foundation of the dynamic system is a function that can extract attribute values from the flexible variantOptions
structure:
function getVariantAttributeValue(variant: ProductVariant, attributeName: string): string | null {
if (variant.variantOptions && Array.isArray(variant.variantOptions)) {
const option = variant.variantOptions.find(opt => opt.name === attributeName)
return option?.value || null
}
return null
}
This function is the bridge between our flexible data structure and the rest of the system. Instead of accessing properties like variant.color
or variant.size
, everything goes through this function to find attribute values in the variantOptions
array.
Why is this important? Because it makes the entire system independent of specific attribute names. Whether we're looking for "color", "material", or "quantum-flux-capacity", the same function handles the lookup.
Now I can build the discovery function that automatically figures out what variant attributes exist:
export function getVariantAttributes(variants: ProductVariant[]): VariantAttribute[] {
if (!variants.length) return []
const attributeMap = new Map<string, Set<string>>()
// Collect all unique attribute names and their values from variantOptions
variants.forEach(variant => {
if (variant.variantOptions && Array.isArray(variant.variantOptions)) {
variant.variantOptions.forEach(option => {
if (option.name && option.value && option.value.trim()) {
if (!attributeMap.has(option.name)) {
attributeMap.set(option.name, new Set())
}
attributeMap.get(option.name)!.add(option.value.trim())
}
})
}
})
// Convert to VariantAttribute array with labels
const attributes: VariantAttribute[] = []
attributeMap.forEach((valuesSet, attributeName) => {
const values = Array.from(valuesSet).sort()
attributes.push({
name: attributeName,
label: attributeName.charAt(0).toUpperCase() + attributeName.slice(1),
values,
})
})
return attributes
}
This function performs the magic of dynamic discovery. It examines all variants for a product and builds a complete picture of what attributes exist and what values are available for each attribute.
Here's how it works step by step:
- Collection Phase: Loop through all variants and collect every unique attribute name and value combination into a Map structure
- Filtering Phase: Only process attributes that have actual, non-empty values
- Consolidation Phase: Convert the collected data into a clean array structure with sorted values
- Labeling Phase: Generate user-friendly labels from internal names (could be enhanced with proper i18n later)
The beauty of this approach is that it adapts automatically to whatever variant structure exists in your data. Add a new variant with a "pattern" attribute? The discovery function will automatically include it in the results. Remove all variants with "material" attributes? It disappears from the results.
🧠 Key Insight: This discovery approach treats variant attributes as pure data rather than schema elements. This fundamental shift is what makes the system infinitely scalable without code changes.
For finding specific values, I created a helper function:
export function getUniqueValuesForAttribute(
variants: ProductVariant[],
attributeName: string
): string[] {
const values = variants
.map(variant => getVariantAttributeValue(variant, attributeName))
.filter((value): value is string => Boolean(value && typeof value === 'string' && value.trim()))
return [...new Set(values)]
}
This function extracts all unique values for a specific attribute across all variants. It's useful for populating dropdown options and validating user selections.
The filter function uses a TypeScript type predicate to ensure we only return actual string values, not nulls or empty strings. This prevents UI glitches where dropdown options might be empty or undefined.
Step 3: Smart Variant Matching
Once users make selections in the UI, we need to find the exact variant that matches their choices. This is more complex than it might seem because we need to handle partial selections gracefully and ensure exact matching when all attributes are selected.
Here's the core matching function:
export function findVariantByAttributes(
variants: ProductVariant[],
selection: VariantSelection
): ProductVariant | null {
if (!variants.length || Object.keys(selection).length === 0) {
return null
}
const variant = variants.find(variant => {
return Object.entries(selection).every(([attributeName, selectedValue]) => {
if (!selectedValue) return true // Skip undefined/empty selections
const variantValue = getVariantAttributeValue(variant, attributeName)
return variantValue === selectedValue
})
})
return variant || null
}
This function implements exact matching logic: for a variant to match, it must have the correct value for every attribute in the selection. The every()
method ensures that if any attribute doesn't match, the variant is rejected.
The key insight is handling partial selections gracefully. If a user has selected "Red" for color but hasn't chosen a size yet, we don't want to return a variant because the selection is incomplete. The function only returns a variant when all selected attributes match exactly.
Here's how the matching works in practice:
// Example variants:
const variants = [
{ variantOptions: [{name: "color", value: "Red"}, {name: "size", value: "Large"}] },
{ variantOptions: [{name: "color", value: "Red"}, {name: "size", value: "Medium"}] },
{ variantOptions: [{name: "color", value: "Blue"}, {name: "size", value: "Large"}] }
]
// Partial selection - no match returned
findVariantByAttributes(variants, { color: "Red" })
// Returns: null (because size is not specified)
// Complete selection - exact match returned
findVariantByAttributes(variants, { color: "Red", size: "Large" })
// Returns: first variant (exact match)
// Invalid selection - no match returned
findVariantByAttributes(variants, { color: "Purple", size: "Large" })
// Returns: null (no variant has Purple color)
For more advanced use cases, I also built a function to find variants that match a partial selection:
export function findCompatibleVariants(
variants: ProductVariant[],
selection: VariantSelection
): ProductVariant[] {
return variants.filter(variant => {
return Object.entries(selection).every(([attributeName, selectedValue]) => {
if (!selectedValue) return true
const variantValue = getVariantAttributeValue(variant, attributeName)
return variantValue === selectedValue
})
})
}
This function returns all variants that are compatible with the current selection, which is useful for implementing smart filtering where selecting one attribute filters the available options for other attributes.
💡 Tip: The exact matching approach prevents users from getting into impossible selection states. If no variant exists with the selected combination, no variant is returned, which the UI can handle by showing an "out of stock" or "invalid combination" message.
Step 4: URL State Management
For a professional e-commerce experience, variant selections need to be reflected in the URL so users can bookmark, share, or refresh the page without losing their selection. This requires careful handling of URL parameters that map to our dynamic attribute system.
Here's how I implemented URL parameter parsing:
export function parseVariantSelectionFromSearchParams(
searchParams: Record<string, string | string[] | undefined> | undefined,
availableAttributes: VariantAttribute[]
): VariantSelection {
if (!searchParams) return {}
const selection: VariantSelection = {}
availableAttributes.forEach(attribute => {
const paramValue = searchParams[attribute.name]
if (paramValue) {
selection[attribute.name] = Array.isArray(paramValue) ? paramValue[0] : paramValue
}
})
return selection
}
This function takes URL search parameters and converts them into our internal selection format. The key insight is that it only processes parameters that correspond to actual variant attributes, preventing URL pollution from unrelated query parameters.
The availableAttributes
parameter acts as a whitelist — only attributes that actually exist for this product are processed from the URL. This prevents users from manually adding invalid parameters that could break the selection logic.
For building URLs from selections, I created the reverse function:
export function buildVariantQueryString(selection: VariantSelection): string {
const params = new URLSearchParams()
Object.entries(selection).forEach(([key, value]) => {
if (value) {
params.set(key, value)
}
})
return params.toString()
}
This function converts a selection object back into URL query parameters. It only includes attributes that have actual values, keeping URLs clean and avoiding empty parameters.
Here's how URL management works in practice:
// User selects "Red" and "Large"
const selection = { color: "Red", size: "Large" }
// Build URL: "color=Red&size=Large"
const queryString = buildVariantQueryString(selection)
// Full URL becomes: "/products/t-shirt?color=Red&size=Large"
const url = `/products/t-shirt?${queryString}`
// Later, parse URL back to selection
const parsedSelection = parseVariantSelectionFromSearchParams(
{ color: "Red", size: "Large" },
availableAttributes
)
// Result: { color: "Red", size: "Large" }
The URL management system is crucial for SEO and user experience. Search engines can index specific variant combinations, and users can share links to exact product configurations.
⚠️ Common Bug: Make sure to handle array-type URL parameters properly. Next.js sometimes parses repeated parameters as arrays, so the parsing function needs to extract the first value when that happens.
Step 5: Building the Dynamic UI Component
Now comes the fun part — building a React component that can generate appropriate UI controls for any variant attributes that exist, without hardcoding specific field names or dropdown options.
Let me start with the component structure:
// src/components/ProductVariantSelector.tsx
"use client"
import React from 'react'
import { useRouter } from 'next/navigation'
import {
getVariantAttributes,
findVariantByAttributes,
buildVariantQueryString,
type VariantSelection,
type ProductVariant
} from '@/lib/variants'
interface ProductVariantSelectorProps {
variants: ProductVariant[]
currentSelection: VariantSelection
productSlug: string
}
export default function ProductVariantSelector({
variants,
currentSelection,
productSlug
}: ProductVariantSelectorProps) {
const router = useRouter()
// Extract all available variant attributes dynamically
const availableAttributes = getVariantAttributes(variants)
// Find the currently selected variant (if selection is complete)
const selectedVariant = findVariantByAttributes(variants, currentSelection)
const updateURL = (newSelection: VariantSelection) => {
const queryString = buildVariantQueryString(newSelection)
const newUrl = queryString ? `/products/${productSlug}?${queryString}` : `/products/${productSlug}`
router.push(newUrl)
}
const handleAttributeChange = (attributeName: string, value: string) => {
const newSelection = { ...currentSelection }
if (value === '') {
delete newSelection[attributeName]
} else {
newSelection[attributeName] = value
}
updateURL(newSelection)
}
// Don't render anything if no variant attributes exist
if (availableAttributes.length === 0) {
return null
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Choose Options</h3>
{/* Dynamic Attribute Selectors */}
{availableAttributes.map((attribute) => (
<div key={attribute.name} className="space-y-2">
<label className="block text-sm font-medium">
{attribute.label}:
</label>
<select
value={currentSelection[attribute.name] || ''}
onChange={(e) => handleAttributeChange(attribute.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Choose {attribute.label}</option>
{attribute.values.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
))}
{/* Selection Status */}
<div className="mt-4 p-3 bg-gray-50 rounded-md">
{selectedVariant ? (
<div className="text-green-600">
<strong>Selected:</strong> SKU {selectedVariant.variantSku}
{selectedVariant.price && (
<span className="ml-2">${selectedVariant.price}</span>
)}
{!selectedVariant.inStock && (
<span className="ml-2 text-red-600">(Out of Stock)</span>
)}
</div>
) : (
<div className="text-gray-600">
Select all options to see pricing and availability
</div>
)}
</div>
</div>
)
}
The magic of this component is in its complete independence from specific attribute names. The entire UI is generated from the availableAttributes
array that comes from our dynamic discovery system.
Here's what makes this approach powerful:
Dynamic Discovery: The availableAttributes
calculation happens every render, so the UI automatically adapts if variant data changes.
URL Integration: Every selection change updates the URL immediately, ensuring the page state is always shareable and bookmarkable.
Status Feedback: The component shows real-time feedback about the current selection, including pricing and stock status when a complete variant is selected.
Graceful Degradation: If no attributes exist (simple product), the component renders nothing. If selection is incomplete, it shows helpful guidance.
Let me break down the key functions:
The handleAttributeChange
function manages selection updates:
const handleAttributeChange = (attributeName: string, value: string) => {
const newSelection = { ...currentSelection }
if (value === '') {
delete newSelection[attributeName]
} else {
newSelection[attributeName] = value
}
updateURL(newSelection)
}
This function creates a new selection object with the updated attribute value. If the user selects the "Choose..." placeholder option (empty string), it removes that attribute from the selection entirely. This handles cases where users want to "un-select" an attribute.
The updateURL
function manages browser navigation:
const updateURL = (newSelection: VariantSelection) => {
const queryString = buildVariantQueryString(newSelection)
const newUrl = queryString ? `/products/${productSlug}?${queryString}` : `/products/${productSlug}`
router.push(newUrl)
}
This function converts the selection to URL parameters and navigates to the new URL. The conditional logic ensures that when no attributes are selected, we navigate to the clean product URL without query parameters.
✅ Best Practice: Using router.push()
instead of client-side state ensures that the URL always reflects the current selection. This makes the experience more reliable because refreshing the page maintains the user's selections.
For enhanced user experience, you could extend this component with smart filtering:
// Enhanced version with smart filtering
const getAvailableValuesForAttribute = (attributeName: string): string[] => {
const compatibleVariants = findCompatibleVariants(variants, {
...currentSelection,
[attributeName]: undefined // Exclude current attribute from filtering
})
return getUniqueValuesForAttribute(compatibleVariants, attributeName)
}
This function would show only attribute values that are actually available given the current selection, preventing users from selecting impossible combinations.
Step 6: Server-Side Integration
The client component handles user interactions, but we need server-side logic to fetch data and parse initial URL state. This is where Next.js App Router's server components shine.
Here's how I integrated the variant system into a product page:
// src/app/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
import ProductVariantSelector from '@/components/ProductVariantSelector'
import {
getVariantAttributes,
parseVariantSelectionFromSearchParams,
findVariantByAttributes
} from '@/lib/variants'
interface ProductPageProps {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}
async function getProductBySlug(slug: string) {
// Fetch product from Payload CMS
const product = await payload.find({
collection: 'products',
where: { slug: { equals: slug } }
})
return product.docs[0] || null
}
async function getProductVariants(productId: string) {
const variants = await payload.find({
collection: 'product-variants',
where: { product: { equals: productId } }
})
return variants.docs
}
function mergeVariantWithProduct(product: any, variant: any) {
if (!variant) return product
return {
...product,
price: variant.price || product.price,
inStock: variant.inStock ?? product.inStock,
sku: variant.variantSku || product.sku,
// Merge other variant-specific fields as needed
}
}
export default async function ProductPage({ params, searchParams }: ProductPageProps) {
const product = await getProductBySlug(params.slug)
if (!product) {
notFound()
}
// Handle products with variants
if (product.hasVariants) {
const variants = await getProductVariants(String(product.id))
// Parse variant selection from URL parameters
const availableAttributes = getVariantAttributes(variants)
const variantSelection = parseVariantSelectionFromSearchParams(searchParams, availableAttributes)
// Find the selected variant and merge its data with the base product
const selectedVariant = findVariantByAttributes(variants, variantSelection)
const mergedProductData = mergeVariantWithProduct(product, selectedVariant)
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-4">{product.title}</h1>
{/* Product images, description, etc. */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
{/* Product gallery using merged data */}
<ProductGallery images={mergedProductData.images} />
</div>
<div>
{/* Product info using merged data */}
<div className="mb-6">
<p className="text-2xl font-bold">${mergedProductData.price}</p>
{!mergedProductData.inStock && (
<p className="text-red-600">Out of Stock</p>
)}
</div>
{/* Dynamic variant selector */}
<ProductVariantSelector
variants={variants}
currentSelection={variantSelection}
productSlug={params.slug}
/>
{/* Add to cart button (only show if variant is selected) */}
{selectedVariant && (
<button
className="w-full mt-6 px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700"
disabled={!selectedVariant.inStock}
>
Add to Cart - ${selectedVariant.price}
</button>
)}
</div>
</div>
</div>
)
}
// Handle simple products without variants
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-4">{product.title}</h1>
{/* Standard product display */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<ProductGallery images={product.images} />
<div>
<p className="text-2xl font-bold mb-4">${product.price}</p>
<button className="w-full px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Add to Cart
</button>
</div>
</div>
</div>
)
}
This server component orchestrates the entire variant experience. Here's how the data flow works:
1. Product Fetching: Load the base product data from Payload CMS using the URL slug.
2. Variant Loading: If the product has variants, fetch all related variant records.
3. URL Parsing: Convert URL parameters back into our internal selection format, but only for attributes that actually exist for this product.
4. Variant Resolution: Find the specific variant that matches the URL selection (if complete).
5. Data Merging: Overlay variant-specific data onto the base product data so the rest of the UI doesn't need to handle variants specially.
The mergeVariantWithProduct
function is crucial for maintaining a clean separation of concerns:
function mergeVariantWithProduct(product: any, variant: any) {
if (!variant) return product
return {
...product,
price: variant.price || product.price,
inStock: variant.inStock ?? product.inStock,
sku: variant.variantSku || product.sku,
images: variant.images || product.images,
}
}
This function creates a single product object that contains either base product data or variant-specific overrides. The product gallery, pricing display, and add-to-cart button can all work with this merged object without needing to understand variants.
🧠 Key Insight: The data merging approach means that most of your product display components don't need to change when you add variant support. They continue working with product objects that contain the appropriate price, stock status, and images.
The conditional rendering logic handles both variant and non-variant products elegantly:
if (product.hasVariants) {
// Show variant selector and use merged data
} else {
// Show standard product display
}
This ensures that simple products continue to work exactly as they did before, while products with variants get the full dynamic selection experience.
Real-World Debugging and Pitfalls
During development and deployment, I encountered several issues that other developers will likely face. Let me share the actual error messages and solutions I discovered.
Issue 1: TypeScript Errors with Dynamic Attribute Access
When I first tried to access variant attributes dynamically, TypeScript wasn't happy:
// This caused compilation errors
function getVariantValue(variant: ProductVariant, attributeName: string) {
return variant[attributeName] // TS Error: Element implicitly has an 'any' type
}
The error message was:
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'ProductVariant'.
No index signature with a parameter of type 'string' was found on type 'ProductVariant'.
I initially tried solving this with type assertions, but that felt dirty:
// Temporary workaround (not recommended)
return (variant as any)[attributeName]
The proper solution was redesigning the data structure to use the variantOptions
array approach I showed earlier. This eliminated the need for dynamic property access entirely:
// Clean solution using the array structure
function getVariantAttributeValue(variant: ProductVariant, attributeName: string): string | null {
const option = variant.variantOptions?.find(opt => opt.name === attributeName)
return option?.value || null
}
This approach is type-safe because we're accessing known array methods and properties rather than dynamic object keys.
Issue 2: URL Parameter Pollution
My initial URL parsing was too permissive and would try to process every query parameter as a variant attribute:
// Problematic approach
function parseSelection(searchParams: any) {
const selection = {}
Object.entries(searchParams).forEach(([key, value]) => {
selection[key] = value // Includes unrelated parameters!
})
return selection
}
This caused problems when URLs contained tracking parameters, search filters, or other unrelated query parameters. The selection object would include keys like utm_source
or page
, which would confuse the variant matching logic.
The fix was whitelisting only known variant attributes:
// Fixed approach with whitelisting
function parseVariantSelectionFromSearchParams(
searchParams: Record<string, string | string[] | undefined>,
availableAttributes: VariantAttribute[]
): VariantSelection {
const selection: VariantSelection = {}
availableAttributes.forEach(attribute => {
const paramValue = searchParams[attribute.name]
if (paramValue) {
selection[attribute.name] = Array.isArray(paramValue) ? paramValue[0] : paramValue
}
})
return selection
}
💡 Tip: Always validate input parameters against known valid values, especially when dealing with user-controlled data like URL parameters.
Issue 3: Inconsistent Dropdown Ordering
My first implementation of getVariantAttributes
processed attributes in whatever order Object.keys()
or Map.keys()
returned them. This led to inconsistent UI layouts where the dropdowns might appear in different orders on different page loads or different products.
The problem was most noticeable when comparing similar products — one might show "Color, Size, Material" while another showed "Material, Color, Size" for no logical reason.
I solved this by adding explicit sorting:
export function getVariantAttributes(variants: ProductVariant[]): VariantAttribute[] {
// ... collection logic ...
const attributes: VariantAttribute[] = []
attributeMap.forEach((valuesSet, attributeName) => {
const values = Array.from(valuesSet).sort() // Sort values within each attribute
attributes.push({
name: attributeName,
label: attributeName.charAt(0).toUpperCase() + attributeName.slice(1),
values,
})
})
// Sort attributes by name for consistent ordering
return attributes.sort((a, b) => a.name.localeCompare(b.name))
}
For more control over attribute ordering, you could enhance this by checking the product's variantOptionTypes
array order:
// Enhanced version that respects product-defined ordering
export function getVariantAttributesWithOrder(
variants: ProductVariant[],
product: Product
): VariantAttribute[] {
const discoveredAttributes = getVariantAttributes(variants)
if (product.variantOptionTypes) {
// Sort based on product's defined order
return product.variantOptionTypes
.map(optionType => discoveredAttributes.find(attr => attr.name === optionType.name))
.filter(Boolean) as VariantAttribute[]
}
return discoveredAttributes
}
Issue 4: Empty or Invalid Variant Data
During testing, I discovered that some variants had empty strings, null values, or whitespace-only values in their variantOptions
. This caused dropdown options to appear empty or contain just spaces.
The error showed up as blank dropdown options or options that looked selected but contained no visible text. Here's the defensive filtering I added:
export function getVariantAttributes(variants: ProductVariant[]): VariantAttribute[] {
const attributeMap = new Map<string, Set<string>>()
variants.forEach(variant => {
if (variant.variantOptions && Array.isArray(variant.variantOptions)) {
variant.variantOptions.forEach(option => {
// Strict validation of option data
if (option.name &&
option.value &&
typeof option.value === 'string' &&
option.value.trim()) {
if (!attributeMap.has(option.name)) {
attributeMap.set(option.name, new Set())
}
attributeMap.get(option.name)!.add(option.value.trim())
}
})
}
})
// ... rest of function
}
The key checks are:
option.name
exists and is truthyoption.value
exists and is truthyoption.value
is actually a string typeoption.value.trim()
has actual content after removing whitespace
⚠️ Common Bug: Always trim and validate string values from CMS data. Content editors sometimes accidentally add leading/trailing spaces or create empty entries.
Issue 5: Race Conditions with URL Updates
When users clicked dropdown options rapidly, sometimes the URL would get out of sync with the component state. This happened because multiple router.push()
calls could interfere with each other.
I solved this by debouncing URL updates and ensuring each update completes before starting the next:
// Enhanced component with debounced URL updates
const [isUpdating, setIsUpdating] = useState(false)
const updateURL = useCallback(async (newSelection: VariantSelection) => {
if (isUpdating) return
setIsUpdating(true)
try {
const queryString = buildVariantQueryString(newSelection)
const newUrl = queryString ? `/products/${productSlug}?${queryString}` : `/products/${productSlug}`
await router.push(newUrl)
} finally {
setIsUpdaging(false)
}
}, [router, productSlug, isUpdating])
This prevents race conditions by blocking new updates while one is already in progress.
The Complete Working System
After all the implementation steps, here's how the entire system works together in practice:
Content Editor Experience:
- Create a new product in Payload CMS
- Check "Has Variants" checkbox
- Define variant option types (e.g., Color, Size, Material)
- Create individual variants with specific combinations
- Set prices, SKUs, and stock status for each variant
End User Experience:
- Visit product page
- See dynamically generated dropdowns for available attributes
- Make selections, watch URL update in real-time
- Get immediate feedback on price and availability
- Share URL with friends — they see the same variant selection
Developer Experience:
- Zero code changes needed for new variant types
- All existing product display components continue working
- SEO automatically handles variant URLs
- Type safety maintained throughout the system
Here's the complete data flow:
// 1. Product defines structure
const product = {
hasVariants: true,
variantOptionTypes: [
{ name: "color", label: "Color" },
{ name: "size", label: "Size" }
]
}
// 2. Variants provide combinations
const variants = [
{
variantOptions: [
{ name: "color", value: "Red" },
{ name: "size", value: "Large" }
],
price: 29.99,
variantSku: "SHIRT-RED-L"
}
]
// 3. System auto-discovers attributes
const attributes = getVariantAttributes(variants)
// Result: [
// { name: "color", label: "Color", values: ["Red", "Blue"] },
// { name: "size", label: "Size", values: ["Large", "Medium"] }
// ]
// 4. UI generates appropriate controls
<ProductVariantSelector variants={variants} currentSelection={selection} />
// 5. User makes selection, URL updates
// URL: /products/shirt?color=Red&size=Large
// 6. System finds exact variant match
const selectedVariant = findVariantByAttributes(variants, { color: "Red", size: "Large" })
// 7. UI shows variant-specific data
<div>Price: ${selectedVariant.price}</div>
<div>SKU: {selectedVariant.variantSku}</div>
The beauty of this system is that every step adapts automatically to whatever variant structure exists in your data. Whether you're selling t-shirts with 2 attributes or industrial equipment with 8 attributes, the same code handles everything.
System Benefits:
✅ Zero Maintenance: New variant types require no code changes
✅ Type Safe: TypeScript catches errors at compile time
✅ SEO Friendly: Each variant combination gets its own URL
✅ User Friendly: Real-time feedback and shareable URLs
✅ Developer Friendly: Clean abstractions and clear separation of concerns
The system now powers several production e-commerce sites and has handled everything from simple apparel variants to complex industrial products with 6+ variant dimensions. Each time, the same codebase just works without modification.
Production Enhancements and Future Ideas
While the core system is production-ready, here are some enhancements that could make it even more powerful for large-scale e-commerce applications:
Smart Filtering and Availability Currently, all attribute values are always available in dropdowns. For better UX, you could implement smart filtering where selecting one attribute filters the available options for other attributes based on actual inventory:
function getAvailableValuesForAttribute(
variants: ProductVariant[],
currentSelection: VariantSelection,
attributeName: string
): string[] {
// Find variants that match current selection (excluding the attribute we're checking)
const partialSelection = { ...currentSelection }
delete partialSelection[attributeName]
const compatibleVariants = variants.filter(variant => {
return Object.entries(partialSelection).every(([attr, value]) => {
if (!value) return true
return getVariantAttributeValue(variant, attr) === value
})
})
// Only show values that exist in compatible variants
return getUniqueValuesForAttribute(compatibleVariants, attributeName)
}
Variant-Specific Images Extend the system to support variant-specific image galleries:
// Enhanced ProductVariant schema
{
name: 'images',
type: 'array',
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media'
}
]
}
// Enhanced merge function
function mergeVariantWithProduct(product: Product, variant: ProductVariant) {
return {
...product,
images: variant?.images?.length ? variant.images : product.images,
// ... other fields
}
}
Inventory Integration For real-time stock management, integrate with inventory systems:
async function getVariantWithInventory(variantSku: string) {
const [variant, inventory] = await Promise.all([
getVariantBySku(variantSku),
getInventoryLevel(variantSku)
])
return {
...variant,
inStock: inventory.quantity > 0,
stockLevel: inventory.quantity
}
}
Performance Optimizations For high-traffic sites with many variants:
// Redis caching for variant lookups
async function getCachedVariantAttributes(productId: string): Promise<VariantAttribute[]> {
const cacheKey = `variant-attributes:${productId}`
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
const variants = await getProductVariants(productId)
const attributes = getVariantAttributes(variants)
await redis.setex(cacheKey, 300, JSON.stringify(attributes)) // 5-minute cache
return attributes
}
Advanced SEO Generate variant-specific meta tags and structured data:
function generateVariantMetadata(product: Product, selectedVariant: ProductVariant) {
const variantTitle = selectedVariant
? `${product.title} - ${formatVariantOptions(selectedVariant.variantOptions)}`
: product.title
return {
title: variantTitle,
description: `${product.description} Available in multiple variants.`,
openGraph: {
title: variantTitle,
images: selectedVariant?.images || product.images
}
}
}
Internationalization Support For global sites, enhance the labeling system:
// Enhanced variant option types with i18n
{
name: 'variantOptionTypes',
type: 'array',
fields: [
{
name: 'name',
type: 'text',
required: true
},
{
name: 'labels',
type: 'group',
fields: [
{ name: 'en', type: 'text', label: 'English Label' },
{ name: 'es', type: 'text', label: 'Spanish Label' },
{ name: 'fr', type: 'text', label: 'French Label' }
]
}
]
}
These enhancements maintain the core principle of the system: everything adapts automatically to whatever variant structure exists in your data, with no hardcoded assumptions about specific attributes.
Wrapping Up: From Schema Trap to Data Freedom
Looking back at this journey, the transformation from hardcoded variant fields to a fully dynamic system represents more than just a technical improvement — it's a fundamental shift in how we think about e-commerce architecture.
The Old Mindset: Schema-Driven Development
When I first approached the variant problem, I was thinking like a traditional database developer. Products have colors and sizes, so I'll create color
and size
fields. Need materials? Add a material
field. This seemed logical and straightforward.
But this approach creates what I call the "Schema Trap" — every business requirement change becomes a development task. Adding new product categories means schema migrations, code updates, testing cycles, and deployments. The business becomes dependent on development velocity for basic content operations.
The New Mindset: Data-Driven Architecture
The breakthrough was realizing that variant attributes aren't inherent properties of products — they're flexible metadata that should be managed as data, not schema. Products define their own variant structure, and the system adapts automatically to whatever structure exists.
This shift transforms variant management from a development bottleneck into a content operation. Store owners can launch entirely new product categories with novel variant combinations in minutes, not sprint cycles.
Looking Forward
As e-commerce continues evolving toward more personalized and diverse product offerings, systems that can adapt automatically become increasingly valuable.
Whether you're building product configurators, pricing matrices, shipping calculators, or any system that needs to adapt to changing business requirements, the same principles apply: design for data-driven flexibility rather than schema-based rigidity.
If you're facing similar challenges with rigid e-commerce architectures, I hope this approach helps you break free from the schema trap and build systems that grow with your business needs.
Have you built similar flexible systems in your projects? I'd love to hear about your approach and any lessons learned.
Thanks, Matija