# How to Build a CRM System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a full CRM system with Lovable and Supabase in 2–3 hours. You'll get a Kanban pipeline with drag-and-drop deal cards, a searchable contacts DataTable, real-time deal movements across team members, activity logging, and role-based team isolation — all without writing backend code manually.

## Before you start

- Lovable Pro account (drag-and-drop Kanban needs enough credits for multi-step generation)
- Supabase project created at supabase.com (free tier works)
- Supabase URL and anon key ready to paste into Lovable Secrets
- Basic familiarity with Lovable's Cloud tab and Secrets panel
- Optional: a list of real pipeline stage names you want to use

## Step-by-step guide

### 1. Scaffold the Supabase schema and connect to Lovable

Start by asking Lovable to generate the full database schema. It will create the SQL migration and wire up the Supabase client. After generation, paste your Supabase URL and anon key into Cloud tab → Secrets as VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.

```
Create a CRM application with Supabase. Set up these tables:
- contacts: id, org_id, first_name, last_name, email, phone, company, owner_id, created_at
- pipelines: id, org_id, name, created_at
- deals: id, org_id, contact_id, pipeline_id, title, value, stage, owner_id, position, created_at, updated_at
- activities: id, org_id, contact_id, deal_id, type (call|email|note|meeting), body, created_by, created_at

Enable RLS on all tables. Add policies so users can only read/write rows where org_id matches their profile's org_id. Use Supabase Auth for authentication.
```

> Pro tip: Ask Lovable to generate a seed SQL snippet with 3 sample contacts and 5 sample deals so you can see the Kanban populated immediately without manual data entry.

**Expected result:** Lovable creates src/integrations/supabase/client.ts, generates TypeScript types for all four tables, and shows a basic scaffold in the preview.

### 2. Build the Kanban pipeline board with drag-and-drop

Prompt Lovable to render the deals table as a Kanban board. Each pipeline stage becomes a column. Deal cards show the contact name, deal value, and a Badge for the stage. Drag-and-drop uses optimistic UI updates before the Supabase upsert completes.

```
Build a Kanban pipeline board component at src/components/pipeline/KanbanBoard.tsx.

Requirements:
- Fetch deals from Supabase grouped by stage
- Render columns for stages: Lead, Qualified, Proposal, Negotiation, Won, Lost
- Each card shows: contact name, deal title, formatted value (e.g. $12,500), owner avatar
- Use shadcn/ui Card for each deal card
- Use shadcn/ui Badge with color coding: Lead=gray, Qualified=blue, Proposal=yellow, Negotiation=orange, Won=green, Lost=red
- Implement drag-and-drop: when a card is dropped into a new column, optimistically update the UI immediately, then call supabase.from('deals').update({ stage: newStage }).eq('id', dealId)
- Show a loading skeleton while deals are fetching
- Add a + button at the top of each column to create a new deal in that stage
```

> Pro tip: If Lovable struggles with the drag-and-drop in one prompt, break it into two: first build the static Kanban layout, then add drag-and-drop in a follow-up prompt.

**Expected result:** A fully interactive Kanban board appears in the preview. You can drag deal cards between columns and the stage updates persist in Supabase.

### 3. Add Supabase Realtime so deal movements sync live

Enable Realtime on the deals table in Supabase, then ask Lovable to subscribe to changes. When any team member moves a deal, every other open browser session updates automatically without a page refresh.

```
Add Supabase Realtime to the KanbanBoard component. Subscribe to postgres_changes on the deals table. When an UPDATE event fires, update the local deals state to reflect the new stage and position. Clean up the subscription on component unmount. Show a subtle toast notification (use Sonner) when a deal is moved by another user, e.g. 'John moved Acme Corp deal to Won'.
```

**Expected result:** Open the app in two browser tabs. Moving a deal in one tab causes the other tab to update within one second without refreshing.

### 4. Create the contacts DataTable with Sheet preview

Build the contacts view using shadcn/ui DataTable powered by TanStack Table v8. Clicking any row opens a Sheet panel on the right side showing full contact details, linked deals, and the activity timeline.

```
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
} from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { DataTable } from '@/components/ui/data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Contact = {
  id: string
  first_name: string
  last_name: string
  email: string
  company: string
  owner_id: string
}

const columns: ColumnDef<Contact>[] = [
  {
    accessorFn: (row) => `${row.first_name} ${row.last_name}`,
    header: 'Name',
    cell: ({ getValue, row }) => (
      <div className="flex items-center gap-2">
        <Avatar className="h-7 w-7">
          <AvatarFallback>{row.original.first_name[0]}</AvatarFallback>
        </Avatar>
        <span>{getValue() as string}</span>
      </div>
    ),
  },
  { accessorKey: 'email', header: 'Email' },
  { accessorKey: 'company', header: 'Company' },
]

export function ContactsTable() {
  const [selected, setSelected] = useState<Contact | null>(null)

  const { data: contacts = [] } = useQuery({
    queryKey: ['contacts'],
    queryFn: async () => {
      const { data, error } = await supabase.from('contacts').select('*').order('created_at', { ascending: false })
      if (error) throw error
      return data as Contact[]
    },
  })

  return (
    <>
      <DataTable columns={columns} data={contacts} onRowClick={setSelected} />
      <Sheet open={!!selected} onOpenChange={() => setSelected(null)}>
        <SheetContent className="w-[420px]">
          <SheetHeader>
            <SheetTitle>{selected?.first_name} {selected?.last_name}</SheetTitle>
          </SheetHeader>
          <div className="mt-4 space-y-2 text-sm">
            <p className="text-muted-foreground">{selected?.email}</p>
            <Badge variant="outline">{selected?.company}</Badge>
          </div>
        </SheetContent>
      </Sheet>
    </>
  )
}
```

