A no-code builder without JSONB — flexibility without compromise
Most no-code platforms stash user data in JSONB. We do the opposite — we generate real PostgreSQL columns. Here's why and how.
Look at the no-code market — Airtable, Notion, Bubble, Retool — and you’ll see the same pattern: flexibility at the cost of performance. Customer adds a field → it goes to JSONB. Searching? Table scan. Sorting? Slow. Reporting? Forget it.
At Avenit we built it entirely differently. Here’s how.
The problem with JSONB
PostgreSQL has a great JSONB type. It’s flexible, indexable (GIN), with query functions. But:
-- "Find customers in segment 'enterprise' in region 'PL'"
SELECT * FROM contractors
WHERE custom_fields->>'segment' = 'enterprise'
AND custom_fields->>'region' = 'PL';
This query either does a sequential scan, or uses a GIN index which is nowhere near as fast as a B-tree on a plain column. At 10 million rows that’s the difference between 50ms and 5s.
Now imagine you want to:
- add a check constraint (
segment IN ('enterprise', 'smb', 'startup')) - enforce NOT NULL
- create a foreign key from a custom field
- index in compound order (
segment, thenregion)
With JSONB — gymnastics. With real columns — just standard DDL.
Our solution: the DDL generator
In Avenit every system entity has two tables:
core_contractors — columns we, the developers, write
core_contractors_ext — columns added by the customer via the no-code builder
When a customer clicks “Add field customer_number as text” in the UI:
- We validate the name (no collision with module prefixes, no reserved words).
- We check
pg_catalogto ensure the column doesn’t already exist. - We execute
ALTER TABLE core_contractors_ext ADD COLUMN customer_number text. - We save the meta-definition in
core_custom_field_definitions.
That’s a real ALTER TABLE — executed on that tenant’s own database (see our database-per-tenant post).
The cost I had to accept
A JOIN on every query for contractors. We have a “BASE + EXT” structure — to fetch the full contractor we do:
SELECT c.*, e.*
FROM core_contractors c
INNER JOIN core_contractors_ext e ON e.id = c.id
WHERE c.id = $1;
But INNER JOIN on PK from both sides, with the _ext row always existing (we create it with a trigger on insert into core_contractors) — it’s <1ms. Not measurable in a normal workflow.
What I get
- Queries don’t need rewriting. Customer adds field
segment_b2b, I writeWHERE segment_b2b = 'enterprise'and it works. - Indexes go on plain columns. When a customer sorts by their field, PostgreSQL can use B-tree, bitmap filter, whatever.
- Constraints work normally. Check, NOT NULL, foreign key, unique — everything PostgreSQL offers, available to custom columns.
- Migrations make sense. When we ever need to change a customer’s column type — standard Drizzle / migration tooling.
When JSONB does make sense
I’m not an anti-JSONB zealot. We use it in:
- Per-tenant configuration (
tenant_settings.config) — rarely read, never indexed by inner value. - Webhook payloads (
integration_events.payload) — append-only, serialize-once-deserialize-once. - Audit snapshots (
audit_log.diff) — compression, never queried by content.
JSONB shines where structure is dynamic and you don’t know in advance what’s in it. Where structure is known (because the customer defined it themselves!), a real column always wins.
The bottom line
The customer sees a standard field-builder UI — just like Airtable. But underneath, a real database schema is being generated, ready for queries, reports, BI and everything PostgreSQL has offered for 30 years.
That’s the “no-code without compromise” promise — and we keep it because we don’t lie to ourselves about the architecture.