Build a mini-CRM by cloning the records pattern
Start state: nothing. End state: a Contacts domain with pipeline board, deal totals, and 9 views — without writing a single line of view code. The records feature is a generic multi-view data point; cloning it into a real domain costs exactly 3 edits: schema block, backend module, frontend config.
Shortcut: pnpm new my-crm --demo crm generates all of this — this tutorial shows the pattern underneath.
1. Scaffold with records
pnpm new crm --title "Mini CRM" --type webapp --features records
pnpm install
cd apps/crm && npx convex dev --once && pnpm exec tsc --noEmit
Expected: apps/crm scaffolded with features dashboard, settings, records; tsc exits clean. Everything below happens inside apps/crm.
2. Edit 1 of 3 — the schema block
In convex/schema.ts, copy the records table block and rename it contacts, swapping the fields for CRM ones (stage replaces status, company + amount carry the deal):
// convex/schema.ts — beside the existing records table
contacts: defineTable({
userId: v.string(),
name: v.string(),
stage: v.string(),
company: v.optional(v.string()),
amount: v.optional(v.number()), // deal size
nextTouch: v.optional(v.string()), // ISO yyyy-mm-dd
tags: v.array(v.string()),
notes: v.optional(v.string()),
imageUrl: v.optional(v.string()),
position: v.optional(v.object({ x: v.number(), y: v.number() })),
}).index("by_user", ["userId"]),
Always index("by_user", ["userId"]) for per-user data, and never name an index by_id — that name is reserved by Convex.
3. Edit 2 of 3 — the backend module
Copy convex/recordsShared.ts to convex/contactsShared.ts and replace the status constants with pipeline stages (one source of truth for backend validators AND the frontend select options):
// convex/contactsShared.ts
export const STAGE_VALUES = ["lead", "qualified", "proposal", "won", "lost"] as const;
export type ContactStage = (typeof STAGE_VALUES)[number];
export const STAGE_META: { value: ContactStage; label: string; tone?: "ok" | "warn" | "danger" | "accent" }[] = [
{ value: "lead", label: "Lead" },
{ value: "qualified", label: "Qualified", tone: "accent" },
{ value: "proposal", label: "Proposal", tone: "warn" },
{ value: "won", label: "Won", tone: "ok" },
{ value: "lost", label: "Lost", tone: "danger" },
];
Then copy convex/records.ts to convex/contacts.ts and do a mechanical rename: records → contacts in the table name and the four endpoints (listContacts, createContact, updateContact, deleteContact), title → name as the required field, status/STATUS_VALUES → stage/STAGE_VALUES, drop priority, add company: v.optional(v.string()) to patchFields. The shape stays identical: requireUserId(ctx) first in every handler, a shared patchFields object of all-optional validators, ownership checked on update and delete.
4. Edit 3 of 3 — the frontend config
Copy the folder src/features/records to src/features/contacts and rewrite the one CONFIG in pages (rename RecordsPage → ContactsPage). This config is the entire frontend of the domain:
import { STAGE_META } from "@convex/contactsShared";
const CONFIG: EntityConfig = {
labels: ["Contact", "Contacts"],
titleKey: "name",
statusKey: "stage", // board groups by pipeline stage
dateKey: "nextTouch", // timeline + calendar
imageKey: "imageUrl", // gallery
positionKey: "position", // canvas
amountKey: "amount", // stats totals = deal value
fields: [
{ key: "name", label: "Name", type: "text", required: true },
{ key: "company", label: "Company", type: "text" },
{ key: "stage", label: "Stage", type: "select", options: STAGE_META },
{ key: "amount", label: "Deal amount", type: "number" },
{ key: "nextTouch", label: "Next touch", type: "date" },
{ key: "tags", label: "Tags", type: "tags" },
{ key: "notes", label: "Notes", type: "longtext" },
{ key: "imageUrl", label: "Photo", type: "image" },
{ key: "position", label: "Position", type: "position" },
],
};
In the same page, point the four hooks at your new endpoints (api.contacts.listContacts and friends) and the Id casts at Id<"contacts">. In src/features/contacts/index.tsx export a contactsFeature with id "contacts", path "/contacts", and its own nav label.
5. Register the feature
In src/features/index.tsx: one import (import { contactsFeature } from "./contacts") + one line in the features array. Nav and route wire themselves.
6. Codegen + typecheck
npx convex codegen && pnpm exec tsc --noEmit
Expected: zero errors. If tsc complains about an optional field being possibly undefined, remember Convex v.optional(...) fields must be omitted, not set to undefined — build objects with conditional spreads.
7. What the 9 views give a CRM
- board — your sales pipeline: kanban columns per stage, drag a card to move a deal (statusKey: stage)
- stats — deal totals and per-stage aggregates, powered by amountKey
- table — the classic contact list, sortable, sticky first column
- calendar + timeline — follow-ups by nextTouch (dateKey)
- gallery — headshot cards via imageKey; canvas — a free-form account map via positionKey
- list + grid — compact and card renderings of the same rows
Omit a role key and its views hide automatically — a config with no imageKey simply has no gallery.
What you built
- A full CRM domain (pipeline board, deal stats, 9 views, create/edit form) from 3 edits and zero view code
- The clone recipe you will reuse for every future domain: schema block → <domain>.ts + <domain>Shared.ts → feature folder with one CONFIG → one registry line