Skip to main content
RapidDev - Software Development Agency

How to Build a Recruitment Platform with Lovable

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.

What you'll build

  • Job postings DataTable with status, department, and applicant counts
  • Candidate application pipeline with Kanban board and drag-and-drop stage transitions
  • Resume upload to private Supabase Storage with secure signed URLs
  • Collaborative evaluation system with per-reviewer scores and composite ratings
  • Candidate detail Dialog with Tabs for Resume, Evaluations, and Timeline
  • Role-based access so hiring managers see only their pipelines
  • Dashboard with open roles, pipeline velocity, and recent activity feed
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced14 min read3-4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

A fully functional Applicant Tracking System (ATS) that lets hiring teams manage the entire recruitment lifecycle without leaving a single dashboard.

Candidates move through configurable pipeline stages — Applied, Screening, Interview, Offer, Hired — via a Kanban board. Each card opens a Dialog with three tabs: the resume viewer (from a signed Supabase Storage URL), all evaluations submitted by reviewers with individual scores, and a timeline of every status change.

Hiring managers create job postings with a DataTable listing open roles, headcount, department, and total applicants. An evaluation form lets multiple reviewers submit structured scores (technical, communication, culture fit) that aggregate into a composite score shown on the candidate card.

Final result

A production-ready ATS where recruiters post jobs, track candidates through a Kanban pipeline, review resumes from private storage, and submit collaborative evaluations — all enforced by Supabase RLS so each reviewer only sees what they should.

Tech stack

LovableAI-assisted UI generation and project scaffolding
React + TypeScriptComponent logic and type-safe data models
Supabase PostgreSQLCandidates, jobs, evaluations, and pipeline stages
Supabase StoragePrivate bucket for resume PDF/DOCX uploads
Supabase AuthReviewer authentication and role enforcement
shadcn/uiDataTable, Dialog, Tabs, Badge, Avatar, Card components
Tailwind CSSResponsive layout and pipeline column styling

Prerequisites

  • 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

Build steps

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.

supabase_schema.sql
1-- Run in Supabase SQL Editor
2create table jobs (
3 id uuid primary key default gen_random_uuid(),
4 title text not null,
5 department text,
6 location text,
7 status text default 'open' check (status in ('open','closed','draft')),
8 headcount int default 1,
9 created_by uuid references auth.users(id),
10 created_at timestamptz default now()
11);
12
13create table pipeline_stages (
14 id uuid primary key default gen_random_uuid(),
15 job_id uuid references jobs(id) on delete cascade,
16 name text not null,
17 position int not null
18);
19
20create table candidates (
21 id uuid primary key default gen_random_uuid(),
22 job_id uuid references jobs(id) on delete cascade,
23 stage_id uuid references pipeline_stages(id),
24 full_name text not null,
25 email text not null,
26 resume_path text,
27 composite_score numeric(3,1),
28 applied_at timestamptz default now()
29);
30
31create table evaluations (
32 id uuid primary key default gen_random_uuid(),
33 candidate_id uuid references candidates(id) on delete cascade,
34 reviewer_id uuid references auth.users(id),
35 technical_score int check (technical_score between 1 and 5),
36 communication_score int check (communication_score between 1 and 5),
37 culture_score int check (culture_score between 1 and 5),
38 notes text,
39 created_at timestamptz default now()
40);
41
42-- RLS
43alter table jobs enable row level security;
44alter table candidates enable row level security;
45alter table evaluations enable row level security;
46alter table pipeline_stages enable row level security;
47
48create policy "Auth users read jobs" on jobs for select using (auth.role() = 'authenticated');
49create policy "Auth users manage candidates" on candidates for all using (auth.role() = 'authenticated');
50create policy "Auth users manage evaluations" on evaluations for all using (auth.role() = 'authenticated');
51create 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.

prompt.txt
1Build a jobs management page at /jobs.
2
3Use a shadcn DataTable with columns:
4- title (text link to /jobs/:id)
5- department (text)
6- location (text)
7- status (Badge: green=open, yellow=draft, gray=closed)
8- headcount (number)
9- applicants (count from candidates table, same job_id)
10- created_at (formatted date)
11
12Add a "New Job" Button top-right that opens a Sheet.
13The Sheet has a Form with: title (Input), department (Select with HR/Engineering/Sales/Marketing/Design), location (Input), headcount (Input type number), status (Select).
14On submit, insert to the jobs table via Supabase. Show a Sonner toast on success.
15Fetch jobs from Supabase with applicant count using a join.
16All 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.

prompt.txt
1Build a Kanban pipeline page at /jobs/:id/pipeline.
2
3Fetch pipeline_stages for the job ordered by position.
4Fetch candidates for the job grouped by stage_id.
5
6Render each stage as a vertical column (min-w-[280px]) with:
7- Stage name header with candidate count Badge
8- Candidate cards using shadcn Card component
9 Each card shows: full_name, email, composite_score (if set), applied_at
10 A colored Badge for composite_score: red < 2.5, yellow 2.5-3.5, green > 3.5
11
12Each card is draggable. On drop to a different column, update candidates.stage_id in Supabase.
13Clicking a card opens a Dialog (see next step).
14Use horizontal scroll for many stages.
15Use 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.