> Pro tip: Ask Lovable to add a global search input above the table that filters contacts by name, email, or company in real time using TanStack Table's built-in filtering.

**Expected result:** The contacts page shows a paginated, searchable table. Clicking a row slides open the Sheet panel with contact details and linked deals.

### 5. Add the deal edit Dialog and activity log

Ask Lovable to build a Dialog that opens when you click a deal card. It should show all deal fields in an editable Form, and below the form display a chronological activity log with an input to add new activities.

```
Build a DealDialog component at src/components/pipeline/DealDialog.tsx.

Requirements:
- Accept a deal prop and an onClose callback
- Show a shadcn/ui Dialog with two sections: Deal Info (form) and Activity Log
- Deal Info form fields: title (Input), value (Input, number), stage (Select with the 6 stages), owner (Select from team members), contact (Select from contacts)
- Use react-hook-form + zod for validation
- On submit, call supabase.from('deals').update(...).eq('id', deal.id)
- Activity Log section: show activities filtered by deal_id, ordered by created_at desc
- Each activity row shows: type Badge (call/email/note/meeting), body text, created_by name, relative time (e.g. '2 hours ago')
- Add a Textarea at the bottom to log a new activity, with a Submit button that inserts into the activities table
```

**Expected result:** Clicking any deal card opens the Dialog. You can edit fields, save changes, and add activity notes that appear immediately in the log.

### 6. Publish and configure RLS for team use

Before sharing with your team, verify RLS policies are active so each user only sees their organization's data. Then publish from the Publish icon in the top-right corner of Lovable.

```
Review and harden the RLS policies for production. For each table (contacts, deals, activities, pipelines), ensure:
1. Row-Level Security is enabled
2. There is a SELECT policy: 'auth.uid() = owner_id OR org_id = (SELECT org_id FROM profiles WHERE id = auth.uid())'
3. There is an INSERT policy that sets org_id automatically from the user's profile
4. There is an UPDATE policy that matches the SELECT policy
5. Add a profiles table if it doesn't exist: id (references auth.users), org_id, full_name, avatar_url

Show me the SQL for all policies.
```

> Pro tip: Test RLS by creating two users in different organizations in the Supabase Auth dashboard. Log in as each and confirm they cannot see each other's data.

**Expected result:** Supabase Table Editor shows the lock icon on all four tables. Running a query as a non-owner user returns zero rows.

## Complete code example

File: `src/components/pipeline/DealCard.tsx`

```typescript
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'

type Deal = {
  id: string
  title: string
  value: number
  stage: string
  contact_name: string
  owner_initials: string
}

const stageVariant: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
  Lead: 'secondary',
  Qualified: 'default',
  Proposal: 'outline',
  Negotiation: 'outline',
  Won: 'default',
  Lost: 'destructive',
}

interface DealCardProps {
  deal: Deal
  onClick: (deal: Deal) => void
  isDragging?: boolean
}

export function DealCard({ deal, onClick, isDragging }: DealCardProps) {
  const formatted = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    maximumFractionDigits: 0,
  }).format(deal.value)

  return (
    <Card
      className={`cursor-pointer transition-shadow hover:shadow-md ${
        isDragging ? 'opacity-50 rotate-1' : ''
      }`}
      onClick={() => onClick(deal)}
    >
      <CardContent className="p-3 space-y-2">
        <p className="text-sm font-medium leading-tight">{deal.title}</p>
        <p className="text-xs text-muted-foreground">{deal.contact_name}</p>
        <div className="flex items-center justify-between">
          <span className="text-sm font-semibold">{formatted}</span>
          <div className="flex items-center gap-1">
            <Badge variant={stageVariant[deal.stage] ?? 'secondary'} className="text-xs">
              {deal.stage}
            </Badge>
            <Avatar className="h-5 w-5">
              <AvatarFallback className="text-xs">{deal.owner_initials}</AvatarFallback>
            </Avatar>
          </div>
        </div>
      </CardContent>
    </Card>
  )
}
```

## Common mistakes

