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
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
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.
1-- Run in Supabase SQL Editor2create 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);1213create 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 null18);1920create 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);3031create 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);4142-- RLS43alter 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;4748create 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.
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.
1Build a jobs management page at /jobs.23Use 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)1112Add 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.
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.
1Build a Kanban pipeline page at /jobs/:id/pipeline.23Fetch pipeline_stages for the job ordered by position.4Fetch candidates for the job grouped by stage_id.56Render each stage as a vertical column (min-w-[280px]) with:7- Stage name header with candidate count Badge8- Candidate cards using shadcn Card component9 Each card shows: full_name, email, composite_score (if set), applied_at10 A colored Badge for composite_score: red < 2.5, yellow 2.5-3.5, green > 3.51112Each 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.
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.
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'78interface Evaluation {9 id: string10 reviewer_id: string11 technical_score: number12 communication_score: number13 culture_score: number14 notes: string15 created_at: string16 reviewer_email?: string17}1819interface CandidateDialogProps {20 candidateId: string | null21 candidateName: string22 resumePath: string | null23 onClose: () => void24}2526export function CandidateDialog({ candidateId, candidateName, resumePath, onClose }: CandidateDialogProps) {27 const [resumeUrl, setResumeUrl] = useState<string | null>(null)28 const [evaluations, setEvaluations] = useState<Evaluation[]>([])2930 const loadResume = async () => {31 if (!resumePath) return32 const { data } = await supabase.storage33 .from('resumes')34 .createSignedUrl(resumePath, 3600)35 if (data) setResumeUrl(data.signedUrl)36 }3738 const loadEvaluations = async () => {39 if (!candidateId) return40 const { data } = await supabase41 .from('evaluations')42 .select('*')43 .eq('candidate_id', candidateId)44 .order('created_at', { ascending: false })45 if (data) setEvaluations(data)46 }4748 const scoreColor = (s: number) =>49 s >= 4 ? 'text-green-600' : s >= 3 ? 'text-yellow-600' : 'text-red-600'5051 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.
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.
1-- Add to Supabase SQL Editor2create or replace function recalculate_composite_score(p_candidate_id uuid)3returns void language plpgsql as $$4declare5 avg_score numeric(3,1);6begin7 select round(avg((technical_score + communication_score + culture_score)::numeric / 3), 1)8 into avg_score9 from evaluations10 where candidate_id = p_candidate_id;1112 update candidates13 set composite_score = avg_score14 where id = p_candidate_id;15end;16$$;1718-- Trigger on evaluation insert/update19create or replace function trigger_composite_score()20returns trigger language plpgsql as $$21begin22 perform recalculate_composite_score(NEW.candidate_id);23 return NEW;24end;25$$;2627create trigger eval_score_trigger28after insert or update on evaluations29for 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.
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.
1Add resume upload to the candidate application form.23When 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_path74. Show upload progress with a Progress bar from shadcn85. Show a Sonner toast: "Resume uploaded" on success910Also build a /dashboard page with four Card components:11- Total open jobs (count from jobs where status='open')12- Total candidates this month13- Average composite score across all evaluated candidates14- Candidates added today1516Below 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
1import { useState, useEffect, useCallback } from 'react'2import { supabase } from '@/lib/supabase'34export interface PipelineStage {5 id: string6 job_id: string7 name: string8 position: number9}1011export interface Candidate {12 id: string13 job_id: string14 stage_id: string15 full_name: string16 email: string17 resume_path: string | null18 composite_score: number | null19 applied_at: string20}2122export function usePipeline(jobId: string) {23 const [stages, setStages] = useState<PipelineStage[]>([])24 const [candidates, setCandidates] = useState<Record<string, Candidate[]>>({})25 const [loading, setLoading] = useState(true)2627 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])4546 const moveCandidate = async (candidateId: string, newStageId: string) => {47 setCandidates(prev => {48 const next = { ...prev }49 let moved: Candidate | undefined50 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 next56 })57 await supabase.from('candidates').update({ stage_id: newStageId }).eq('id', candidateId)58 }5960 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation