codexmachina
App schema

CRM, built on every verified stack.

Sales CRM: companies, contacts, named pipelines, deal-stage tracking, and an append-only activity trail.

40 verified stacks40 greenLast updated 2026-08-23How we verify →

40 verified stacks

Showing 12 across 1 app types. Every stack page links its neighbours along each axis, so any combination is a click or two from here.

Next.js 16 (App Router) + MySQL 8Auth.js (NextAuth) · Resend
Next.js 16 (App Router) + MySQL 8Better Auth
Next.js 16 (App Router) + MySQL 8Better Auth · Resend
Next.js 16 (App Router) + MySQL 8Clerk · Resend
Next.js 16 (App Router) + MySQL 8Supabase Auth
Next.js 16 (App Router) + MySQL 8Supabase Auth · Resend
Next.js 16 (App Router) + Postgres (Neon)Auth.js (NextAuth)
Next.js 16 (App Router) + Postgres (Neon)Better Auth
Next.js 16 (App Router) + Postgres (Neon)Better Auth · Resend
Next.js 16 (App Router) + Postgres (Neon)Clerk
Next.js 16 (App Router) + Postgres (Neon)Supabase Auth
Nuxt 4 + MySQL 8Better Auth · Resend

What CRM gives you

Five tables carry the sales layer, and the spine runs companies → contacts → deals → activities. Each link is a foreign key with a deliberate delete rule: contacts.company_id is nullable and ON DELETE SET NULL, because a lead exists before you know their employer and losing an account record should not take the person with it; deals.contact_id cascades, so deleting a contact takes their opportunities and, through activities.deal_id, the interaction trail underneath them. deals.pipeline_id has no delete rule at all, which means the database refuses to drop a pipeline that still holds deals — a pipeline is configuration, and silently emptying a board is worse than an error. Identity is borrowed, never declared.

Better Auth owns user, and this schema references user.id three times: contacts.owner_id and deals.owner_id name the rep accountable for a record, activities.actor_id names whoever logged the call. Those columns are text rather than uuid because Better Auth's user.id is text, and recasting it would break the constraint. Two columns are constrained instead of typed. deals.stage and activities.type are text with CHECK constraints — deals_stage_check over lead/qualified/won/lost, activities_type_check over call/email/note — so shipping a 'negotiating' stage is a constraint swap rather than an ALTER TYPE mid-deploy. Money is amount_cents, a bigint of integer cents, so a forecast is exact addition with no float anywhere near it. companies.domain gets an index but not a unique: it is the key you de-duplicate accounts on, not a rule that prevents two Acme rows.

The consequence of this shape is that it is record-scoped, not tenant-scoped. There is no organizations table, no row-level security, no billing and no metering; 'my deals' means owner_id = me, expressed in your query rather than enforced by the database, and owner_id is indexed on contacts but not on deals. What you get cheaply is the board and the timeline: idx_deal_stage buckets every deal by stage, and idx_activity_deal_time (deal_id, occurred_at) returns one deal's history already in order, out of a table nothing is designed to update.

Built for

The pipeline board: every deal bucketed by stage

A group-by over deals.stage, served by idx_deal_stage. Stage is text under deals_stage_check rather than an enum, so the board's columns move with a constraint swap, and amount_cents (bigint cents) sums exactly for the per-stage total.

One deal's full history, newest first

activities is read through idx_activity_deal_time on (deal_id, occurred_at): the leading column picks the deal, the trailing one supplies the ordering, so a timeline is an index range scan and not a sort.

Every contact a rep owns

contacts.owner_id is a text FK to Better Auth's user.id, indexed as idx_contact_owner — one scan returns a rep's whole book without touching companies or deals.

Who else works at this account

idx_contact_company on contacts.company_id lists a company's roster; idx_company_domain on companies.domain is how an inbound lead gets matched to an existing account before the contact row is written.

The opportunities sitting against a contact

idx_deal_contact on deals.contact_id joins the account view to the pipeline, and the FK cascades — a contact erased on request takes their deals, and the activities under those deals, in one statement.

Why CRM, specifically

note

Deal stage is text + CHECK ('lead','qualified','won','lost') on the deals table — new stages ship without an ALTER TYPE migration; the idx_deal_stage index drives the pipeline board's 'deals grouped by stage' query.

note

activities is append-only (no updates, no deletes cascaded from deal): the per-deal timeline is always a raw log, never a mutated summary, queried via idx_activity_deal_time (deal_id, occurred_at).