<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: StoreFlux</title>
    <description>The latest articles on DEV Community by StoreFlux (@storeflux).</description>
    <link>https://dev.to/storeflux</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4207347%2F2a8ef658-848f-45d9-9289-b7d5cb592cbb.png</url>
      <title>DEV Community: StoreFlux</title>
      <link>https://dev.to/storeflux</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/storeflux"/>
    <language>en</language>
    <item>
      <title>Building a "Sell Anything" Product Catalog in Postgres: JSONB + a Typed Shadow Index</title>
      <dc:creator>StoreFlux</dc:creator>
      <pubDate>Mon, 26 Oct 2026 16:37:16 +0000</pubDate>
      <link>https://dev.to/storeflux/building-a-sell-anything-product-catalog-in-postgres-jsonb-a-typed-shadow-index-1pi5</link>
      <guid>https://dev.to/storeflux/building-a-sell-anything-product-catalog-in-postgres-jsonb-a-typed-shadow-index-1pi5</guid>
      <description>&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;We're building &lt;a href="https://storeflux.store" rel="noopener noreferrer"&gt;StoreFlux&lt;/a&gt;, a headless commerce backend-as-a-service. Most commerce platforms quietly assume every tenant is selling the same &lt;em&gt;kind&lt;/em&gt; of thing — clothing, with size and color as the universal example fields. That assumption breaks the moment someone wants to list cars (mileage, transmission, fuel type), real estate (bedrooms, area, purpose), or anything else that isn't retail apparel.&lt;/p&gt;

&lt;p&gt;The constraint we set for ourselves: &lt;strong&gt;nothing about a product category is predefined or coded.&lt;/strong&gt; Every product has fixed basic information and a price. Everything else — field names, field types, which fields are filterable — is defined by the store owner themselves, while they add their &lt;em&gt;first&lt;/em&gt; product in a category. That becomes the category's &lt;strong&gt;form&lt;/strong&gt;. Every later product in that category reuses the same form; the same form can be attached to any number of categories; sub-categories inherit their parent's form unless they define their own.&lt;/p&gt;

&lt;p&gt;A Zameen-style property listing, a PakWheels-style vehicle listing, and a plain t-shirt all go through the exact same mechanism. No "car mode." No vertical-specific entities, migrations, or seed data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is harder than it sounds
&lt;/h3&gt;

&lt;p&gt;The obvious naive approach — a single &lt;code&gt;attributes JSONB&lt;/code&gt; column on the product table — solves half the problem instantly. Rendering a product page, building API responses, generating webhook payloads: all trivial, no joins, just read the JSON. For arbitrary key/value storage with unknown shape ahead of time, JSONB is the right starting point.&lt;/p&gt;

&lt;p&gt;It falls apart the moment you need &lt;strong&gt;fast filtering at scale&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Show me cars between 20,000 and 30,000 miles" — a numeric range query on a key that's different for every tenant and every category.&lt;/li&gt;
&lt;li&gt;"How many cars are automatic vs manual?" — a facet count, computed live, over an arbitrary enum field.&lt;/li&gt;
&lt;li&gt;Doing this with pure JSONB means either a full scan with a cast-and-compare predicate (&lt;code&gt;(attributes-&amp;gt;&amp;gt;'mileage')::numeric BETWEEN 20000 AND 30000&lt;/code&gt;), which doesn't use an index efficiently for ranges across unknown keys, or declaring a &lt;code&gt;btree&lt;/code&gt; expression index per key — which you can't do, because the keys aren't known at schema-design time. They're invented by a store owner at runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We considered three alternatives before landing on the current design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic DDL&lt;/strong&gt; — literally &lt;code&gt;ALTER TABLE&lt;/code&gt; to add a real column per tenant-defined field. Rejected early: migrations, locking, and column-count ceilings get ugly fast once tenants start defining dozens of fields across hundreds of categories. It also couples schema changes (expensive, rare) to a frequent product-owner action (cheap, common).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External search engine&lt;/strong&gt; (Elasticsearch/OpenSearch/Algolia) — still on the table for full-text search and large-scale faceting later, but we didn't want to take on an async sync pipeline and an eventual-consistency window before the core feature itself was even proven out. Every extra moving part is something that can silently drift out of sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSONB + GIN only&lt;/strong&gt; — covers existence/equality queries reasonably well via &lt;code&gt;jsonb_path_ops&lt;/code&gt;, but doesn't give you real range queries or facet counts without the expression-index problem above.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What we actually built: JSONB as source of truth, typed index as a disposable shadow
&lt;/h3&gt;

&lt;p&gt;The design that stuck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- source of truth — one row per product, no joins needed to render it&lt;/span&gt;
&lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;  &lt;span class="c1"&gt;-- GIN index, jsonb_path_ops&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- disposable, rebuildable filter index — NOT a source of truth&lt;/span&gt;
&lt;span class="n"&gt;product_field_values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;field_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;value_date&lt;/span&gt;
  &lt;span class="c1"&gt;-- real indexes on value_number, value_date, (field_key, value_text), etc.&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;products.attributes&lt;/code&gt; is what the product page, the API, the cards, the export, and the webhooks all read from. It never needs a join. It's the only place a product's data can be considered authoritative.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;product_field_values&lt;/code&gt; exists purely to make filtering and faceting fast. On &lt;strong&gt;every&lt;/strong&gt; product save — create or update — we rebuild that product's rows in &lt;code&gt;product_field_values&lt;/code&gt; from scratch, inside the same database transaction as the JSONB write. Not an incremental patch, not an async job: a synchronous rebuild, same transaction, same commit. That's the key invariant that keeps the two in lockstep — there is never a moment where the index can be observed out of sync with the source of truth, because they commit atomically together.&lt;/p&gt;

