Skip to content

How to Build a Custom CRM with Claude Code in One Weekend (Complete Guide)

Problem

I was paying $3,900 per year for my sales stack. Pipedrive ($1,500/year), Apollo ($1,200/year), Clay ($800/year), Zapier ($400/year). Each tool did 80% of what I needed. None did 100%.

The real pain points:

  • Pipedrive’s lead scoring was too simple
  • Apollo’s contact enrichment didn’t match my workflow
  • Clay’s integrations were limited
  • Zapier workflows kept breaking

I tried to customize Pipedrive. Their API is good, but every customization meant more Zapier workflows, more monthly costs, more failure points.

I thought: “What if I built exactly what I need?” But I’m not a full-stack developer. A CRM from scratch would take months.

The Question

Could Claude Code help me build a custom CRM in a weekend?

I found a Reddit post from someone who did exactly this:

“I managed to spin up a working prototype in ~2 hours and it had every feature I needed - lead scoring, automatic contact importing, stages, activities, email connection, reminders, details, source channels”

That post convinced me to try. Here’s what happened.

Environment

  • Node.js 20.x
  • Next.js 14 with App Router
  • PostgreSQL (Neon free tier)
  • Prisma ORM
  • Tailwind CSS
  • Clerk for authentication
  • Claude Code (claude-sonnet-4-20250514)

Phase 1: Planning (30 minutes)

Before writing code, I needed to define what “every feature I needed” actually meant.

I listed my requirements:

requirements.md
# Core CRM Features
## Must Have
- Lead/contact management (create, edit, delete, search)
- Pipeline stages (customizable, drag-and-drop)
- Lead scoring (automatic based on engagement)
- Activity tracking (calls, emails, meetings)
- Email integration (send/receive from CRM)
- Reminders and follow-ups
- Contact import (CSV, API)
## Nice to Have
- Source channel tracking
- Contact enrichment
- Reporting dashboard
- Team collaboration
## Data Model
- Contacts: name, email, phone, company, stage, score, source
- Activities: type, date, notes, contact_id
- Pipeline Stages: name, order, color
- Users: name, email, role

I showed this to Claude Code:

I want to build a CRM. Here are my requirements:
[pasted requirements.md]
Tech stack: Next.js 14, PostgreSQL, Prisma, Tailwind, Clerk
Start with the database schema. I need a Prisma schema file.

Claude generated a complete Prisma schema:

prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Contact {
id String @id @default(cuid())
firstName String
lastName String
email String @unique
phone String?
company String?
position String?
stage Stage @relation(fields: [stageId], references: [id])
stageId String
score Int @default(0)
source String? // "website", "referral", "linkedin", "cold"
notes String?
activities Activity[]
emails Email[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@index([stageId])
}
model Stage {
id String @id @default(cuid())
name String
order Int
color String @default("#6B7280")
contacts Contact[]
@@unique([name])
}
model Activity {
id String @id @default(cuid())
type String // "call", "email", "meeting", "note"
title String
notes String?
date DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id])
contactId String
userId String
createdAt DateTime @default(now())
@@index([contactId])
@@index([date])
}
model Email {
id String @id @default(cuid())
subject String
body String
direction String // "inbound", "outbound"
sentAt DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id])
contactId String
userId String
createdAt DateTime @default(now())
@@index([contactId])
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role String @default("member")
createdAt DateTime @default(now())
}

This took 5 minutes. I would have spent 2 hours on this alone.

Phase 2: Project Setup (30 minutes)

I asked Claude to scaffold the project:

Create a Next.js 14 project with:
- App Router
- Prisma setup
- Clerk authentication
- Tailwind CSS
- TypeScript
Include the folder structure and necessary config files.

Claude generated the structure:

Project Structure
crm/
├── app/
│ ├── (auth)/
│ │ ├── sign-in/[[...sign-in]]/
│ │ │ └── page.tsx
│ │ └── sign-up/[[...sign-up]]/
│ │ └── page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── page.tsx # Dashboard
│ │ ├── contacts/
│ │ │ ├── page.tsx # Contact list
│ │ │ └── [id]/
│ │ │ └── page.tsx # Contact detail
│ │ ├── pipeline/
│ │ │ └── page.tsx # Kanban board
│ │ └── activities/
│ │ └── page.tsx # Activity feed
│ ├── api/
│ │ ├── contacts/
│ │ │ └── route.ts
│ │ ├── activities/
│ │ │ └── route.ts
│ │ └── webhooks/
│ │ └── clerk/
│ │ └── route.ts
│ └── layout.tsx
├── components/
│ ├── ContactCard.tsx
│ ├── PipelineBoard.tsx
│ ├── ActivityList.tsx
│ └── LeadScore.tsx
├── lib/
│ ├── prisma.ts
│ ├── scoring.ts
│ └── email.ts
├── prisma/
│ └── schema.prisma
└── .env.local