src/components/CandidateDialog.tsx
1import { useState } from 'react'
2import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
3import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
4import { Avatar, AvatarFallback } from '@/components/ui/avatar'
5import { Badge } from '@/components/ui/badge'
6import { supabase } from '@/lib/supabase'
7
8interface Evaluation {
9 id: string
10 reviewer_id: string
11 technical_score: number
12 communication_score: number
13 culture_score: number
14 notes: string
15 created_at: string
16 reviewer_email?: string
17}
18
19interface CandidateDialogProps {
20 candidateId: string | null
21 candidateName: string
22 resumePath: string | null
23 onClose: () => void
24}
25
26export function CandidateDialog({ candidateId, candidateName, resumePath, onClose }: CandidateDialogProps) {
27 const [resumeUrl, setResumeUrl] = useState<string | null>(null)
28 const [evaluations, setEvaluations] = useState<Evaluation[]>([])
29
30 const loadResume = async () => {
31 if (!resumePath) return
32 const { data } = await supabase.storage
33 .from('resumes')
34 .createSignedUrl(resumePath, 3600)
35 if (data) setResumeUrl(data.signedUrl)
36 }
37
38 const loadEvaluations = async () => {
39 if (!candidateId) return
40 const { data } = await supabase
41 .from('evaluations')
42 .select('*')
43 .eq('candidate_id', candidateId)
44 .order('created_at', { ascending: false })
45 if (data) setEvaluations(data)
46 }
47
48 const scoreColor = (s: number) =>
49 s >= 4 ? 'text-green-600' : s >= 3 ? 'text-yellow-600' : 'text-red-600'
50
51 return (
52 <Dialog open={!!candidateId} onOpenChange={onClose}>
53 <DialogContent className="max-w-2xl">
54 <DialogHeader>
55 <DialogTitle>{candidateName}</DialogTitle>
56 </DialogHeader>
57 <Tabs defaultValue="resume" onValueChange={(v) => { if (v === 'resume') loadResume(); if (v === 'evaluations') loadEvaluations(); }}>
58 <TabsList>
59 <TabsTrigger value="resume">Resume</TabsTrigger>
60 <TabsTrigger value="evaluations">Evaluations</TabsTrigger>
61 <TabsTrigger value="timeline">Timeline</TabsTrigger>
62 </TabsList>
63 <TabsContent value="resume" className="mt-4">
64 {resumeUrl ? (
65 <iframe src={resumeUrl} className="w-full h-[500px] rounded border" />
66 ) : (
67 <p className="text-muted-foreground text-sm">No resume uploaded.</p>
68 )}
69 </TabsContent>
70 <TabsContent value="evaluations" className="mt-4 space-y-3">
71 {evaluations.map((e) => (
72 <div key={e.id} className="border rounded-lg p-4 space-y-2">
73 <div className="flex items-center gap-2">
74 <Avatar className="h-7 w-7"><AvatarFallback>R</AvatarFallback></Avatar>
75 <span className="text-sm font-medium">{e.reviewer_email ?? 'Reviewer'}</span>
76 </div>
77 <div className="flex gap-4 text-sm">
78 <span>Tech: <span className={scoreColor(e.technical_score)}>{e.technical_score}/5</span></span>
79 <span>Comm: <span className={scoreColor(e.communication_score)}>{e.communication_score}/5</span></span>
80 <span>Culture: <span className={scoreColor(e.culture_score)}>{e.culture_score}/5</span></span>
81 </div>
82 {e.notes && <p className="text-sm text-muted-foreground">{e.notes}</p>}
83 </div>
84 ))}
85 </TabsContent>
86 <TabsContent value="timeline" className="mt-4">
87 <p className="text-sm text-muted-foreground">Timeline coming in next step.</p>
88 </TabsContent>
89 </Tabs>
90 </DialogContent>
91 </Dialog>
92 )
93}

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.

composite_score_function.sql
1-- Add to Supabase SQL Editor
2create or replace function recalculate_composite_score(p_candidate_id uuid)
3returns void language plpgsql as $$
4declare
5 avg_score numeric(3,1);
6begin
7 select round(avg((technical_score + communication_score + culture_score)::numeric / 3), 1)
8 into avg_score
9 from evaluations
10 where candidate_id = p_candidate_id;
11
12 update candidates
13 set composite_score = avg_score
14 where id = p_candidate_id;
15end;
16$$;
17
18-- Trigger on evaluation insert/update
19create or replace function trigger_composite_score()
20returns trigger language plpgsql as $$
21begin
22 perform recalculate_composite_score(NEW.candidate_id);
23 return NEW;
24end;
25$$;
26
27create trigger eval_score_trigger
28after insert or update on evaluations
29for 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.

