TLDR: One shared Supabase table + a
rolesarray beats two per-app contact tables. But the canonical isn't done when the rows merge — grep every hardcoded list or your new entity is invisible where it matters most.
the setup
an ecommerce business, a health and wellness brand I build for, needed two internal tools at roughly the same time.
A KOL CRM (key opinion leaders — their influencer and partner network) and a supply chain tracker to manage vendors, purchase orders, and inventory.
I built them as two separate Next.js apps. Separate codebases, separate Supabase (Postgres-as-a-service) databases.
Which seemed perfectly fine.
the wall
One contact is both a KOL and a supplier.
They existed as two entirely separate records — one in each system. Diverging fields. No connection between them.
That's not a bug, exactly. That's just what happens when two apps have no canonical home for shared entities — no single authoritative record that both systems trust.
the fix that actually worked
One shared Supabase project. One contacts table. Both apps reading and writing the same rows.
The interesting design question: how do you avoid locking a shared contact to one app's data shape?
We landed on roles text[] — a simple Postgres array with no constraint on the values.
Each app owns its own strings:
- the KOL CRM writes
kol - the supply chain app writes
supplier,co-packer,customer
That contact now has one record: roles = ['kol', 'supplier'].
Both apps resolve the same person, same phone, same everything. The /admin/contacts UI in the supply chain app has toggle checkboxes for each role. KOL CRM hardcodes ['kol'] on create. Neither app ever touches the other's role values. Clean ownership. Zero coordination overhead between two codebases that don't even share a deploy.
the part I completely missed
Merging the rows felt like the finish line.
It wasn't.
The supply-chain velocity reports, inventory dashboard, and lots table all had hardcoded lists of which products to show. The new canonical products existed in the database just fine. They just didn't appear anywhere, because fixed arrays buried in report queries had never been updated to include them.
Same story with Shopify products: wholesale and promo SKUs were separate line items in every report. I had to build a product aliases layer — a canonical_products concept where is_reportable gates what surfaces, and alias SKUs roll up to their canonical parent. The data was right. The display was WRONG. Silently wrong.
The fix took an afternoon. The surprise was that I needed it at all.
why this matters to me
A canonical entity isn't done when the rows merge.
Every surface that shows, charts, counts, or routes an entity can be gated by a hardcoded list that nobody updated. grep for them before you ship — fixed arrays, status enums, switch chains over type, anything that encodes "show these specific named things."
The rule that's now baked into how I build: derive the list from data, don't maintain a parallel hardcoded one. "Show all contacts where roles @> '{kol}'" is self-maintaining. "Show these 9 KOLs" forgets every future addition.
This bites on every multi-system build I've touched.
The entities merge. The lists don't. Check the lists first.