# How to Build a Recruitment Platform with Lovable

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

## TL;DR

Build a full ATS recruitment platform in Lovable with job postings, candidate applications, Kanban pipeline stages, and collaborative evaluations. Uses Supabase for storage and RLS-protected data. Resume files go to private Storage buckets. Reviewers score candidates with composite ratings tracked per hiring stage.

## Before you start

- Lovable Pro account (Storage and Edge Functions required)
- Supabase project created at supabase.com with Auth enabled
- Basic familiarity with Lovable's Cloud tab for connecting Supabase
- Understanding of SQL — you will write one database function for composite scoring
- SUPABASE_URL and SUPABASE_ANON_KEY ready to paste into Lovable Secrets

## Step-by-step guide

### 1. Set up Supabase schema and connect to Lovable

Create the core tables in Supabase SQL editor, then connect your project to Lovable via the Cloud tab. The schema covers jobs, candidates, pipeline_stages, evaluations, and a resume_files reference table.

```
-- Run in Supabase SQL Editor
create table jobs (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  department text,
  location text,
  status text default 'open' check (status in ('open','closed','draft')),
  headcount int default 1,
  created_by uuid references auth.users(id),
  created_at timestamptz default now()
);

create table pipeline_stages (
  id uuid primary key default gen_random_uuid(),
  job_id uuid references jobs(id) on delete cascade,
  name text not null,
  position int not null
);

create table candidates (
  id uuid primary key default gen_random_uuid(),
  job_id uuid references jobs(id) on delete cascade,
  stage_id uuid references pipeline_stages(id),
  full_name text not null,
  email text not null,
  resume_path text,
  composite_score numeric(3,1),
  applied_at timestamptz default now()
);

create table evaluations (
  id uuid primary key default gen_random_uuid(),
  candidate_id uuid references candidates(id) on delete cascade,
  reviewer_id uuid references auth.users(id),
  technical_score int check (technical_score between 1 and 5),
  communication_score int check (communication_score between 1 and 5),
  culture_score int check (culture_score between 1 and 5),
  notes text,
  created_at timestamptz default now()
);

-- RLS
alter table jobs enable row level security;
alter table candidates enable row level security;
alter table evaluations enable row level security;
alter table pipeline_stages enable row level security;

create policy "Auth users read jobs" on jobs for select using (auth.role() = 'authenticated');
create policy "Auth users manage candidates" on candidates for all using (auth.role() = 'authenticated');
create policy "Auth users manage evaluations" on evaluations for all using (auth.role() = 'authenticated');
create policy "Auth users read stages" on pipeline_stages for select using (auth.role() = 'authenticated');
```

> Pro tip: After running the SQL, go to Supabase Storage and create a private bucket called resumes. Private buckets require a signed URL to read — Lovable will generate these on demand.

**Expected result:** Supabase shows all 4 tables in the Table Editor. The resumes bucket appears in Storage. You can now connect Lovable via Cloud tab → Database.

### 2. Scaffold the jobs DataTable and posting form

Prompt Lovable to generate the jobs management view. This gives you a paginated DataTable with columns for title, department, status Badge, headcount, and applicant count, plus a Sheet to create or edit a job posting.

```
Build a jobs management page at /jobs.

Use a shadcn DataTable with columns:
- title (text link to /jobs/:id)
- department (text)
- location (text)
- status (Badge: green=open, yellow=draft, gray=closed)
- headcount (number)
- applicants (count from candidates table, same job_id)
- created_at (formatted date)

Add a "New Job" Button top-right that opens a Sheet.
The Sheet has a Form with: title (Input), department (Select with HR/Engineering/Sales/Marketing/Design), location (Input), headcount (Input type number), status (Select).
On submit, insert to the jobs table via Supabase. Show a Sonner toast on success.
Fetch jobs from Supabase with applicant count using a join.
All data is typed with TypeScript interfaces.
```

**Expected result:** The /jobs route renders a DataTable with all open roles. The New Job Sheet saves to Supabase and the table refreshes automatically.

### 3. Build the Kanban pipeline board per job

Each job has its own pipeline at /jobs/:id/pipeline. Prompt Lovable to render pipeline_stages as columns and candidates as draggable cards. Moving a card updates the candidate's stage_id in Supabase.

