Plenty of small businesses run a CRM that is really a WhatsApp group, a diary, and whoever happens to pick up the phone. Leads arrive from the website, from walk-ins, from a Sunday newspaper ad. By Tuesday half of them have evaporated, because nobody knew who was supposed to call whom.
Lead pipelines and CRM integrations for a multi-tenant platform are the shape of system I work on, and this is how I think about building one. Not because it is glamorous work. Because the decisions you make in the first two weeks decide whether you have built an asset or a maintenance burden, and almost all of them are hard to reverse.
Two companion posts go narrower: the features that earn a place in v1, and what a custom CRM costs to build.
Most teams should buy
Salesforce, HubSpot and Zoho exist because 80% of CRM needs are identical everywhere. Contacts, deals, a pipeline, reminders. If your sales process is ordinary, a custom build is an expensive way to rediscover that.
Build when the workflow is the product. On the kind of platform I work on, a "lead" is not a name and an email. It is a buyer tied to a specific product variant, a scheduled visit, a trade-in valuation and one branch's live inventory. No off-the-shelf CRM models that without enough workarounds that you end up fighting the tool daily.
So the question is whether your entities map onto somebody else's idea of a deal. If your deal involves inventory, appointments or domain objects a generic CRM cannot represent, you are in build territory. Same if your users work the pipeline from inside your app rather than from a separate sales tool, because then the CRM is a feature and not a department's software. Same again if per-seat pricing breaks your model, which it will the moment you have thousands of small-business users at enterprise rates. And same if real-time sync with your own inventory or telephony is the core value rather than an add-on.
It is worth saying that this is not all-or-nothing, which people miss. Plenty of good systems keep HubSpot for the marketing team and build a small custom pipeline only where the domain-specific work happens.
Four tables
Strip away the dashboards and a CRM is a small, disciplined data model. Get contacts, leads, pipeline stages and activities right before any UI exists. Everything else hangs off them.
-- The heart of a custom CRM: four tables.
CREATE TABLE contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
phone text, -- normalized E.164; the real identity key in many markets
email text,
full_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE leads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contact_id uuid NOT NULL REFERENCES contacts(id),
source text NOT NULL, -- 'web-form' | 'ads' | 'walk-in' | 'marketplace'
stage text NOT NULL DEFAULT 'new',
owner_id uuid, -- who must act next
payload jsonb, -- domain context: variant, budget, visit slot…
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE activities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id uuid NOT NULL REFERENCES leads(id),
type text NOT NULL, -- 'call' | 'note' | 'stage-change' | 'visit'
detail jsonb,
actor_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The query your sales team runs 500 times a day: "my open leads, newest first"
CREATE INDEX idx_leads_owner_stage ON leads (owner_id, stage, created_at DESC);Two decisions in there have saved us repeatedly. Activities are append-only, so you never update history, you add to it. That buys you an audit trail for free and turns "what happened to this lead?" from an argument into a query. And the payload column absorbs domain-specific fields, which keeps the core schema stable while the business experiments on top of it.
The common mistake is the opposite. Thirty nullable columns on the lead table, one per campaign idea, until nobody remembers which ones are still alive. Keep the core narrow and push volatility into jsonb.
A status column becomes fiction within a month
Anything that can write to stage eventually does. Leads jump from "new" to "won" with no call logged, and conversion reports quietly stop meaning anything. Allow only explicit transitions, and record every one as an activity.
// pipeline.ts — the only way a lead changes stage
const TRANSITIONS: Record<Stage, Stage[]> = {
new: ['contacted', 'lost'],
contacted: ['qualified', 'lost'],
qualified: ['visit-scheduled', 'lost'],
'visit-scheduled': ['won', 'lost'],
won: [],
lost: ['contacted'], // leads do come back — model it
};
export async function moveLead(leadId: string, to: Stage, actorId: string) {
const lead = await db.leads.findById(leadId);
if (!TRANSITIONS[lead.stage].includes(to)) {
throw new InvalidTransitionError(lead.stage, to);
}
await db.$transaction([
db.leads.update(leadId, { stage: to }),
db.activities.create({
leadId, type: 'stage-change', actorId,
detail: { from: lead.stage, to },
}),
]);
}That one function is the difference between a CRM your team trusts and one they route around. Funnel metrics, response-time SLAs, loss analysis: all of it falls out of transitions being explicit and logged, and none of it is recoverable afterwards if they were not.
The UI is about a third of the work
A CRM's real job is being the place where every channel converges. Web forms, ad platforms, marketplaces, missed-call systems. Every one of those arrives as a webhook or a batch that will be retried, duplicated and delivered out of order, and it will do all three in the first week.
Two rules kept us sane. Every inbound lead endpoint is idempotent, because the same ad-platform webhook will hit you twice, and without an idempotency key you create two leads, assign them to two salespeople, and the customer gets two calls. Nothing burns a sales team's trust faster than that.
The second is to dedup on capture rather than on cleanup. Normalize the phone number, check whether that contact already has an open lead, and merge the new inquiry into it as an activity instead of inserting a sibling. A weekly dedup job is just a way of telling your team to spend the week working duplicates.
Retry-safety has a post of its own, idempotent APIs and webhooks, but the short version is that every write path a third party can retry needs a natural key and an upsert rather than an insert.
Multi-tenancy is a day-one decision
If your CRM serves many businesses — dealerships, clinics, agencies — you make this choice once and live with it. Shared tables with a tenant_id on every row scale to thousands of tenants, at the cost that every query must be tenant-scoped, enforced in exactly one place. Query middleware, or Postgres row-level security. Never by convention, because convention holds until the afternoon someone writes a reporting query in a hurry.
Shared tables are cheaper to operate and harder to isolate. Schema-per-tenant is the reverse. Twenty minutes on that trade-off before the first migration is the best-value twenty minutes in the whole project, and I have written up the three models properly because this is the one decision here that is genuinely painful to undo.
Ship the boring parts first
The failure mode of custom CRM projects is not bad code. It is scope. Lead scoring, email sequences, a custom report builder, an AI assistant. None of it matters if a lead can sit untouched for three days and nobody notices.
Version one is capture, assignment, a pipeline view and an activity log. Every channel lands in one deduped queue with its source recorded. Every lead has exactly one owner within minutes, by round-robin if nothing smarter exists yet. One board where a salesperson sees their own leads and moves them through stages. Calls and notes against the lead, append-only.
That is the whole of v1. No reports, because with the state machine above your funnel report is a GROUP BY you can write in an afternoon whenever someone actually asks for it. Speed-to-lead is the metric that pays for the system: the team that calls back in five minutes beats the team with the prettier dashboard, reliably, and it is not close.
Cutting that ruthlessly is a product skill more than an engineering one. It is also the difference between a CRM that ships and one still being designed a year later.
Things that only break at scale
Phone numbers are identity. In most non-US markets email is optional and the phone number is the primary key of a human, so normalize to E.164 at capture or dedup will never work properly.
Ownership has to be unambiguous. The moment two people share a lead, both assume the other one called. One owner, always, visible on every row.
The audit trail turned out to be a feature rather than plumbing. Managers used "show me everything that happened to this lead" more than any dashboard we built, which was not what we expected when we built the dashboards.
What I would check before the first migration
Custom CRM development is justified when the workflow is your product's edge, and a distraction when it is not. That is the whole buy-or-build answer and most teams land on the wrong side of it.
If you are building: four tables modelled properly, the pipeline as a real state machine, idempotent capture treated as non-negotiable, tenancy settled on day one, and the boring core shipped before any of the clever parts. The business running on a diary does not need AI lead scoring. It needs to know that someone called the Tuesday lead.
If the vertical is property, the domain model shifts enough to be worth its own post: how to build a real estate CRM.