<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Iconium Bilişim]]></title><description><![CDATA[Iconium Bilişim]]></description><link>https://iconiumbilisim.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Iconium Bilişim</title><link>https://iconiumbilisim.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 10 Oct 2026 18:52:09 GMT</lastBuildDate><atom:link href="https://iconiumbilisim.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How We Structure Product Variants with Next.js, Prisma and PostgreSQL]]></title><description><![CDATA[Product variants look simple at first.
A product has a size, a color, maybe a material.
Then the real requirements arrive.
You need different prices, different stock levels, different SKUs, optional i]]></description><link>https://iconiumbilisim.hashnode.dev/how-we-structure-product-variants-with-next-js-prisma-and-postgresql</link><guid isPermaLink="true">https://iconiumbilisim.hashnode.dev/how-we-structure-product-variants-with-next-js-prisma-and-postgresql</guid><category><![CDATA[Next.js]]></category><category><![CDATA[ecommerce]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[prisma]]></category><dc:creator><![CDATA[Iconium Bilisim]]></dc:creator><pubDate>Wed, 07 Oct 2026 17:08:58 GMT</pubDate><content:encoded><![CDATA[<p>Product variants look simple at first.</p>
<p>A product has a size, a color, maybe a material.</p>
<p>Then the real requirements arrive.</p>
<p>You need different prices, different stock levels, different SKUs, optional images, disabled combinations, campaign prices, and sometimes completely custom option groups.</p>
<p>At that point, product variants stop being a frontend problem and become a data-modeling problem.</p>
<p>This article explains a practical way to structure product variants in a modern e-commerce application using Next.js, Prisma and PostgreSQL.</p>
<h2>The basic problem</h2>
<p>Imagine a product like a chair.</p>
<p>It may have:</p>
<ul>
<li><p>Color: Black, White, Walnut</p>
</li>
<li><p>Fabric: Linen, Velvet</p>
</li>
<li><p>Leg Type: Metal, Wood</p>
</li>
</ul>
<p>If every combination is valid, that already creates:</p>
<pre><code class="language-txt">3 × 2 × 2 = 12 variants
</code></pre>
<p>For each variant, we may need to store:</p>
<ul>
<li><p>SKU</p>
</li>
<li><p>stock</p>
</li>
<li><p>price</p>
</li>
<li><p>discounted price</p>
</li>
<li><p>barcode</p>
</li>
<li><p>weight</p>
</li>
<li><p>image</p>
</li>
<li><p>active/inactive status</p>
</li>
</ul>
<p>The important part is that the product itself and the sellable combinations should be modeled separately.</p>
<h2>A simple product model</h2>
<p>A simplified Prisma model may start like this:</p>
<pre><code class="language-prisma">model Product {
  id          String           @id @default(cuid())
  name        String
  slug        String           @unique
  description String?
  variants    ProductVariant[]
  options     ProductOption[]
  createdAt   DateTime         @default(now())
  updatedAt   DateTime         @updatedAt
}
</code></pre>
<p>The <code>Product</code> represents the main catalog item.</p>
<p>For example:</p>
<pre><code class="language-txt">Modern Armchair
</code></pre>
<p>The actual purchasable combinations live in <code>ProductVariant</code>.</p>
<h2>Product options</h2>
<p>Options define the configurable dimensions of a product.</p>
<p>For example:</p>
<pre><code class="language-prisma">model ProductOption {
  id        String               @id @default(cuid())
  name      String
  productId String
  product   Product              @relation(fields: [productId], references: [id])
  values    ProductOptionValue[]
}
</code></pre>
<p>An option can be:</p>
<pre><code class="language-txt">Color
Fabric
Size
Material
</code></pre>
<p>Then each option contains values:</p>
<pre><code class="language-prisma">model ProductOptionValue {
  id       String        @id @default(cuid())
  value    String
  optionId String
  option   ProductOption @relation(fields: [optionId], references: [id])
}
</code></pre>
<p>For example:</p>
<pre><code class="language-txt">Color
  - Black
  - White
  - Walnut