&lt;p&gt;With that table in place, "cars between 20k and 30k miles" becomes an ordinary indexed range query against &lt;code&gt;value_number&lt;/code&gt;, filtered by &lt;code&gt;field_key = 'mileage'&lt;/code&gt;. Facet counts become a &lt;code&gt;GROUP BY&lt;/code&gt; against &lt;code&gt;value_text&lt;/code&gt; or &lt;code&gt;value_bool&lt;/code&gt;. No per-tenant-key expression indexes needed — the index columns are generic and typed once, for every possible field, forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  The form layer on top
&lt;/h3&gt;

&lt;p&gt;The index table alone doesn't explain &lt;em&gt;where field types come from&lt;/em&gt;. That's a separate concept: a &lt;strong&gt;form&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;product_forms&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- field definitions: key, kind, required?, filterable?, ...&lt;/span&gt;
&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_form_id&lt;/span&gt;     &lt;span class="c1"&gt;-- a category points at a form&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a store owner adds the first product in a category, the "Details" section is empty and offers "Add detail": a name and a kind of value (short text, long text, number+unit, yes/no, choose one, choose many, date), plus flags like required, filterable, and show-on-card. Saving the product does two things at once: it writes the chosen values into that product's &lt;code&gt;attributes&lt;/code&gt; JSONB, &lt;em&gt;and&lt;/em&gt; it persists the field definitions as the category's form. The next product added to that category sees the same form pre-built. Attaching the same form to a different category, or letting sub-categories inherit their parent's form, are both just pointer operations on &lt;code&gt;categories.product_form_id&lt;/code&gt; — no data migration needed.&lt;/p&gt;

&lt;p&gt;A field's key and type are immutable once created (changing the type of an existing field would invalidate every stored value against it). Removing a field from a form doesn't delete the data already stored in products' JSONB — if the field is added back later, the old values simply reappear, since the JSONB never lost them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update semantics worth calling out
&lt;/h3&gt;

&lt;p&gt;Product &lt;strong&gt;create&lt;/strong&gt; validates every field in the form (required fields must be present, unless the product is saved as a draft). Product &lt;strong&gt;update&lt;/strong&gt; &lt;em&gt;merges&lt;/em&gt;: only the fields explicitly sent in the update change, &lt;code&gt;null&lt;/code&gt; clears a specific field, and anything else — including fields outside the current form — is left untouched. This matters for bulk import and partial-update API calls, where you don't want a product's unrelated custom fields silently wiped because an update payload didn't mention them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this bought us
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A property listing, a vehicle listing, and a t-shirt all flow through identical code paths. Zero vertical-specific entities or seed data.&lt;/li&gt;
&lt;li&gt;Filtering and faceting stay index-fast regardless of how many arbitrary fields a tenant has defined, because the shadow index is generically typed, not per-key.&lt;/li&gt;
&lt;li&gt;The source of truth stays simple (one JSONB column) for every consumer that doesn't care about filtering — rendering, export, webhooks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What it costs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A double write on every product save: the JSONB write, then the shadow-index rebuild. For our volumes this is a non-issue (it's one transaction, a handful of rows), but it's a real cost that a pure-JSONB design wouldn't have.&lt;/li&gt;
&lt;li&gt;The shadow index must always be &lt;em&gt;rebuilt&lt;/em&gt;, never incrementally patched, or it will drift. That's a discipline requirement on every code path that writes a product, not just a nice-to-have.&lt;/li&gt;
&lt;li&gt;No foreign key from &lt;code&gt;product_field_values&lt;/code&gt; back to &lt;code&gt;products&lt;/code&gt;, since the products table is partitioned — referential integrity here is enforced in application code, not the database, which is a trade-off we're watching carefully.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where we're not done yet
&lt;/h3&gt;

&lt;p&gt;CSV export doesn't currently include the dynamic field columns (import does — &lt;code&gt;field:{key}&lt;/code&gt; columns work both ways for import, just not export yet). Site-search text indexing doesn't currently include field values either. Both are straightforward extensions of the same pattern, just not built yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open question for anyone who's solved this before
&lt;/h3&gt;

&lt;p&gt;If you've built a "tenant defines arbitrary fields, needs fast filtering" system before — a form builder, a headless CMS with custom fields, a multi-vertical marketplace — I'd genuinely like to know what you did differently. Expression indexes per discovered key? A columnar side-store? CDC into something OLAP-ish? Happy to compare notes in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We're building this at &lt;a href="https://storeflux.store" rel="noopener noreferrer"&gt;storeflux.store&lt;/a&gt; — a headless commerce backend-as-a-service. This feature shipped a few days ago and is running on our own demo store now.&lt;/em&gt;&lt;br&gt;
``&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>architecture</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
