Every growing company reaches the moment where the CRM fights back. The sales pipeline does not match the stages the team actually uses, delivery wants fields the vendor does not offer, finance exports to a spreadsheet anyway, and the per-seat bill keeps climbing for features nobody opens. We hit that moment ourselves, built Steppe Dev Box to replace it, and now use the same foundation when clients ask for CRM, back-office or operations tooling. This article explains the architecture and the trade-offs honestly, including when you should not build your own.
When building a CRM is the wrong idea
Start with the uncomfortable part. A custom CRM is a bad investment when:
- Your process is standard (lead → qualified → proposal → won) and a SaaS fits with light configuration.
- You need a large ecosystem of integrations on day one — email sequencing, telephony, marketing automation.
- Nobody on the team will own the product after launch. Internal tools without an owner decay fast.
It is a good investment when the process is the competitive advantage, when data must stay in your infrastructure, when several teams need different views of the same entities, or when the SaaS bill already exceeds what a focused build would cost over two or three years. For us it was the second and third reasons: sales, delivery and finance all needed to see projects, people and money, each from a different angle, and we wanted one source of truth.
The core idea: everything is a module
The design decision that made the system maintainable is that there is no hard-coded notion of "lead" or "deal". There is a module system, and a module is a package that declares:
- an entity schema — fields with types, validation and display hints;
- views — a kanban board, a table, a detail page, each configured rather than coded;
- relations to other modules (a project belongs to a client; a task belongs to a project);
- permissions — who can read, create, edit and delete, down to the field level;
- optional hooks — server-side functions that run on create, update or stage change.
Sales pipeline, projects, clients, invoices, candidates and even internal assets are all modules. Adding a new one means writing a schema definition and, if needed, a hook. The core renders the views, enforces permissions, persists data and broadcasts changes. In practice, a new module takes one engineer about a day, which is the metric we care about most.
Under the hood each module maps to a Prisma model on PostgreSQL. We considered a fully dynamic, schema-less store and rejected it: typed tables, migrations and real foreign keys are worth the small cost of generating a migration when a module changes. Flexible JSON columns are used only for genuinely dynamic attributes.
Real-time by default
A CRM used by multiple people at once has a specific failure mode: two people drag the same card, one of them loses, and nobody notices until the client calls. We decided early that every mutation is broadcast and every open view reflects it immediately.
The mechanism is deliberately simple:
- All writes go through the server (Next.js route handlers and server actions), which validates against the module schema and permissions, writes to PostgreSQL in a transaction and returns the canonical record.
- After commit, the server publishes a change event —
{ module, id, type: "created" | "updated" | "deleted", version }— to a channel. - A WebSocket gateway fans events out to connected clients, filtered by what each client is permitted to see.
- The client holds a normalised cache per module. On an event it either patches the record in place or, if the version indicates it missed something, refetches that record.
Drag-and-drop uses optimistic updates with rollback: the card moves immediately, the server confirms or rejects, and a rejection snaps the card back with a message. Conflicts are resolved by version numbers — a stale write is rejected and the client refreshes — which is simple and has been enough for a team of our size. True collaborative editing of long text fields would need CRDTs; we have not needed it.
The interface: fast for daily use
Internal tools are used for hours a day, so speed of interaction matters more than visual flourish. The patterns that earned their place:
- Kanban pipelines with drag-and-drop as the primary view for anything with stages, backed by the same table view for bulk operations.
- Inline editing on tables and detail pages — click a cell, change it, tab to the next — with validation from the module schema.
- Keyboard navigation and a command palette for jumping between records and running actions.
- Saved filters and views per user, so each role lands on the slice of data they need.
- Server rendering for first load, then client-side navigation with cached data, which keeps the app usable even on a slow connection.
We use Next.js and TypeScript across the stack with Tailwind CSS for styling; the shared types between the Prisma schema, the server and the client are a large part of why the module system is safe to extend quickly.
Permissions down to the field
Roles in a CRM are rarely clean. A salesperson should see deal values but not salaries; a contractor should see their tasks but not the client's budget. We implemented permissions at three levels — module, record and field — evaluated on the server for every read and write. Views are generated from the permitted field set, so a user never sees a column they cannot read, and the WebSocket gateway applies the same rules before broadcasting, so a forbidden field never crosses the wire.
This is more work than a simple admin/user split. It is also the feature that let us open the system to partners and contractors without building a separate portal.
What it costs to run
Numbers people ask for:
- Infrastructure: a managed PostgreSQL instance, a small Node.js process for the WebSocket gateway and the Next.js app on Vercel. For a company of our size the monthly bill is comparable to a handful of SaaS seats.
- Maintenance: a few hours a month for dependency updates and small schema changes. New modules are the main ongoing work, and they are driven by business needs rather than upkeep.
- Initial build: a system with the module core, real-time sync, three or four modules and permissions is a 6–10 week project for a small team. Compare that with the three-year cost of a SaaS at your seat count before deciding.
Reusing the foundation for clients
Because the module system is generic, Steppe Dev Box became a starting point for client projects rather than a one-off: back offices for marketplaces, partner portals, operations dashboards. The core — modules, views, permissions, real-time sync — stays; the modules are tailored to the client's process. That is also our honest advice if you are evaluating a custom CRM: do not build a CRM, build a module system, then describe your CRM in it.