</code></pre>
<p>This structure is much more flexible than adding columns like:</p>
<pre><code class="language-txt">color
size
material
</code></pre>
<p>directly to the product table.</p>
<h2>Product variants</h2>
<p>Now we can define the sellable combination.</p>
<pre><code class="language-prisma">model ProductVariant {
  id        String   @id @default(cuid())
  productId String
  product   Product  @relation(fields: [productId], references: [id])

  sku       String   @unique
  price     Decimal
  stock     Int      @default(0)
  isActive  Boolean  @default(true)

  values    ProductVariantValue[]
}
</code></pre>
<p>The <code>ProductVariantValue</code> table connects a variant to its selected option values.</p>
<pre><code class="language-prisma">model ProductVariantValue {
  id            String             @id @default(cuid())
  variantId     String
  optionValueId String

  variant       ProductVariant     @relation(fields: [variantId], references: [id])
  optionValue   ProductOptionValue @relation(fields: [optionValueId], references: [id])

  @@unique([variantId, optionValueId])
}
</code></pre>
<p>This gives us a normalized structure.</p>
<p>A variant might represent:</p>
<pre><code class="language-txt">Black + Velvet + Metal
</code></pre>
<p>while another represents:</p>
<pre><code class="language-txt">Walnut + Linen + Wood
</code></pre>
<h2>Why not store variants as JSON?</h2>
<p>JSON is tempting.</p>
<p>For example:</p>
<pre><code class="language-json">{
  "color": "Black",
  "fabric": "Velvet",
  "legType": "Metal"
}
</code></pre>
<p>PostgreSQL handles JSONB very well, and in some systems this is completely valid.</p>
<p>But there are trade-offs.</p>
<p>Relational variant tables make it easier to:</p>
<ul>
<li><p>enforce references</p>
</li>
<li><p>query by option</p>
</li>
<li><p>build filters</p>
</li>
<li><p>maintain consistent option values</p>
</li>
<li><p>manage inventory</p>
</li>
<li><p>prevent invalid data</p>
</li>
<li><p>create admin tools</p>
</li>
</ul>
<p>JSONB becomes attractive when the product schema is extremely dynamic.</p>
<p>For many standard e-commerce systems, I still prefer a relational core.</p>
<h2>Variant uniqueness</h2>
<p>One important requirement is preventing duplicate combinations.</p>
<p>You do not want two variants representing:</p>
<pre><code class="language-txt">Black + Velvet + Metal
</code></pre>
<p>for the same product unless there is a very specific business reason.</p>
<p>This cannot always be solved with a simple database constraint because the combination is represented through multiple rows.</p>
<p>A practical solution is to generate a normalized combination key.</p>
<p>For example:</p>
<pre><code class="language-txt">color:black|fabric:velvet|leg:metal
</code></pre>
<p>Then store it on the variant:</p>
<pre><code class="language-prisma">model ProductVariant {
  id             String  @id @default(cuid())
  productId      String
  combinationKey String

  @@unique([productId, combinationKey])
}
</code></pre>
<p>Before saving:</p>
<pre><code class="language-ts">const combinationKey = selectedValues
  .sort((a, b) =&gt; a.optionId.localeCompare(b.optionId))
  .map(x =&gt; `${x.optionId}:${x.valueId}`)
  .join('|')