- **Forgetting to enable RLS before sharing with the team** — Supabase tables are publicly readable by default if RLS is off. Any authenticated user can query all rows. Fix: In the Supabase Table Editor, click the table name → RLS tab → Enable RLS. Then add policies. Ask Lovable to generate the policies for you by describing the access rules.
- **Placing the Supabase service role key in client-side code** — The service role key bypasses RLS entirely. Exposing it in the browser lets anyone read or delete all data. Fix: Only the anon key goes in VITE_SUPABASE_ANON_KEY. The service role key should only appear in Edge Functions via Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'), stored in Cloud tab → Secrets.
- **Running into AI looping when building the drag-and-drop Kanban** — Drag-and-drop is complex. If Lovable gets stuck fixing the same error repeatedly, it burns credits without progress. Fix: Switch to Plan Mode (no code changes) to let Lovable reason through the approach first. If still stuck after 3 attempts, duplicate the project and restart the Kanban step with a simpler prompt.
- **Not adding a Realtime cleanup function** — Supabase Realtime channels that are not removed on component unmount accumulate and cause memory leaks or duplicate events. Fix: Always return a cleanup function from the useEffect that calls channel.unsubscribe(). Ask Lovable explicitly to 'add cleanup on component unmount' in the Realtime prompt.
- **Skipping the position field on deals for Kanban ordering** — Without a position column, deal order within a column changes randomly after every Supabase query. Fix: Add a position integer column to the deals table. When a deal is dragged, update the positions of all affected cards in a single batch upsert.

## Best practices

- Always enable RLS on every table before adding real data. Add policies that match your org_id structure so team isolation works from day one.
- Use optimistic UI updates for drag-and-drop so the board feels instant. Revert the optimistic update in the catch block if the Supabase call fails.
- Scope Realtime subscriptions per organization using a filter: .filter('org_id=eq.' + orgId) so clients only receive events relevant to them.
- Store sensitive API keys (like email provider keys) in Cloud tab → Secrets, not in VITE_ env vars. Secrets are encrypted and only accessible in Edge Functions.
- Break large Lovable prompts into steps: schema first, then layout, then interactivity. Each step builds on the previous verified output.
- Add a soft-delete pattern (deleted_at timestamp) instead of hard deletes on contacts and deals. This makes accidental deletion recoverable.
- Use TanStack Query's staleTime to avoid refetching the contacts list on every mount. Set staleTime: 60_000 for list views and rely on Realtime for live updates.

## Frequently asked questions

### Can I import existing contacts from a CSV into my Lovable CRM?

Yes. Ask Lovable to add a CSV import feature — it will generate a file input, a Papa Parse integration to read the CSV, a preview table using shadcn/ui DataTable, and a batch insert into your contacts table in Supabase. Lovable handles the column mapping UI so users can match their CSV headers to your schema.

### How do I prevent team members from seeing each other's deals?

Enable RLS on your deals table and create a SELECT policy that checks org_id against the user's profile. For individual ownership, add a secondary condition: owner_id = auth.uid() OR org_id = (SELECT org_id FROM profiles WHERE id = auth.uid()). Ask Lovable to generate these policies and it will write the SQL and apply the migration.

### Will Supabase Realtime work after I deploy the app?

Yes, Supabase Realtime works on all Supabase plans including free tier. The WebSocket connection is established from the user's browser directly to Supabase, so it works regardless of where your Lovable app is deployed. Just make sure the table's Realtime option is toggled on in the Supabase dashboard under Database → Replication.

### Can I add email notifications when a deal stage changes?

Yes. Create a Supabase Edge Function that receives a webhook from a Supabase Database Webhook (triggered on deal UPDATE). The function checks if the stage column changed, then sends an email via Resend or SendGrid using the deal owner's email from the profiles table. Store your email provider API key in Cloud tab → Secrets.

### What happens if I accidentally delete a contact?

By default, a delete is permanent. To make it recoverable, ask Lovable to add a deleted_at timestamptz column to your contacts table and change DELETE buttons to set deleted_at = now() instead. Update your RLS SELECT policy to add AND deleted_at IS NULL so soft-deleted rows are invisible to the app but remain in the database.

### Can Lovable handle a CRM with thousands of contacts?

Yes, as long as you use pagination or virtual scrolling. Ask Lovable to implement server-side pagination in the contacts DataTable: fetch only 50 rows per page using Supabase's .range(from, to) and pass total count via select('*', { count: 'exact' }). TanStack Table supports server-side pagination natively.

### Is there a team or agency that can help set up a custom CRM in Lovable?

RapidDev specializes in building production-grade Lovable apps including CRMs with custom workflows, third-party integrations, and data migrations. Reach out if you need a bespoke setup that goes beyond what you can prompt yourself.

### Can I connect my CRM to an email provider like Gmail or Outlook?

You can connect via OAuth using a Supabase Edge Function as the proxy. The function handles the OAuth token exchange and stores refresh tokens encrypted in a user_integrations table. From there, the function can fetch emails using the Gmail or Microsoft Graph API and insert them as activities linked to matching contacts by email address.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/crm-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/crm-system
