--- path: 2026_03_30_CRM_Architecture_Go_Postgres title: "Building a CRM on Go, PostgreSQL, and React: Architecture Decisions" desc: "Why Reactima chose Go, PostgreSQL with pgvector and PostGIS, and React for its CRM architecture." date: Mar 30, 2026 --- # Building a CRM on Go, PostgreSQL, and React: Architecture Decisions *March 2026* Most CRMs are built on Rails, Django, or Node.js. Reactima isn't. Our backend is Go with the Echo framework, our database is PostgreSQL with pgvector and PostGIS extensions, and our frontend is React. Here's why we made these choices and what we learned. ## Why Go CRM backends are deceptively write-heavy. Every page view triggers enrichment lookups, permission checks, and activity logging. Every search query hits filters across millions of records. Go's concurrency model — goroutines and channels — handles this naturally. Our API response times sit at p50 under 15ms and p99 under 80ms for authenticated endpoints. We didn't have to think about async frameworks or event loops; Go's scheduler handles the concurrency we need out of the box. The other advantage is deployment simplicity. A Go binary is a single static file. We deploy on Dokku, and our entire backend is one container with no runtime dependencies beyond PostgreSQL. ## Why PostgreSQL (with pgvector and PostGIS) Every CRM eventually needs three things beyond basic CRUD: full-text search, vector similarity (for AI features), and geographic queries (for location-based filtering). PostgreSQL handles all three natively with extensions. **pgvector** powers our AI features — we store embeddings for contacts, notes, and activity logs, enabling semantic search ("find candidates similar to this person") without a separate vector database. **PostGIS** handles location-aware queries — filtering candidates or companies by region, calculating proximity for in-person roles, and geofencing for market research. We use `sqlx` for database access with a codegen pattern: standard CRUD operations are generated from schema definitions, while business logic lives in custom controllers. Table names follow an `hh_` prefix convention for namespacing. ## Why React (Not a SPA Framework) Our frontend is React with server-side rendered marketing pages served from the Go backend. The CRM application itself is a SPA at `crm.reactima.com`, but marketing pages are pre-rendered HTML templates served directly by Echo. This split — SSR for marketing, SPA for app — gives us the best of both worlds: crawlable marketing pages for SEO and a fast, interactive application experience for logged-in users. ## Nullable Types and the `nl` Package One underappreciated CRM problem is null handling. Contact records are inherently sparse — a candidate might have an email but no phone, a company but no LinkedIn, a salary expectation but no equity preference. Standard Go types don't handle this well. We built a custom `nl.Null*` package (nullable string, nullable int, nullable time, etc.) that wraps database nulls into a consistent API across the entire codebase. Every attribute that can be absent uses `nl.NullString` instead of `*string` or `sql.NullString`, giving us type safety and JSON serialization that correctly outputs `null` instead of zero values. ## What We'd Do Differently If starting over, we'd invest in OpenAPI spec generation from day one. Our API grew organically and the documentation lagged behind. We're now retroactively building a Swagger spec, but it would have been cheaper to generate it from annotated handlers from the start. We'd also set up `robots.txt` and `sitemap.xml` as static files served outside the Echo router from day one. Routing these through Echo's catch-all handler caused a subtle bug where 404s returned JSON instead of HTML — something that went unnoticed for months.