</code></pre>
<p>This makes duplicate detection much easier.</p>
<h2>Stock belongs to the variant</h2>
<p>A common mistake is storing stock only on the product.</p>
<p>That works until different combinations have different inventory levels.</p>
<p>For example:</p>
<pre><code class="language-txt">Black / Small  -&gt; 8
Black / Large  -&gt; 2
White / Small  -&gt; 0
White / Large  -&gt; 5
</code></pre>
<p>The real inventory unit is the variant, not the parent product.</p>
<p>So in most cases:</p>
<pre><code class="language-txt">ProductVariant.stock
</code></pre>
<p>is the correct place for inventory.</p>
<p>The product-level stock can then be calculated:</p>
<pre><code class="language-ts">const totalStock = product.variants.reduce(
  (total, variant) =&gt; total + variant.stock,
  0
)
</code></pre>
<h2>Pricing strategy</h2>
<p>Pricing can also exist at different levels.</p>
<p>A useful model is:</p>
<ul>
<li><p>Product has a base price</p>
</li>
<li><p>Variant can optionally override the price</p>
</li>
</ul>
<p>For example:</p>
<pre><code class="language-prisma">model Product {
  basePrice Decimal
}
</code></pre>
<p>and:</p>
<pre><code class="language-prisma">model ProductVariant {
  price Decimal?
}
</code></pre>
<p>Then:</p>
<pre><code class="language-ts">const finalPrice = variant.price ?? product.basePrice
</code></pre>
<p>This works well when most variants share a price but a few combinations cost more.</p>
<h2>Variant images</h2>
<p>Images are another interesting case.</p>
<p>Sometimes the image belongs to the product.</p>
<p>Sometimes it belongs to the variant.</p>
<p>For example:</p>
<pre><code class="language-txt">Black chair -&gt; black chair images
White chair -&gt; white chair images
</code></pre>
<p>A useful approach is allowing both.</p>
<pre><code class="language-prisma">model ProductVariantImage {
  id        String         @id @default(cuid())
  url       String
  sortOrder Int            @default(0)
  variantId String
  variant   ProductVariant @relation(fields: [variantId], references: [id])
}
</code></pre>
<p>Then the frontend can fall back to product images if the variant has no custom image.</p>
<h2>Querying products efficiently</h2>
<p>Variant-heavy product queries can become expensive if every relation is always loaded.</p>
<p>A naive query like this:</p>
<pre><code class="language-ts">await prisma.product.findUnique({
  where: { slug },
  include: {
    options: {
      include: {
        values: true
      }
    },
    variants: {
      include: {
        values: {
          include: {
            optionValue: true
          }
        }
      }
    }
  }
})
</code></pre>
<p>may be completely fine for smaller catalogs.</p>
<p>But as the dataset grows, it is worth thinking about:</p>
<ul>
<li><p>selecting only required fields</p>
</li>
<li><p>pagination</p>
</li>
<li><p>caching</p>
</li>
<li><p>separate inventory queries</p>
</li>
<li><p>denormalized read models</p>
</li>
<li><p>server-side caching</p>
</li>
</ul>
<p>The right optimization depends on the real traffic pattern.</p>
<h2>Building the frontend selector</h2>
<p>Once the data model is clean, the frontend becomes easier.</p>
<p>The frontend usually needs:</p>
<ol>
<li><p>available options</p>
</li>
<li><p>selected values</p>
</li>
<li><p>matching variant</p>
</li>
<li><p>price</p>
</li>
<li><p>stock</p>
</li>
<li><p>images</p>
</li>
</ol>
<p>A simplified variant lookup can look like this:</p>
<pre><code class="language-ts">function findVariant(variants, selections) {
  return variants.find(variant =&gt;
    Object.entries(selections).every(([optionId, valueId]) =&gt;
      variant.values.some(
        item =&gt;
          item.optionId === optionId &amp;&amp;
          item.valueId === valueId
      )
    )
  )
}
</code></pre>
<p>In production, I normally transform variant data into a structure optimized for lookup rather than scanning all variants every time the user changes an option.</p>
<h2>Disable impossible combinations</h2>
<p>A better UX does not let users select combinations that do not exist.</p>
<p>Suppose:</p>
<pre><code class="language-txt">Black + Velvet
</code></pre>
<p>exists, but:</p>
<pre><code class="language-txt">White + Velvet
</code></pre>
<p>does not.</p>
<p>When the user selects White, Velvet should become unavailable.</p>
<p>This requires checking valid combinations after every selection.</p>
<p>It is one of the areas where good backend modeling directly improves frontend UX.</p>
<h2>Admin panel matters too</h2>
<p>The hardest part of variants is often not the customer-facing selector.</p>
<p>It is the admin panel.</p>
<p>A practical admin interface should allow users to:</p>
<ul>
<li><p>define option groups</p>
</li>
<li><p>add option values</p>
</li>
<li><p>generate combinations</p>
</li>
<li><p>disable invalid combinations</p>
</li>
<li><p>update stock in bulk</p>
</li>
<li><p>update prices in bulk</p>
</li>
<li><p>edit SKUs</p>
</li>
<li><p>assign images</p>
</li>
<li><p>search variants</p>
</li>
</ul>
<p>If the merchant has 150 variants, editing each one manually becomes painful very quickly.</p>
<p>Bulk operations are essential.</p>
<h2>When the model needs to become more flexible</h2>
<p>Some businesses need more than standard variants.</p>
<p>Examples include:</p>
<ul>
<li><p>custom dimensions</p>
</li>
<li><p>made-to-order furniture</p>
</li>
<li><p>configurable industrial products</p>
</li>
<li><p>engraving</p>
</li>
<li><p>free-text customization</p>
</li>
<li><p>quantity-based pricing</p>
</li>
</ul>
<p>At that point, it may make sense to separate:</p>
<pre><code class="language-txt">Variant options
</code></pre>
<p>from:</p>
<pre><code class="language-txt">Configuration options
</code></pre>
<p>A configuration option does not always create a new inventory unit.</p>
<p>For example, engraving text should not generate thousands of SKU variants.</p>
<p>This distinction is important in custom e-commerce systems.</p>
<h2>Final thoughts</h2>
<p>Product variants are not just a UI feature.</p>
<p>They affect:</p>
<ul>
<li><p>catalog architecture</p>
</li>
<li><p>inventory</p>
</li>
<li><p>pricing</p>
</li>
<li><p>filtering</p>
</li>
<li><p>product feeds</p>
</li>
<li><p>order items</p>
</li>
<li><p>admin UX</p>
</li>
<li><p>performance</p>
</li>
</ul>
<p>A good variant model should remain understandable when a store grows from 20 products to several thousand.</p>
<p>For most projects, I prefer a relational structure with:</p>
<pre><code class="language-txt">Product
ProductOption
ProductOptionValue
ProductVariant
ProductVariantValue
</code></pre>
<p>and then add denormalization only when real performance requirements justify it.</p>
<p>We use similar architecture decisions when building custom e-commerce systems at <a href="https://www.iconiumbilisim.com/e-ticaret-yazilimi">Iconium Bilişim</a>.</p>
<p>The goal is not to create the most complicated schema possible.</p>
<p>The goal is to build a model that stays predictable as the business grows.</p>
]]></content:encoded></item></channel></rss>