· 2 min read
Migrating an AppSheet CRM to Express + PostgreSQL
Replaced a no-code AppSheet CRM hitting its limits with a typed Express + PostgreSQL backend and a React front-end — without losing a single record.
Problem
A small sales team outgrew AppSheet: slow syncs, no real relational integrity, impossible to integrate with billing, and per-user pricing scaling badly. They needed ownership of their data and an API other tools could call.
Result
Cut manual data-sync from ~6 hrs/week to near-zero; eliminated the daily row-count reconciliation.
Stack
TL;DR
| Aspect | Before (AppSheet) | After (Express + PG) |
|---|---|---|
| Data model | Flat Google Sheets, duplicated | Normalized relational tables |
| Integrity | None | Foreign keys + constraints |
| Integration | Google Apps Script hacks | REST API |
| Cost scaling | Per AppSheet user | Flat hosting |
Architecture
A clean split: Express API + PostgreSQL as the source of truth, React SPA for the team, and a one-time ETL bridge that pulled AppSheet’s sheets into relational tables.
AppSheet (Google Sheets) ──ETL──▶ PostgreSQL (normalized)
│
▼
Express API (REST)
│
┌───────────┴───────────┐
▼ ▼
React SPA Billing / webhooks
- Drizzle ORM for typed queries + generated migrations (
push: falsein prod). - Zod validation at every route boundary.
- A read-only “shadow” period: both systems ran in parallel for two weeks while we reconciled row counts daily.
Interesting decisions
- Model the data relationally, not as a 1:1 of the sheets. AppSheet’s flat sheets
had a “customer” column duplicated across five tabs. Normalizing to
customers ↔ contacts ↔ dealsand backfilling foreign keys unlocked billing and reporting. - Idempotent ETL with a cursor. Each AppSheet row carried an
updatedAt; the importer tracked the last-seen timestamp per table, so re-runs never double-inserted. - Keep AppSheet as a read mirror during cutover via a scheduled export job, so holdouts didn’t lose visibility.
Lessons learned
- Date/timezones: AppSheet stores datetimes as the editor’s local time, unlabelled.
One-time normalization to UTC + an explicit
tzcolumn prevented subtle drift. - Validate before you migrate. A Zod pass over exported rows surfaced ~3% junk data (orphaned FKs, malformed emails) before it hit Postgres.
- Parallel run beats big-bang. The two-week shadow caught three mapping bugs a dry-run script had missed.
Pros & cons
Pros: full data ownership, an API other services can call, predictable flat cost. Cons: you now operate a database (backups, monitoring) — AppSheet hid that from you.
Work with me
I build business software like this — fast, with AI-assisted delivery. Open to remote (US/EU) roles and freelance.