prompt.txt
1Add resume upload to the candidate application form.
2
3When creating a new candidate:
41. Show a file Input (accept .pdf,.doc,.docx) labeled "Resume"
52. On form submit, upload the file to Supabase Storage bucket 'resumes' using path: `${jobId}/${candidateId}/${file.name}`
63. Store the returned path in candidates.resume_path
74. Show upload progress with a Progress bar from shadcn
85. Show a Sonner toast: "Resume uploaded" on success
9
10Also build a /dashboard page with four Card components:
11- Total open jobs (count from jobs where status='open')
12- Total candidates this month
13- Average composite score across all evaluated candidates
14- Candidates added today
15
16Below 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

src/hooks/usePipeline.ts
1import { useState, useEffect, useCallback } from 'react'
2import { supabase } from '@/lib/supabase'
3
4export interface PipelineStage {
5 id: string
6 job_id: string
7 name: string
8 position: number
9}
10
11export interface Candidate {
12 id: string
13 job_id: string
14 stage_id: string
15 full_name: string
16 email: string
17 resume_path: string | null
18 composite_score: number | null
19 applied_at: string
20}
21
22export function usePipeline(jobId: string) {
23 const [stages, setStages] = useState<PipelineStage[]>([])
24 const [candidates, setCandidates] = useState<Record<string, Candidate[]>>({})
25 const [loading, setLoading] = useState(true)
26
27 const load = useCallback(async () => {
28 setLoading(true)
29 const [{ data: stageData }, { data: candData }] = await Promise.all([
30 supabase.from('pipeline_stages').select('*').eq('job_id', jobId).order('position'),
31 supabase.from('candidates').select('*').eq('job_id', jobId).order('applied_at')
32 ])
33 if (stageData) setStages(stageData)
34 if (candData) {
35 const grouped: Record<string, Candidate[]> = {}
36 stageData?.forEach(s => { grouped[s.id] = [] })
37 candData.forEach(c => {
38 if (grouped[c.stage_id]) grouped[c.stage_id].push(c)
39 else grouped[c.stage_id] = [c]
40 })
41 setCandidates(grouped)
42 }
43 setLoading(false)
44 }, [jobId])
45
46 const moveCandidate = async (candidateId: string, newStageId: string) => {
47 setCandidates(prev => {
48 const next = { ...prev }
49 let moved: Candidate | undefined
50 Object.keys(next).forEach(sid => {
51 const idx = next[sid].findIndex(c => c.id === candidateId)
52 if (idx !== -1) { [moved] = next[sid].splice(idx, 1) }
53 })
54 if (moved) next[newStageId] = [...(next[newStageId] ?? []), { ...moved, stage_id: newStageId }]
55 return next
56 })
57 await supabase.from('candidates').update({ stage_id: newStageId }).eq('id', candidateId)
58 }
59
60 useEffect(() => { load() }, [load])
61 return { stages, candidates, loading, moveCandidate, reload: load }
62}

Customization ideas

Email notifications on stage change

Add a Supabase Edge Function triggered by a database webhook on candidates UPDATE. When stage_id changes, send a notification email to the candidate using Resend or SendGrid.

Custom pipeline templates

Let hiring managers save their pipeline_stages as a named template. When creating a new job, they pick a template and the stages are cloned automatically via a SQL function.

Offer letter generation

When a candidate reaches the Offer stage, show a Button that opens a pre-filled Textarea with offer details. Save the offer as a PDF blob to Storage using a browser print API.

Interview scheduling integration

Add a Calendar Popover to each candidate card that saves an interview slot to a schedule table. Show a timeline of upcoming interviews on the dashboard.

Bulk CSV import

Add a CSV import Dialog that accepts a spreadsheet of candidates with name, email, and job title columns. Parse client-side and bulk-insert to Supabase with a single upsert call.

Evaluation score weighting

Let admins configure score weights per job (e.g., technical 50%, communication 30%, culture 20%). Store weights in jobs table and update the composite_score function to use them.

Common pitfalls

Pitfall: Storing resume files in the public bucket

How to avoid: Always use the private resumes bucket and generate signed URLs with a short expiry (3600 seconds) each time the resume tab opens.

Pitfall: Forgetting RLS on the evaluations table

How to avoid: Enable RLS and add policies so reviewers can only update their own evaluations. Use auth.uid() = reviewer_id in the policy check.

Pitfall: Running the composite score calculation in the frontend

How to avoid: Use the database trigger approach in Step 5. The Postgres function updates candidates.composite_score immediately on every evaluation insert.

Pitfall: Not handling missing stage_id when candidates first apply

How to avoid: 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.

Pitfall: Embedding Supabase service role key in Lovable frontend

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

Design a PostgreSQL schema for an ATS with jobs, candidates, pipeline stages, and evaluations tables. Include RLS policies, a composite score trigger function, and indexes for a Kanban-style pipeline view.

Lovable Prompt

Build a recruitment ATS with a /jobs DataTable, /jobs/:id/pipeline Kanban board, and a candidate detail Dialog with Resume/Evaluations/Timeline tabs. Use Supabase for data, shadcn/ui for all components, and private Storage for resume uploads.

Build Prompt

Add drag-and-drop to the Kanban pipeline board so hiring managers can move candidate cards between stage columns. Use optimistic updates: update React state immediately on drag end, then sync to Supabase in the background. Show a Sonner toast if the Supabase update fails and revert the card to its original column.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.