Build an applicant tracking system with V0 using Next.js and Supabase that lets recruiters post jobs, receive applications with resume uploads, and move candidates through customizable hiring pipeline stages. Features a kanban-style pipeline, interview notes, and candidate profiles — all in about 1-2 hours.
What you're building
Hiring is complex — tracking applicants across job postings, managing resumes, coordinating interviews, and moving candidates through pipeline stages. Dedicated ATS tools charge hundreds per month. You can build your own with full control over the workflow.
V0 generates the job board, application forms, and recruiter dashboard from prompts. Supabase handles the database, file storage for resumes (with private bucket security), and authentication for the recruiter portal.
The architecture uses Next.js App Router with public Server Components for the job board (SEO-optimized), protected pages for the recruiter dashboard, Supabase Storage with signed URLs for secure resume access, and Server Actions for all candidate workflow mutations.
Final result
A recruitment platform with a public job board, application forms with resume upload, a recruiter kanban pipeline for tracking candidates, interview notes with ratings, and secure resume file handling.
Tech stack
Prerequisites
- A V0 account (Premium recommended for multiple page types)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Job descriptions to post for testing
- Sample resumes (PDF) for testing the upload flow
Build steps
Set up the project and recruitment schema
Open V0 and create a new project. Use the Connect panel to add Supabase. Create the schema for jobs, applications, candidates, interview notes, and pipeline stages. Set up a private Storage bucket for resumes.
1// Paste this prompt into V0's AI chat:2// Build a recruitment platform. Create a Supabase schema with:3// 1. jobs: id (uuid PK), company_id (uuid FK), title (text), department (text), location (text), type (text check full-time/part-time/contract/remote), description (text), requirements (text[]), salary_min (integer), salary_max (integer), status (text check draft/open/closed), created_at (timestamptz)4// 2. candidates: id (uuid PK), user_id (uuid nullable), first_name (text), last_name (text), email (text unique), phone (text), linkedin_url (text), created_at (timestamptz)5// 3. applications: id (uuid PK), job_id (uuid FK), candidate_id (uuid FK), stage (text check applied/screening/interview/offer/hired/rejected), resume_url (text), cover_letter (text), applied_at (timestamptz), updated_at (timestamptz)6// 4. interview_notes: id (uuid PK), application_id (uuid FK), interviewer_id (uuid FK), notes (text), rating (integer check 1-5), created_at (timestamptz)7// 5. pipeline_stages: id (uuid PK), company_id (uuid FK), name (text), position (integer), color (text)8// Create a private Supabase Storage bucket 'resumes' for resume files.9// RLS: public can read open jobs and submit applications. Only company members can read applications and notes.10// Generate SQL migration and TypeScript types.Expected result: Supabase is connected with all five tables and a private resumes Storage bucket. RLS allows public job browsing and application submission while restricting recruiter data.
Build the public job board
Create a public-facing job board where candidates can browse open positions with search and filters. Each job links to a detail page with a full description and apply button.
1// Paste this prompt into V0's AI chat:2// Build a public job board at app/jobs/page.tsx.3// Requirements:4// - Fetch all jobs with status='open' from Supabase5// - Search Input for job title search6// - Filter: Select for department, Select for type (all/full-time/part-time/contract/remote), Select for location7// - Grid of job Cards showing: title, department, location, type Badge, salary range, posted date8// - Each Card links to /jobs/[id] detail page9// - Detail page at app/jobs/[id]/page.tsx with full description, requirements list, salary range, and prominent "Apply Now" Button linking to /jobs/[id]/apply10// - Server Components for SEO optimization11// - Use shadcn/ui Card, Badge, Input, Select, SeparatorPro tip: Use V0's Git panel to connect to GitHub so your recruitment platform code is version-controlled and team devs can review changes via auto-created PRs.
Expected result: A public job board with searchable and filterable job Cards. Each job links to a detail page with full description and an Apply Now button.
Create the application form with resume upload
Build the application page where candidates fill in their details and upload a resume PDF to Supabase Storage. The resume goes to a private bucket with signed URLs for recruiter-only access.
1'use server'23import { createClient } from '@/lib/supabase/server'4import { redirect } from 'next/navigation'56export async function submitApplication(formData: FormData) {7 const supabase = await createClient()89 const email = formData.get('email') as string10 const resumeFile = formData.get('resume') as File1112 // Find or create candidate13 let { data: candidate } = await supabase14 .from('candidates')15 .select('id')16 .eq('email', email)17 .single()1819 if (!candidate) {20 const { data: newCandidate } = await supabase21 .from('candidates')22 .insert({23 first_name: formData.get('first_name') as string,24 last_name: formData.get('last_name') as string,25 email,26 phone: formData.get('phone') as string,27 linkedin_url: formData.get('linkedin') as string,28 })29 .select()30 .single()31 candidate = newCandidate32 }3334 // Upload resume to private bucket35 const filePath = `${candidate!.id}/${Date.now()}-${resumeFile.name}`36 await supabase.storage37 .from('resumes')38 .upload(filePath, resumeFile)3940 // Create application41 await supabase.from('applications').insert({42 job_id: formData.get('job_id') as string,43 candidate_id: candidate!.id,44 stage: 'applied',45 resume_url: filePath,46 cover_letter: formData.get('cover_letter') as string,47 })4849 redirect(`/jobs/${formData.get('job_id')}?applied=true`)50}Expected result: Candidates submit applications with resume PDF upload. Resumes are stored in a private Supabase Storage bucket. The application record stores the file path for signed URL generation.
Build the recruiter pipeline kanban dashboard
Create the recruiter-facing dashboard showing candidates organized by pipeline stage. Recruiters can move candidates between stages, view applications, and access resumes via signed URLs.
1// Paste this prompt into V0's AI chat:2// Build a recruiter pipeline dashboard at app/dashboard/page.tsx.3// Requirements:4// - Protected by auth (only company recruiters)5// - Fetch all applications for the company's jobs, grouped by stage6// - Display as kanban columns: Applied, Screening, Interview, Offer, Hired, Rejected7// - Each candidate Card shows: Avatar with initials, full name, job title applied for, applied date, rating average from interview_notes8// - Clicking a Card opens a Sheet (right sidebar) showing:9// - Candidate details: name, email, phone, LinkedIn link10// - Resume download Button (generates signed URL valid 1 hour)11// - Cover letter text12// - Interview notes list with interviewer name, rating (stars), notes text, date13// - "Add Note" form: Textarea for notes, Select for rating 1-5, Button to submit14// - Stage transition: Select for new stage, Button to move15// - Pipeline stage headers show candidate count16// - Use shadcn/ui Card, Avatar, Sheet, Badge, Select, Textarea, Progress for pipeline visualization17// - Server Actions: moveStage(), addInterviewNote(), generateResumeUrl()Expected result: A kanban pipeline dashboard with candidate Cards organized by hiring stage. Clicking a Card opens a Sheet with full details, resume download, interview notes, and stage controls.
Add secure resume access with signed URLs
Create a Server Action that generates time-limited signed URLs for resume downloads. This ensures resumes are only accessible to authenticated recruiters and links expire after 1 hour.
1'use server'23import { createClient } from '@/lib/supabase/server'45export async function generateResumeUrl(resumePath: string) {6 const supabase = await createClient()78 const { data: { user } } = await supabase.auth.getUser()9 if (!user) throw new Error('Unauthorized')1011 const { data, error } = await supabase.storage12 .from('resumes')13 .createSignedUrl(resumePath, 3600)1415 if (error) throw new Error('Failed to generate resume URL')1617 return data.signedUrl18}1920export async function moveStage(21 applicationId: string,22 newStage: string23) {24 const supabase = await createClient()2526 await supabase27 .from('applications')28 .update({29 stage: newStage,30 updated_at: new Date().toISOString(),31 })32 .eq('id', applicationId)33}3435export async function addInterviewNote(input: {36 application_id: string37 notes: string38 rating: number39}) {40 const supabase = await createClient()41 const { data: { user } } = await supabase.auth.getUser()4243 await supabase.from('interview_notes').insert({44 application_id: input.application_id,45 interviewer_id: user?.id,46 notes: input.notes,47 rating: input.rating,48 })49}Expected result: Recruiters can download resumes via signed URLs that expire after 1 hour. Stage transitions and interview notes are persisted via Server Actions.
Complete code
1'use server'23import { createClient } from '@/lib/supabase/server'4import { redirect } from 'next/navigation'5import { revalidatePath } from 'next/cache'67export async function submitApplication(formData: FormData) {8 const supabase = await createClient()9 const email = formData.get('email') as string10 const resumeFile = formData.get('resume') as File1112 let { data: candidate } = await supabase13 .from('candidates')14 .select('id')15 .eq('email', email)16 .single()1718 if (!candidate) {19 const { data: created } = await supabase20 .from('candidates')21 .insert({22 first_name: formData.get('first_name') as string,23 last_name: formData.get('last_name') as string,24 email,25 phone: formData.get('phone') as string,26 linkedin_url: formData.get('linkedin') as string,27 })28 .select()29 .single()30 candidate = created31 }3233 const filePath = `${candidate!.id}/${Date.now()}.pdf`34 await supabase.storage.from('resumes').upload(filePath, resumeFile)3536 await supabase.from('applications').insert({37 job_id: formData.get('job_id') as string,38 candidate_id: candidate!.id,39 stage: 'applied',40 resume_url: filePath,41 cover_letter: formData.get('cover_letter') as string,42 })4344 revalidatePath('/dashboard')45 redirect(`/jobs/${formData.get('job_id')}?applied=true`)46}Customization ideas
Add email notifications
Send automated emails to candidates when their application stage changes using Resend via Vercel Marketplace — confirmation on apply, updates on stage transitions.
Build interview scheduling
Integrate calendar availability so recruiters can send interview slots to candidates and sync with Google Calendar.
Add AI resume scoring
Use the OpenAI API to automatically score and summarize resumes against job requirements, highlighting matching skills and experience gaps.
Build analytics dashboard
Track hiring metrics like time-to-hire, stage conversion rates, and source effectiveness using Recharts visualizations.
Common pitfalls
Pitfall: Storing resumes in a public Supabase Storage bucket
How to avoid: Use a private bucket and generate signed URLs (valid 1 hour) for authenticated recruiter access only. Store only the file path in the applications table.
Pitfall: Not adding a unique constraint on candidates.email
How to avoid: Add a UNIQUE constraint on the email column. In the application Server Action, check for existing candidates by email before creating a new one.
Pitfall: Making the recruiter dashboard a public page
How to avoid: Protect the dashboard with auth middleware. Use RLS policies that restrict application and note access to company members only.
Best practices
- Use a private Supabase Storage bucket for resumes with signed URLs for time-limited access
- Use Server Components for the public job board to optimize SEO and page load speed
- Protect recruiter pages with authentication and RLS policies scoped to company membership
- Use V0's Git panel to version-control the platform and enable team code reviews via auto-PRs
- Store resume file paths (not signed URLs) in the database — generate fresh signed URLs on demand
- Use Server Actions for all mutations to keep the codebase simple — no API routes needed for this project
AI prompts to try
Copy these prompts to build this project faster.
I'm building a recruitment platform with Next.js App Router and Supabase. I need help designing the candidate pipeline kanban. Write a Server Component that fetches applications grouped by stage, and a 'use client' kanban component that displays candidates as Cards in stage columns. Include a Sheet component that opens on Card click showing candidate details, resume download button (signed URL), interview notes, and a stage transition Select.
Create a resume upload component. Use a drag-and-drop file zone that accepts PDF files only (max 5MB). Show upload Progress, filename, and file size. On form submit, upload to Supabase Storage private bucket 'resumes' with path format candidate_id/timestamp.pdf. Return the file path for storing in the applications table. Mark as 'use client'.
Frequently asked questions
What V0 plan do I need for a recruitment platform?
V0 Free works for the basic build, but Premium ($20/month) is recommended because the platform has both public (job board) and private (recruiter dashboard) pages that benefit from prompt queuing.
Are uploaded resumes secure?
Yes. Resumes are stored in a private Supabase Storage bucket. Only authenticated recruiters can access them via signed URLs that expire after 1 hour. The file URLs are never exposed to the public.
Can candidates apply without creating an account?
Yes. The application form is public and does not require login. Candidate records are created from the form data. If a candidate with the same email applies again, the existing record is reused.
How do I deploy the recruitment platform?
Click Share then Publish to Production in V0. Your job board pages are server-rendered for SEO. Supabase credentials are auto-configured from the Connect panel.
Can I customize the pipeline stages?
Yes. The pipeline_stages table allows each company to define custom stage names, colors, and ordering. The kanban dashboard dynamically reads from this table instead of using hardcoded stages.
Can RapidDev help build a custom recruitment platform?
Yes. RapidDev has built 600+ apps including recruitment platforms with AI resume parsing, interview scheduling, and analytics dashboards. Book a free consultation to discuss your hiring workflow.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation