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

One config, nine views.
  • 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