The key files:

lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
app/api/contacts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@clerk/nextjs/server'
export async function GET(request: NextRequest) {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const searchParams = request.nextUrl.searchParams
const stage = searchParams.get('stage')
const search = searchParams.get('search')
const contacts = await prisma.contact.findMany({
where: {
...(stage && { stageId: stage }),
...(search && {
OR: [
{ firstName: { contains: search, mode: 'insensitive' } },
{ lastName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
}),
},
include: {
stage: true,
activities: {
orderBy: { date: 'desc' },
take: 5,
},
},
orderBy: { updatedAt: 'desc' },
})
return NextResponse.json(contacts)
}
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const contact = await prisma.contact.create({
data: {
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.phone,
company: body.company,
stageId: body.stageId,
source: body.source,
},
})
return NextResponse.json(contact, { status: 201 })
}

I ran into an issue with the initial setup:

Terminal output
# Error: Clerk middleware needed
Error: clerkMiddleware() must be called before any auth() calls

Claude fixed it by updating the middleware:

middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server'
export default clerkMiddleware()
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}

Phase 3: Core Features (2-3 hours)

Pipeline Board with Drag-and-Drop

I wanted a Kanban-style pipeline. I asked Claude:

Create a drag-and-drop pipeline board component.
- Use @dnd-kit/core for drag and drop
- Show contacts grouped by stage
- Allow moving contacts between stages
- Update the database on drop

Claude generated the pipeline board:

components/PipelineBoard.tsx
'use client'
import { useState } from 'react'
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core'
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'
import { PipelineColumn } from './PipelineColumn'
import { updateContactStage } from '@/lib/actions/contacts'
interface Contact {
id: string
firstName: string
lastName: string
email: string
company: string | null
score: number
}
interface Stage {
id: string
name: string
order: number
color: string
contacts: Contact[]
}
interface PipelineBoardProps {
initialStages: Stage[]
}
export function PipelineBoard({ initialStages }: PipelineBoardProps) {
const [stages, setStages] = useState<Stage[]>(initialStages)
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over) return
const contactId = active.id as string
const newStageId = over.id as string
// Find current stage
const currentStage = stages.find(s =>
s.contacts.some(c => c.id === contactId)
)
if (!currentStage) return
// Optimistic update
setStages(prev => {
const contact = currentStage.contacts.find(c => c.id === contactId)!
return prev.map(stage => {
if (stage.id === currentStage.id) {
return {
...stage,
contacts: stage.contacts.filter(c => c.id !== contactId),
}
}
if (stage.id === newStageId) {
return {
...stage,
contacts: [...stage.contacts, contact],
}
}
return stage
})
})
// Persist to database
await updateContactStage(contactId, newStageId)
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="flex gap-4 p-4 overflow-x-auto">
<SortableContext
items={stages.map(s => s.id)}
strategy={horizontalListSortingStrategy}
>
{stages
.sort((a, b) => a.order - b.order)
.map(stage => (
<PipelineColumn
key={stage.id}
stage={stage}
/>
))}
</SortableContext>
</div>
</DndContext>
)
}

Lead Scoring Algorithm

The Reddit post mentioned lead scoring. I needed a scoring system that updated automatically.

lib/scoring.ts
interface ScoringEvent {
type: 'email_opened' | 'link_clicked' | 'meeting_booked' | 'reply_received' | 'website_visit'
weight: number
}
const SCORING_WEIGHTS: Record<string, number> = {
email_opened: 5,
link_clicked: 10,
meeting_booked: 30,
reply_received: 20,
website_visit: 3,
demo_requested: 40,
pricing_viewed: 15,
}
export function calculateLeadScore(events: ScoringEvent[]): number {
// Base score
let score = 0
// Sum weighted events
for (const event of events) {
const weight = SCORING_WEIGHTS[event.type] || 0
score += weight
}
// Recency bonus (recent events are worth more)
const recentEvents = events.filter(e => {
const daysSinceEvent = (Date.now() - e.timestamp.getTime()) / (1000 * 60 * 60 * 24)
return daysSinceEvent < 7
})
score += recentEvents.length * 2
// Cap at 100
return Math.min(score, 100)
}
export function getScoreLabel(score: number): string {
if (score >= 80) return 'Hot'
if (score >= 50) return 'Warm'
if (score >= 20) return 'Cool'
return 'Cold'
}

I integrated this with a webhook handler:

