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.

signature here. ...