```
Build a Kanban pipeline page at /jobs/:id/pipeline.

Fetch pipeline_stages for the job ordered by position.
Fetch candidates for the job grouped by stage_id.

Render each stage as a vertical column (min-w-[280px]) with:
- Stage name header with candidate count Badge
- Candidate cards using shadcn Card component
  Each card shows: full_name, email, composite_score (if set), applied_at
  A colored Badge for composite_score: red < 2.5, yellow 2.5-3.5, green > 3.5

Each card is draggable. On drop to a different column, update candidates.stage_id in Supabase.
Clicking a card opens a Dialog (see next step).
Use horizontal scroll for many stages.
Use TypeScript interfaces for Candidate and PipelineStage.
```

> Pro tip: If drag-and-drop feels sluggish, tell Lovable to use optimistic updates: update the local state immediately on drag end, then call Supabase in the background.

**Expected result:** The pipeline page shows columns for each stage. Dragging a candidate card between columns updates Supabase instantly and the card appears in the new column.

### 4. Add the candidate Detail Dialog with Tabs

Clicking any candidate card opens a Dialog with three Tabs: Resume (signed URL from Storage), Evaluations (list of reviewer scores), and Timeline (stage change history). This is the core review interface.

```
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { supabase } from '@/lib/supabase'

interface Evaluation {
  id: string
  reviewer_id: string
  technical_score: number
  communication_score: number
  culture_score: number
  notes: string
  created_at: string
  reviewer_email?: string
}

interface CandidateDialogProps {
  candidateId: string | null
  candidateName: string
  resumePath: string | null
  onClose: () => void
}

export function CandidateDialog({ candidateId, candidateName, resumePath, onClose }: CandidateDialogProps) {
  const [resumeUrl, setResumeUrl] = useState<string | null>(null)
  const [evaluations, setEvaluations] = useState<Evaluation[]>([])

  const loadResume = async () => {
    if (!resumePath) return
    const { data } = await supabase.storage
      .from('resumes')
      .createSignedUrl(resumePath, 3600)
    if (data) setResumeUrl(data.signedUrl)
  }

  const loadEvaluations = async () => {
    if (!candidateId) return
    const { data } = await supabase
      .from('evaluations')
      .select('*')
      .eq('candidate_id', candidateId)
      .order('created_at', { ascending: false })
    if (data) setEvaluations(data)
  }

  const scoreColor = (s: number) =>
    s >= 4 ? 'text-green-600' : s >= 3 ? 'text-yellow-600' : 'text-red-600'

  return (
    <Dialog open={!!candidateId} onOpenChange={onClose}>
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>{candidateName}</DialogTitle>
        </DialogHeader>
        <Tabs defaultValue="resume" onValueChange={(v) => { if (v === 'resume') loadResume(); if (v === 'evaluations') loadEvaluations(); }}>
          <TabsList>
            <TabsTrigger value="resume">Resume</TabsTrigger>
            <TabsTrigger value="evaluations">Evaluations</TabsTrigger>
            <TabsTrigger value="timeline">Timeline</TabsTrigger>
          </TabsList>
          <TabsContent value="resume" className="mt-4">
            {resumeUrl ? (
              <iframe src={resumeUrl} className="w-full h-[500px] rounded border" />
            ) : (
              <p className="text-muted-foreground text-sm">No resume uploaded.</p>
            )}
          </TabsContent>
          <TabsContent value="evaluations" className="mt-4 space-y-3">
            {evaluations.map((e) => (
              <div key={e.id} className="border rounded-lg p-4 space-y-2">
                <div className="flex items-center gap-2">
                  <Avatar className="h-7 w-7"><AvatarFallback>R</AvatarFallback></Avatar>
                  <span className="text-sm font-medium">{e.reviewer_email ?? 'Reviewer'}</span>
                </div>
                <div className="flex gap-4 text-sm">
                  <span>Tech: <span className={scoreColor(e.technical_score)}>{e.technical_score}/5</span></span>
                  <span>Comm: <span className={scoreColor(e.communication_score)}>{e.communication_score}/5</span></span>
                  <span>Culture: <span className={scoreColor(e.culture_score)}>{e.culture_score}/5</span></span>
                </div>
                {e.notes && <p className="text-sm text-muted-foreground">{e.notes}</p>}
              </div>
            ))}
          </TabsContent>
          <TabsContent value="timeline" className="mt-4">
            <p className="text-sm text-muted-foreground">Timeline coming in next step.</p>
          </TabsContent>
        </Tabs>
      </DialogContent>
    </Dialog>
  )
}
```

**Expected result:** Clicking a candidate card opens the Dialog. The Resume tab loads a signed iframe from private Storage. The Evaluations tab lists all reviewer scores with color-coded ratings.

### 5. Build the evaluation form and composite scoring function

