Live: SoberMark
Case study: SoberMark
Built and run solo under my LLC, Culmination Digital. Source is private.
SoberMark is a public directory of addiction-treatment providers. You search by location, level of care, insurance, language, telehealth, and more, and you get providers near you with the facts that matter. Today it covers 10,900+ providers across all 50 states, in English and Spanish.
What I wanted to learn
I wanted to go deep on the data layer, and I wanted to do it in production rather than in a tutorial:
- Database depth. Schema design, migrations, row-level security, materialized views, and Postgres extensions doing real work.
- Search. Geospatial radius search, full-text ranking, and what to do when someone misspells a city.
- Data cleaning. Public data is messy, duplicated, and stale in ways a hand-curated list never is.
- Combining data. The same facility shows up in several datasets with a different name, phone number, and address format in each.
- Data management. Keeping all of it correct over time, on a schedule, with one engineer.
- AI-assisted, spec-driven development in production. Writing the spec and the conventions first, then building with a coding agent inside them.
How it's built
Next.js 16 on Vercel, TypeScript strict, in a pnpm monorepo with a Python ingest app beside the web app. Postgres with PostGIS is the core. GitHub Actions runs CI, the scheduled ingest, and secret scanning.
Twenty sources in. The Python pipeline has connectors for SAMHSA, NPPES, federal exclusion and debarment lists, CMS, and several state licensing boards. Addresses are geocoded in batches through the Census geocoder. A monthly job refreshes the core federal sources against a throwaway database first and only then against production.
One record out. Matching runs in a fixed order: exact NPI first, then phone and state, then a normalized name, city, and state. The phone rule only counts when the city and name, or the street and ZIP, also agree. I added that guard after an early version merged unrelated providers in different cities because they shared a phone number. When sources disagree, the higher-confidence source wins each field, and every record keeps the list of sources it came from, so any value can be traced back.
Validation before anyone sees it. After each run the pipeline checks provider counts, state coverage, geocoding, and how recently each source was ingested. If the count or the coverage falls below a safe floor, the run fails instead of publishing a broken directory.
Search. Radius search runs on PostGIS geography. Text search uses Postgres full-text ranking and falls back to trigram similarity, so a misspelled city or provider name still finds a match. Results come from a materialized view, which keeps the request path fast no matter how many joins sit behind it.
Managing it over time. 239 forward-only migrations so far, row-level security on the tables, and scripts that validate pending migrations and check that environments match before anything merges.
Spec-driven, with an AI pair
Every rule a coding agent needs lives in the repo before the code does: agent guides for branches and migrations, conventions, runbooks, a database reference, and a decision log with dozens of recorded trade-offs. I write the spec, the agent builds inside it, and the tests and the migration checks decide whether the work is done. The same discipline shows up in the product itself. Claude helps with a few back-office tasks, and the ownership-claim assistant is built so it can never approve or reject a claim on its own.
What I took away
The search box is the visible part. The real product is the set of decisions about which source to believe, which match to trust, and when to refuse to publish. Several of the rules above exist because something went wrong in production first, and the fix got written down as a rule.