Skip to content
8 min read

Custom CRM development: when to build instead of buy

When custom CRM development beats Salesforce, and how to build one that lasts: the data model, pipeline state machine, idempotent lead capture and scope.

On this page
  1. Most teams should buy
  2. Four tables
  3. A status column becomes fiction within a month
  4. The UI is about a third of the work
  5. Multi-tenancy is a day-one decision
  6. Ship the boring parts first
  7. Things that only break at scale
  8. What I would check before the first migration

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.

crm-core.sqlsql
-- 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.tsts
// 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.

Frequently asked

What is custom CRM development?

Custom CRM development means building your own customer relationship management system — lead capture, pipeline, contacts, and activity tracking — tailored to your business workflow instead of adopting an off-the-shelf product like Salesforce, HubSpot, or Zoho. It makes sense when your sales process involves domain-specific entities (inventory, appointments, valuations) that generic CRMs cannot model cleanly.

When should you build a custom CRM instead of using Salesforce or HubSpot?

Build custom when the CRM workflow is part of your product itself — for example, a marketplace or SaaS whose users work leads inside your app — when your deal entities do not map to a generic CRM, or when per-seat pricing breaks your business model. If your sales process is ordinary and standalone, buying an established CRM is almost always cheaper and faster.

What are the core features a custom CRM needs in version one?

Four things: lead capture from every channel into one deduplicated queue, automatic assignment so each lead has exactly one owner, a pipeline view where salespeople move leads through explicit stages, and an append-only activity log of calls and notes. Reporting, scoring, and automation can come later — they all depend on this core being trustworthy.

What is a good data model for a CRM?

A solid CRM core is four tables: contacts (people, keyed by normalized phone or email), leads or deals (one row per sales opportunity with a stage and an owner), pipeline stage transitions enforced as a state machine, and an append-only activities table recording every call, note, and stage change. Domain-specific fields belong in a flexible JSON column rather than dozens of nullable columns.

How do CRMs handle duplicate leads?

The reliable approach is deduplication at capture time: normalize the identity key (usually the phone number, to E.164 format), check whether the contact already has an open lead, and merge the new inquiry into it as an activity instead of creating a second lead. Inbound webhook endpoints should also be idempotent, because ad platforms and forms routinely deliver the same lead twice.

Why should a CRM pipeline be a state machine?

Because a free-for-all status column becomes unreliable — leads jump stages without required steps, and funnel metrics stop meaning anything. A state machine allows only explicit transitions (new → contacted → qualified → won/lost) and records each one as an audit event, which makes conversion reporting, response-time tracking, and loss analysis trustworthy by construction.

How long does it take to build a custom CRM?

A focused v1 — capture, assignment, pipeline board, and activity log for a single team — is typically a few weeks to a couple of months of engineering work. Timelines balloon when scope creeps into reporting builders, email automation, and scoring before the core is adopted, or when multi-tenancy is retrofitted late instead of decided on day one.

Is a custom CRM worth it for a small team?

Usually not. A small team with a conventional sales process gets far more value from an off-the-shelf CRM than from months of custom development. The exception is when the team is small but the CRM serves many external users — like a platform serving thousands of small businesses — where per-seat pricing and workflow mismatch make building the rational choice.

Found this useful?Share on XShare on LinkedIn

Related reading