Mayur Mehta

Building MapChat · Part 2 of 4 · Architecture case study

How we built MapChat's backend: one Postgres for an app and a venue CRM

The data platform behind MapChat · April 2026 – present

Reversing my own call

The native rebuild started on February 23, 2026, and the plan I made the next day was to keep Bubble.io, the no-code platform behind the first version, as the backend. Five weeks of driving its API from the new app showed where it broke. Every call was capped at 50 records. Day queries were rounded in UTC, so the app once showed 16 events where Bubble held 156. List fields silently dropped empty values. Distance math was wrong at high latitudes and couldn't use an index. And every endpoint change had to be written up as click-by-click instructions for a visual editor.

On April 1 I reversed the decision. The new backend, on Supabase and PostgreSQL, landed in two days. It was rewritten service by service (auth, profiles, venues and events, check-ins, waves, then chat) while the Bubble web app stayed live. Each limit became a design rule.

The architecture

Two products share one database. The app is React Native on Expo, one codebase for iOS and Android. The venue CRM is a separate product that our founder's team builds and runs. Supabase provides Postgres with PostGIS, sign-in, realtime updates, storage, scheduled jobs and Edge Functions.

Two rules decide where logic lives. Reads and multi-step writes are Postgres functions by default, so they run inside the same access rules as everything else. Edge Functions are the exception, kept for work that has to reach the outside world, such as email, SMS, push, image processing, account deletion, the recurrence engine, the AI bio writer and the link to the games server. They sit outside the database's safety net and start slower, so each one has to earn its place.

CLIENTS MapChat app React Native + Expo iOS and Android Venue CRM the CRM team's build catalogers + agents reads the catalog, owns social data writes the catalog Supabase one production database for both Postgres + PostGIS 160 tables Row-level security on every table RPCs the default path Edge Functions outside calls only Realtime chat, check-ins pg_cron scheduled jobs Outside services OpenAI (bio writer) · Resend (email) Telnyx (SMS) · Expo (push)
The system today. The app also uses Mapbox, Sentry and Amplitude on the device, and talks to a separate games server for lobbies and games (Part 1).

Rules that live in the schema

I wanted the database itself to enforce the product's rules, so that no client, agent or future feature could forget them.

The schema has grown to 160 tables, with row-level security on every one of them.

One database, two products

The CRM team owns the catalog of venues, events and specials, and we own the social product. On April 28 we locked a single-database design. The CRM writes the catalog, the app reads it, and social data belongs to the app, with the split enforced by row-level security. CRM-only tables sit in their own schema that the app never sees. A catalog row reaches phones only when a database trigger marks it visible, and that flag cascades from a venue to its events and specials.

The two teams' coding agents worked from a written contract. Every request was a handoff packet with its decisions confirmed (“do not re-litigate”), a verification gate, and a clear boundary, since our side never pushed code to theirs. I defined the venue data dictionary the catalogers work against. Before approving the CRM's cutover to the shared database, we re-ran its 12 acceptance checks independently, and all 12 passed.

The existing catalog moved in through an all-SQL migration in nine phases, ordered by foreign keys, safe to re-run and gated on a 50-venue dry run. It loaded 1,925 venues and 40,260 related rows in about 11 minutes, with a 100% row match. Production moved to the new schema on May 4 with 166K rows loaded and every user account preserved.

Catalogers and CRM agents venues, events, specials one transactional write Catalog tables CRM writes · app reads (RLS) trigger: published = visible Consumer-visible rows flag cascades venue → events → specials repeating items Recurrence rules (RFC 5545) one template per repeating event daily materializer Dated instances rolling 30-day window read models rebuilt daily Feed and map queries read models · bounding-box RPCs under 1 s cold, ~200 ms warm MapChat app iOS and Android
From a cataloger's edit to a phone. Highlighted are the step where ownership is enforced and the step where repeating events become dated rows.

Supply with no licensed feed

A venue app with no venues is dead on arrival, and there was no licensed venue or event feed to buy. The CRM team built the catalog instead. Venues are seeded from Google Places and state liquor-license lists, then enriched by agents that browse venue websites, Instagram and Yelp the way a person would. I designed the scraping and enrichment harnesses those agents run, through written handoffs that the CRM's coding agent carried out. An LLM classifies each find with a plain test (“Is there something happening beyond the food/drink deal itself? If not, it's NOT an event”), and a recurring event is admitted only when its schedule is about 80% confirmed.

Two pieces on our side made that supply usable. The recurrence engine stores repeating events and specials as standard calendar rules (RFC 5545) and turns them into dated rows every day, so reading a night's events is one indexed range scan and each night can carry its own attendees and changes. Before we switched to that engine in May, an audit of all 13,477 production rules found none malformed and recovered 178 series that had never produced a date. The old path stayed disabled as a rollback for nine days.

The second piece opens cities on its own. You count as in-market when at least 3 published venues sit within 35 miles, checked by a query that takes about 12 ms instead of a count that took over a second, and the check fails open. New metros go live with no app release.

1,925 → 17,000+venues on the platform
100,000+live events and specials
No licensed feedGoogle Places and license lists, enriched by agents
3 in 35 mipublished venues open a new city

The catalog now covers markets from Los Angeles and Washington, DC, to New York, Boston and Dallas. The growth is the CRM team's work, on a platform we built.

The one AI feature, built to a cost and safety bar

The first version already had an AI bio writer, built on the no-code platform. We kept the feature and rebuilt it as an Edge Function calling GPT-4o. A model call costs real money, so the chain fails closed at the cheapest possible point. A kill switch in the app's config comes first, then a signed-in check at the edge, a moderation check on every field the user wrote, and a rate limit checked before any token is spent. Generate mode returns three candidates with suggested tweaks, refine mode returns one, and bios are capped at 250 characters.

The launch flag stayed off until a 9-case deterministic test and a 10-case human-graded eval passed against the deployed function. The human review changed the prompt the same day, adding a ban on exclamation marks, a guard against generic openers, a rule against inventing details in suggested tweaks, and an instruction to keep the user's own voice. In production it has handled 59 of 59 calls successfully, at about $0.003 a call and a 4-second median.

Feature switched on? kill switch in app config stop Signed in? JWT checked at the edge stop Moderation every user field, fails closed stop Rate limit checked before a token is spent stop GPT-4o, JSON mode three candidates, or one refine Shape check 250-character cap, then the app
Each gate can stop the request before the model is ever called.

Fast on a small database

The database runs on a small instance with a 256 MB cache, and the CRM's scrapers rewrite event rows all day. Guessing at performance fixes wasn't safe, so I made a performance lab the standing gate. It's a local Docker replica of production, seeded at production scale and queried as the app's real role rather than an admin, and the lab's query plan has to match production's before a change ships. Since June 19, every database performance change has gone through it.

When venue-feed loads started failing now and then, production statistics traced the cause to cache residency, a working set of roughly 442 MB against that 256 MB cache, rather than to query cost. The fix cut the data each query touches, so the feed's first page now does about 63 times less work. It was measured in the lab first, production matched the lab's plan within 1.5%, and feed errors went from 63 in two weeks to none in the next 42 calls. A timeout increase applied along the way was labeled, in its own migration, as a mitigation and not a fix. Map and feed now load in under a second on a cold launch and in about 200 ms warm, while the catalog has grown ninefold.

Production schema, statistics, real query plans snapshot Docker replica seeded at production scale queried as the app's real role Plan-fidelity gate lab plan must match production pass Migration ships then re-measured on production re-measure STANDING GATE FOR EVERY DATABASE PERFORMANCE CHANGE
No database performance change ships on a guess.