CRM
Companies, contacts, and deals on a pipeline, with notes, tasks, files, and a timeline on every record. This is the
pack-crm pack. It is a starting point you extend in code, not a hosted CRM with a settings screen for everything.
It installs into the admin app only. The CRM is your own back office: the people who run the product use it, and the users of your product never see it. There is one CRM for the whole product, shared by every admin.
What you need
- Postgres and Valkey, both already in the base template. Valkey counts API requests per key; when it is down, the API keeps answering and the limit is off.
- Object storage (RustFS locally) for file attachments. The pack creates its own bucket,
crm-files, on first upload. - No third-party accounts and no environment variables.
What it depends on
- A SaaS starter (Better Auth or WorkOS). The pack needs
authandadmin: it uses the starter's admin app, its admin roles, and its admin list. - Any layout that shells the admin app. Records open in a docked side panel, which every layout provides as
PanelShell.
What you get
Lists (all in the admin app, under CRM in the sidebar)
/companies,/contacts,/deals, and/tasks: tables where you click a cell to change it. An "Add new" row at the bottom creates a record from its name.- Filter, sort, pick which fields are columns, drag a header to reorder them, drag a column edge to resize it, and group by owner, stage, or company. All of it lives in the URL, so a link shows exactly what you see.
- Hover a cell for a copy button and an edit button, so a domain or a phone number can be grabbed without opening an editor.
- Saved views, private or shared with every admin.
- A bulk bar for selected rows: set the owner, add a tag, merge two duplicates, delete, restore.
- Trash with restore. Deleted records are removed for good after 30 days.
- CSV import for companies and contacts with column mapping, and CSV export of any list as filtered.
Deals
- Several pipelines, each with stages you rename, reorder, colour, and mark as open, won, or lost.
- A board per pipeline with totals per stage. Dragging a card onto a lost stage asks for the reason.
- A calendar by expected close date. Tasks have one by due date. Drag to reschedule.
/sales: open and weighted pipeline value, this month's wins and losses, deals closing next, your tasks.
Records
- A record opens beside its list in a panel with no backdrop. Resize it, keep editing the table behind it, click another
row to swap the record. The same content is also a full page at
/companies/$companyIdand so on. - Fields grouped into sections and edited in place, linked records, and tabs for the timeline, tasks, notes, and files.
- The timeline is written in the same database transaction as each change.
- Cmd+K searches companies, contacts, and deals from anywhere in the app. Star a record or a view to keep it one click away.
On a phone
- There are no mobile app screens. The admin screens are responsive: tables scroll sideways, the board scrolls by stage, and a record opens full width with a back button where there is no room to dock a panel.
For integrations
/crm-api/v1: the same companies, contacts, deals, tasks, and notes behind an API key (Authorization: Bearer crm_live_...), limited to 120 requests a minute per key.- Webhooks for any timeline event, signed with HMAC SHA-256 in the
x-crm-signatureheader (t=<unix seconds>,v1=<hex of "<t>.<body>">), retried with backoff, with a delivery log and a resend button.
Both are managed under CRM > Settings.
Who can get in
Only admins. Every route under /admin/crm runs behind the starter's admin guard: no session is a 401, and a
signed-in user who is not an admin is a 403. The admin app itself sits behind the same check.
| Who | Can do |
|---|---|
| Any admin | Everything on records: companies, contacts, deals, tasks, notes, files, their own views, trash |
| Super admin | Also pipelines and stages, renaming and deleting tags, shared views, API keys, webhooks, and deleting for good |
| Signed-in user | Nothing |
| API key | /crm-api/v1 only. A key is created by a super admin, shown once, and stored as a hash |
/crm-api/v1 is the one way in without an admin session, and it exists only if a super admin creates a key. If you do
not want it, delete routes/crm-api and its line in routes/index.ts.
routes/crm/crm.access.integration.test.ts reads the router and fails when someone adds a route outside these guards.
Add a field
Every field is described once, in the field registry. Say you want a vatNumber on companies.
-
Column. In
apps/backend/prisma/schema/crm.prisma, add it toCrmCompany, then runpnpm prisma migrate dev:vatNumber String @default("") @map("vat_number") @db.VarChar(40) -
Schema. In
packages/enums/src/crm/schemas.ts, add it tocompanyFieldsand to theCrmCompanyRecordtype:vatNumber: text(40), -
Registry. In
packages/enums/src/crm/fields.ts, add one entry toCOMPANY_FIELDS:{ key: "vatNumber", type: "text", icon: "Building2", group: "business", editable: true, sortable: true, filterable: true, defaultVisible: false, width: 160, csv: ["vat", "vat number"] }, -
Label. TypeScript now points at
fieldLabelsinfeatures/crm/utils/fields.ts, in the admin app. Add the label and its message key toapps/admin/messages/en.json. Return the new column fromtoCompanyRecordin the backend'smappers/records.ts.
The table column, its inline editor, the filter and sort menus, saved views, CSV import and export, the record panel, and the API's sort and filter whitelist all pick it up from the registry entry.
What is not in it
- A CRM per organization, or anything your users can see. It is admin only.
- Mobile app screens.
- A link between CRM companies and the organizations that sign up to your product.
- Workflows and automations.
- Custom objects or fields added at runtime. Fields are added in code, as above.
- Email and calendar sync.
- Currency conversion. Each deal has a currency, and totals add amounts as stored.
After installing
cd apps/backend
pnpm prisma generate
pnpm prisma migrate devThen sign in and open /companies. The first visit creates a default "Sales" pipeline.