# How to Build Recruitment platform with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
// Paste this prompt into V0's AI chat:
// Build a recruitment platform. Create a Supabase schema with:
// 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)
// 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)
// 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)
// 4. interview_notes: id (uuid PK), application_id (uuid FK), interviewer_id (uuid FK), notes (text), rating (integer check 1-5), created_at (timestamptz)
// 5. pipeline_stages: id (uuid PK), company_id (uuid FK), name (text), position (integer), color (text)
// Create a private Supabase Storage bucket 'resumes' for resume files.
// RLS: public can read open jobs and submit applications. Only company members can read applications and notes.
// 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.

### 2. 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.

```
// Paste this prompt into V0's AI chat:
// Build a public job board at app/jobs/page.tsx.
// Requirements:
// - Fetch all jobs with status='open' from Supabase
// - Search Input for job title search
// - Filter: Select for department, Select for type (all/full-time/part-time/contract/remote), Select for location
// - Grid of job Cards showing: title, department, location, type Badge, salary range, posted date
// - Each Card links to /jobs/[id] detail page
// - Detail page at app/jobs/[id]/page.tsx with full description, requirements list, salary range, and prominent "Apply Now" Button linking to /jobs/[id]/apply
// - Server Components for SEO optimization
// - Use shadcn/ui Card, Badge, Input, Select, Separator
```

> Pro 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.

### 3. 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.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export async function submitApplication(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const resumeFile = formData.get('resume') as File

  // Find or create candidate
  let { data: candidate } = await supabase
    .from('candidates')
    .select('id')
    .eq('email', email)
    .single()

  if (!candidate) {
    const { data: newCandidate } = await supabase
      .from('candidates')
      .insert({
        first_name: formData.get('first_name') as string,
        last_name: formData.get('last_name') as string,
        email,
        phone: formData.get('phone') as string,
        linkedin_url: formData.get('linkedin') as string,
      })
      .select()
      .single()
    candidate = newCandidate
  }

  // Upload resume to private bucket
  const filePath = `${candidate!.id}/${Date.now()}-${resumeFile.name}`
  await supabase.storage
    .from('resumes')
    .upload(filePath, resumeFile)

  // Create application
  await supabase.from('applications').insert({
    job_id: formData.get('job_id') as string,
    candidate_id: candidate!.id,
    stage: 'applied',
    resume_url: filePath,
    cover_letter: formData.get('cover_letter') as string,
  })

  redirect(`/jobs/${formData.get('job_id')}?applied=true`)
}
```

**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.

### 4. 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.

```
// Paste this prompt into V0's AI chat:
// Build a recruiter pipeline dashboard at app/dashboard/page.tsx.
// Requirements:
// - Protected by auth (only company recruiters)
// - Fetch all applications for the company's jobs, grouped by stage
// - Display as kanban columns: Applied, Screening, Interview, Offer, Hired, Rejected
// - Each candidate Card shows: Avatar with initials, full name, job title applied for, applied date, rating average from interview_notes
// - Clicking a Card opens a Sheet (right sidebar) showing:
//   - Candidate details: name, email, phone, LinkedIn link
//   - Resume download Button (generates signed URL valid 1 hour)
//   - Cover letter text
//   - Interview notes list with interviewer name, rating (stars), notes text, date
//   - "Add Note" form: Textarea for notes, Select for rating 1-5, Button to submit
//   - Stage transition: Select for new stage, Button to move
// - Pipeline stage headers show candidate count
// - Use shadcn/ui Card, Avatar, Sheet, Badge, Select, Textarea, Progress for pipeline visualization
// - 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.

### 5. 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.

```
'use server'

import { createClient } from '@/lib/supabase/server'

export async function generateResumeUrl(resumePath: string) {
  const supabase = await createClient()

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Unauthorized')

  const { data, error } = await supabase.storage
    .from('resumes')
    .createSignedUrl(resumePath, 3600)

  if (error) throw new Error('Failed to generate resume URL')

  return data.signedUrl
}

export async function moveStage(
  applicationId: string,
  newStage: string
) {
  const supabase = await createClient()

  await supabase
    .from('applications')
    .update({
      stage: newStage,
      updated_at: new Date().toISOString(),
    })
    .eq('id', applicationId)
}

export async function addInterviewNote(input: {
  application_id: string
  notes: string
  rating: number
}) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  await supabase.from('interview_notes').insert({
    application_id: input.application_id,
    interviewer_id: user?.id,
    notes: input.notes,
    rating: input.rating,
  })
}
```

**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 example

File: `app/actions/applications.ts`

```typescript
'use server'

import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function submitApplication(formData: FormData) {
  const supabase = await createClient()
  const email = formData.get('email') as string
  const resumeFile = formData.get('resume') as File

  let { data: candidate } = await supabase
    .from('candidates')
    .select('id')
    .eq('email', email)
    .single()

  if (!candidate) {
    const { data: created } = await supabase
      .from('candidates')
      .insert({
        first_name: formData.get('first_name') as string,
        last_name: formData.get('last_name') as string,
        email,
        phone: formData.get('phone') as string,
        linkedin_url: formData.get('linkedin') as string,
      })
      .select()
      .single()
    candidate = created
  }

  const filePath = `${candidate!.id}/${Date.now()}.pdf`
  await supabase.storage.from('resumes').upload(filePath, resumeFile)

  await supabase.from('applications').insert({
    job_id: formData.get('job_id') as string,
    candidate_id: candidate!.id,
    stage: 'applied',
    resume_url: filePath,
    cover_letter: formData.get('cover_letter') as string,
  })

  revalidatePath('/dashboard')
  redirect(`/jobs/${formData.get('job_id')}?applied=true`)
}
```

## Common mistakes

- **Storing resumes in a public Supabase Storage bucket** — Resumes contain personal information (address, phone, work history). A public bucket makes them accessible to anyone with the URL. Fix: 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.
- **Not adding a unique constraint on candidates.email** — Without uniqueness, the same person applying to multiple jobs creates duplicate candidate records, making the pipeline confusing. Fix: Add a UNIQUE constraint on the email column. In the application Server Action, check for existing candidates by email before creating a new one.
- **Making the recruiter dashboard a public page** — Without authentication, anyone can access candidate data, resumes, and interview notes — a serious privacy violation. Fix: 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

## 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.

---

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