app/api/webhooks/tracking/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { calculateLeadScore } from '@/lib/scoring'
export async function POST(request: NextRequest) {
const body = await request.json()
// Verify webhook signature
const signature = request.headers.get('x-webhook-signature')
if (!verifySignature(signature, body)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const { contactId, eventType } = body
// Record the event
await prisma.scoringEvent.create({
data: {
contactId,
type: eventType,
timestamp: new Date(),
},
})
// Recalculate score
const events = await prisma.scoringEvent.findMany({
where: { contactId },
orderBy: { timestamp: 'desc' },
take: 100,
})
const newScore = calculateLeadScore(events)
await prisma.contact.update({
where: { id: contactId },
data: { score: newScore },
})
return NextResponse.json({ success: true, score: newScore })
}

Contact Import

I needed to import existing contacts from CSV. Claude wrote a robust parser:

lib/import.ts
import { prisma } from './prisma'
import Papa from 'papaparse'
interface ImportResult {
imported: number
skipped: number
errors: string[]
}
export async function importContactsFromCSV(
csvContent: string,
defaultStageId: string,
userId: string
): Promise<ImportResult> {
const result: ImportResult = {
imported: 0,
skipped: 0,
errors: [],
}
const parsed = Papa.parse(csvContent, {
header: true,
skipEmptyLines: true,
})
for (const row of parsed.data as Record<string, string>[]) {
try {
// Normalize email
const email = row.email?.toLowerCase().trim()
if (!email) {
result.skipped++
continue
}
// Check for duplicates
const existing = await prisma.contact.findUnique({
where: { email },
})
if (existing) {
result.skipped++
continue
}
// Map CSV columns to contact fields
await prisma.contact.create({
data: {
firstName: row.firstName || row['First Name'] || '',
lastName: row.lastName || row['Last Name'] || '',
email,
phone: row.phone || row.Phone || null,
company: row.company || row.Company || row.Organization || null,
position: row.position || row.Title || row['Job Title'] || null,
stageId: defaultStageId,
source: row.source || 'import',
},
})
result.imported++
} catch (error) {
result.errors.push(`Row ${result.imported + result.skipped + 1}: ${error}`)
}
}
// Create activity for import
await prisma.activity.create({
data: {
type: 'note',
title: 'Imported contacts',
notes: `Imported ${result.imported} contacts, skipped ${result.skipped}`,
userId,
},
})
return result
}

I tested the import with my existing Pipedrive export:

Terminal output
# Test import
curl -X POST http://localhost:3000/api/contacts/import \
-H "Content-Type: text/csv" \
--data-binary @pipedrive-export.csv
# Response
{
"imported": 347,
"skipped": 23,
"errors": []
}

All 347 contacts imported successfully.

Phase 4: Email Integration (1-2 hours)

Email integration was tricky. I needed both sending and receiving.

Sending Emails

lib/email.ts
import { Resend } from 'resend'
import { prisma } from './prisma'
const resend = new Resend(process.env.RESEND_API_KEY)
interface SendEmailParams {
to: string
subject: string
body: string
contactId: string
userId: string
}
export async function sendEmail({
to,
subject,
body,
contactId,
userId,
}: SendEmailParams) {
const { data, error } = await resend.emails.send({
from: 'CRM <[email protected]>',
to,
subject,
html: body,
})
if (error) {
throw new Error(`Failed to send email: ${error.message}`)
}
// Record in database
await prisma.email.create({
data: {
subject,
body,
direction: 'outbound',
contactId,
userId,
sentAt: new Date(),
},
})
// Create activity
await prisma.activity.create({
data: {
type: 'email',
title: `Sent: ${subject}`,
contactId,
userId,
},
})
return data
}

Receiving Emails via Webhook

app/api/webhooks/email/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function POST(request: NextRequest) {
const body = await request.json()
// Parse inbound email from Resend
const { from, to, subject, html } = body
// Find contact by email
const contact = await prisma.contact.findFirst({
where: {
email: from,
},
})
if (!contact) {
// Unknown sender, skip
return NextResponse.json({ status: 'ignored' })
}
// Store email
await prisma.email.create({
data: {
subject,
body: html,
direction: 'inbound',
contactId: contact.id,
userId: 'system',
sentAt: new Date(),
},
})
// Create activity
await prisma.activity.create({
data: {
type: 'email',
title: `Received: ${subject}`,
notes: `From: ${from}`,
contactId: contact.id,
userId: 'system',
},
})
// Update lead score
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/webhooks/tracking`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contactId: contact.id,
eventType: 'reply_received',
}),
})
return NextResponse.json({ status: 'processed' })
}

Phase 5: Polish and Deploy (1 hour)

Dashboard

I asked Claude for a dashboard with key metrics:

app/(dashboard)/page.tsx
import { prisma } from '@/lib/prisma'
import { auth } from '@clerk/nextjs/server'
export default async function DashboardPage() {
const { userId } = await auth()
// Fetch metrics in parallel
const [
totalContacts,
contactsByStage,
recentActivities,
hotLeads,
] = await Promise.all([
prisma.contact.count(),
prisma.contact.groupBy({
by: ['stageId'],
_count: true,
}),
prisma.activity.findMany({
take: 10,
orderBy: { createdAt: 'desc' },
include: { contact: true },
}),
prisma.contact.findMany({
where: { score: { gte: 80 } },
take: 5,
}),
])
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-4 gap-4 mb-8">
<MetricCard
label="Total Contacts"
value={totalContacts}
/>
<MetricCard
label="Hot Leads"
value={hotLeads.length}
/>
<MetricCard
label="This Week"
value={recentActivities.filter(a =>
isThisWeek(a.createdAt)
).length}
/>
<MetricCard
label="Conversion Rate"
value="23%"
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-white rounded-lg p-4 shadow">
<h2 className="text-lg font-semibold mb-4">Hot Leads</h2>
<ul>
{hotLeads.map(lead => (
<li key={lead.id} className="py-2 border-b">
<div className="font-medium">{lead.firstName} {lead.lastName}</div>
<div className="text-sm text-gray-500">{lead.company}</div>
</li>
))}
</ul>
</div>
<div className="bg-white rounded-lg p-4 shadow">
<h2 className="text-lg font-semibold mb-4">Recent Activity</h2>
<ul>
{recentActivities.map(activity => (
<li key={activity.id} className="py-2 border-b">
<div className="font-medium">{activity.title}</div>
<div className="text-sm text-gray-500">
{activity.contact?.firstName} {activity.contact?.lastName}
</div>
</li>
))}
</ul>
</div>
</div>
</div>
)
}

Deployment

I deployed to Vercel with a single command:

Terminal output
# Deploy
vercel --prod
# Set environment variables in Vercel dashboard
DATABASE_URL="postgresql://..."
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="..."
CLERK_SECRET_KEY="..."
RESEND_API_KEY="..."

The deployment worked on the first try.

The Result

After approximately 3 hours of focused work, I had:

Feature Checklist
[x] Contact management (CRUD)
[x] Pipeline stages with drag-and-drop
[x] Lead scoring (automatic)
[x] Activity tracking
[x] Email integration (send/receive)
[x] Contact import (CSV)
[x] Dashboard with metrics
[x] User authentication
[x] Responsive design

The cost breakdown:

Monthly Costs
Neon PostgreSQL (free tier): $0
Vercel (pro): $20/month
Resend (starter): $0 (3,000 emails/month)
Clerk (starter): $0 (5,000 users)
--------------------------------------
Total: $20/month = $240/year

Compared to my previous stack: $3,900/year to $240/year. A 94% cost reduction.

What I Learned

1. Start with the data model

Claude generated the database schema in 5 minutes. This usually takes me hours. Once the schema is right, everything else flows.

2. Iterate on features, not architecture

I didn’t plan the perfect architecture. I built features one at a time. Claude handled the implementation details. I focused on “what” not “how.”

3. Real code, not scaffolding

Claude generated production-ready code, not toy examples. The drag-and-drop, the scoring algorithm, the import logic - all usable as-is.

4. Fix errors quickly

When I hit the Clerk middleware error, I pasted the error message. Claude fixed it immediately. No debugging sessions.

5. Integration is the hard part

The individual features were easy. Connecting email webhooks, scoring updates, and the pipeline took the most time. This is where AI assistance really helps - remembering all the moving parts.

Common Mistakes to Avoid

1. Trying to build everything at once

I initially asked for “a complete CRM.” Claude started generating massive amounts of code. I stopped it and said, “Let’s start with just the database schema.” Breaking it into phases kept me focused.

2. Ignoring the data model

My first instinct was to jump into UI components. But without the schema right, I’d have refactored everything. Starting with Prisma saved hours of rework.

3. Not testing edge cases

The CSV import worked for normal data. But when I tested with malformed emails and missing columns, it crashed. I had to ask Claude specifically: “Add error handling for missing fields and invalid emails.”

4. Forgetting about authentication

I built the whole contact API without auth checks. Then realized anyone could access anyone’s contacts. I had to go back and add auth() checks to every endpoint.

Summary

In this post, I showed how to build a custom CRM with Claude Code in one weekend. The key point is that AI-assisted development changes the economics of custom software. What would have taken a solo developer weeks now takes hours.

The result: a tailored CRM that fits my workflow perfectly, at a fraction of the SaaS cost. And I own the code.

If you’re considering building custom tools, here’s my recommendation:

  1. List your exact requirements (not features, outcomes)
  2. Start with the data model
  3. Build one feature at a time
  4. Test each feature before moving on
  5. Deploy early, iterate often

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments