A monitor, a laptop, and a power supply have almost nothing in common on a spec sheet. A monitor has panel type, refresh rate, and response time. A laptop has CPU, RAM, and battery capacity. A power supply has wattage, efficiency rating, and modularity.
The system this replaced was a spreadsheet per category, which meant comparing two categories was manual and reporting was copy-paste. A relational schema with a column per attribute would have needed a migration for every new product type.
A flexible column, a fixed envelope
The compromise is JSONB for the attributes, inside a table that keeps the relational parts relational. A product row holds its identity, category, and relations in ordinary columns. Its specification block is a JSONB column whose shape depends on the category.
model Product {
id String @id @default(cuid())
categoryId String
category Category @relation(fields: [categoryId], references: [id])
attributes Json
createdAt DateTime @default(now())
}
What this buys is that adding a new product category is data, not a deployment. What it costs is that the database cannot enforce the shape, so the application has to.
The definition owns the form
Each category carries a schema: an ordered list of fields with a type, a unit, and whether it is comparable. The admin form is generated from that schema, and the comparison view reads the same schema to decide which columns line up.
Having one definition feed both is what makes the flexibility usable. When the form and the comparison table are built from different sources, they drift, and a field that exists in one disappears from the other. Here they cannot, because there is only one place to change.
Validation follows the same rule. The schema says a field is numeric with a unit, so the API rejects a string before it reaches the column. JSONB removes the database’s type check, which means the application layer has to take it seriously rather than treat the column as a bag.
Comparisons need a baseline
The comparison view compares products within a category against a chosen baseline, marking better and worse per field. That requires knowing which direction is better, which is a property of the field rather than the value: higher refresh rate is better, higher response time is not.
Storing that direction on the field definition, next to the unit, keeps the comparison logic generic. The alternative is a hardcoded list of which attributes are inverted, which is exactly the kind of list that goes stale the moment someone adds a category.