Reviewers submit scores via a Form inside the Dialog. A Supabase database function recalculates the composite score on each new evaluation and writes it back to candidates.composite_score.

```
-- Add to Supabase SQL Editor
create or replace function recalculate_composite_score(p_candidate_id uuid)
returns void language plpgsql as $$
declare
  avg_score numeric(3,1);
begin
  select round(avg((technical_score + communication_score + culture_score)::numeric / 3), 1)
  into avg_score
  from evaluations
  where candidate_id = p_candidate_id;

  update candidates
  set composite_score = avg_score
  where id = p_candidate_id;
end;
$$;

-- Trigger on evaluation insert/update
create or replace function trigger_composite_score()
returns trigger language plpgsql as $$
begin
  perform recalculate_composite_score(NEW.candidate_id);
  return NEW;
end;
$$;

create trigger eval_score_trigger
after insert or update on evaluations
for each row execute function trigger_composite_score();
```

> Pro tip: After pasting this SQL, tell Lovable: 'Add an evaluation form inside the CandidateDialog Evaluations tab with three 1-5 Select fields for Technical, Communication, and Culture scores, plus a Textarea for notes. On submit insert to evaluations table.' Lovable will wire up the form automatically.

**Expected result:** After a reviewer submits scores, candidates.composite_score updates automatically via the trigger. The Kanban card Badge color changes on next render to reflect the new score.

### 6. Add resume upload and finalize the dashboard

Prompt Lovable to add a resume upload input to the candidate creation form that sends the file to the Supabase resumes bucket and stores the path. Then scaffold a simple stats dashboard.

```
Add resume upload to the candidate application form.

When creating a new candidate:
1. Show a file Input (accept .pdf,.doc,.docx) labeled "Resume"
2. On form submit, upload the file to Supabase Storage bucket 'resumes' using path: `${jobId}/${candidateId}/${file.name}`
3. Store the returned path in candidates.resume_path
4. Show upload progress with a Progress bar from shadcn
5. Show a Sonner toast: "Resume uploaded" on success

Also build a /dashboard page with four Card components:
- Total open jobs (count from jobs where status='open')
- Total candidates this month
- Average composite score across all evaluated candidates
- Candidates added today

Below the cards, show a recent activity list: last 10 candidates ordered by applied_at with their name, job title, and stage Badge.
```

**Expected result:** The candidate form now has a file upload with progress indicator. The dashboard shows live stats from Supabase and a recent activity feed below the metric cards.

## Complete code example

File: `src/hooks/usePipeline.ts`

```typescript
import { useState, useEffect, useCallback } from 'react'
import { supabase } from '@/lib/supabase'

export interface PipelineStage {
  id: string
  job_id: string
  name: string
  position: number
}

export interface Candidate {
  id: string
  job_id: string
  stage_id: string
  full_name: string
  email: string
  resume_path: string | null
  composite_score: number | null
  applied_at: string
}

export function usePipeline(jobId: string) {
  const [stages, setStages] = useState<PipelineStage[]>([])
  const [candidates, setCandidates] = useState<Record<string, Candidate[]>>({})
  const [loading, setLoading] = useState(true)

  const load = useCallback(async () => {
    setLoading(true)
    const [{ data: stageData }, { data: candData }] = await Promise.all([
      supabase.from('pipeline_stages').select('*').eq('job_id', jobId).order('position'),
      supabase.from('candidates').select('*').eq('job_id', jobId).order('applied_at')
    ])
    if (stageData) setStages(stageData)
    if (candData) {
      const grouped: Record<string, Candidate[]> = {}
      stageData?.forEach(s => { grouped[s.id] = [] })
      candData.forEach(c => {
        if (grouped[c.stage_id]) grouped[c.stage_id].push(c)
        else grouped[c.stage_id] = [c]
      })
      setCandidates(grouped)
    }
    setLoading(false)
  }, [jobId])

  const moveCandidate = async (candidateId: string, newStageId: string) => {
    setCandidates(prev => {
      const next = { ...prev }
      let moved: Candidate | undefined
      Object.keys(next).forEach(sid => {
        const idx = next[sid].findIndex(c => c.id === candidateId)
        if (idx !== -1) { [moved] = next[sid].splice(idx, 1) }
      })
      if (moved) next[newStageId] = [...(next[newStageId] ?? []), { ...moved, stage_id: newStageId }]
      return next
    })
    await supabase.from('candidates').update({ stage_id: newStageId }).eq('id', candidateId)
  }

  useEffect(() => { load() }, [load])
  return { stages, candidates, loading, moveCandidate, reload: load }
}
```

## Common mistakes

- **Storing resume files in the public bucket** — Public buckets expose files to anyone with the URL, violating candidate privacy and potentially GDPR requirements. Fix: Always use the private resumes bucket and generate signed URLs with a short expiry (3600 seconds) each time the resume tab opens.
- **Forgetting RLS on the evaluations table** — Without RLS, any authenticated user can read or modify any reviewer's scores, allowing gaming the evaluation process. Fix: Enable RLS and add policies so reviewers can only update their own evaluations. Use auth.uid() = reviewer_id in the policy check.
- **Running the composite score calculation in the frontend** — Calculating averages in React means the stored composite_score is never updated — next page load shows stale data. Fix: Use the database trigger approach in Step 5. The Postgres function updates candidates.composite_score immediately on every evaluation insert.
- **Not handling missing stage_id when candidates first apply** — If a candidate is inserted without a stage_id, they won't appear on any Kanban column and are effectively lost. Fix: When creating a job, always insert a default first stage (e.g., Applied) and set new candidates.stage_id to that stage's id automatically.
- **Embedding Supabase service role key in Lovable frontend** — The service role key bypasses all RLS policies. Exposing it client-side gives anyone full database access. Fix: Only use the anon key in Lovable. If you need admin operations, put them in a Supabase Edge Function accessed via a secure endpoint.

## Best practices

- Always enable RLS on every table before writing any application code — it is off by default in Supabase
- Use Supabase composite indexes on candidates(job_id, stage_id) for fast Kanban column queries on large datasets
- Generate resume signed URLs lazily — only when the Resume tab is opened, not when the Dialog first mounts
- Use optimistic UI updates when moving candidates between pipeline stages to make the Kanban feel instant
- Store pipeline stage order as an integer position column so hiring managers can reorder without complex queries
- Scope all Lovable prompts to a single component or feature to avoid unintended rewrites of working code
- Test RLS policies in Supabase's built-in policy tester before connecting Lovable to avoid surprise permission errors
- Use Lovable Plan Mode when designing the evaluation scoring logic — describe the full data flow before asking it to generate code

## Frequently asked questions

### How do I prevent two reviewers from submitting duplicate evaluations?

Add a unique constraint in Supabase: ALTER TABLE evaluations ADD CONSTRAINT one_eval_per_reviewer UNIQUE (candidate_id, reviewer_id). This makes Supabase reject a second insert from the same reviewer. In Lovable, handle the error response and show a Sonner toast: 'You have already submitted an evaluation for this candidate.'

### Can I build this on the Lovable free plan?

The free plan supports the database schema and Kanban UI. However, private Storage buckets and Edge Functions require Lovable Pro ($25/month). The resume upload and signed URL features will not work on the free tier.

### How do I handle resume files larger than the Supabase free tier Storage limit?

The Supabase free tier includes 1 GB of Storage. For a small team this is plenty. If you expect high volume, upgrade to Supabase Pro ($25/month, 100 GB) or add a file size validation in the upload handler to reject files over 5 MB.

### How do I deploy this to a custom domain?

Click the Publish icon (top-right in Lovable), then go to Settings → Custom Domain. Enter your domain name and follow the DNS instructions. For OAuth to work correctly, add the new domain to your Supabase Auth → URL Configuration → Redirect URLs list.

### Can multiple hiring managers work simultaneously without data conflicts?

Yes. Supabase uses PostgreSQL row-level locking, so concurrent stage updates don't conflict. For real-time board updates when a colleague moves a candidate, enable Supabase Realtime on the candidates table and subscribe to changes in the Kanban component.

### How do I add GDPR-compliant candidate data deletion?

Add a DELETE button in the candidate detail Dialog that calls a Supabase Edge Function. The function deletes the resume from Storage, removes all evaluations (cascade handles this), then deletes the candidate row. Log deletions to a separate audit table with the deleting user's ID and timestamp.

### I'm stuck and my Lovable build keeps looping — what should I do?

This is Lovable's known 'looping' issue where the AI repeatedly tries to fix the same bug. The quickest fix: use Plan Mode (free, no credits) to describe exactly what component is broken, then click 'Implement the plan' for a fresh targeted fix. If it persists, RapidDev can help audit your Supabase RLS setup and component logic to unblock the build.

### How do I filter the DataTable to show only jobs owned by the current user?

Add a WHERE clause in your Supabase query: .eq('created_by', session.user.id). Then add a toggle Switch labeled 'My jobs only' that conditionally applies the filter. Store the toggle state in useState and re-fetch when it changes.

---

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