# How to Build Resume parser with V0

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

## TL;DR

Build an AI-powered resume parser with V0 using Next.js, Supabase, and OpenAI structured output that extracts names, emails, experience, skills, and education from uploaded PDF resumes. Features drag-and-drop upload, confidence scoring, and editable extraction results — all in about 1-2 hours.

## Before you start

- A V0 account (Premium recommended for the extraction pipeline)
- A Supabase project (free tier works — connect via V0's Connect panel)
- An OpenAI API key (pay-as-you-go for structured output calls)
- Sample PDF resumes for testing

## Step-by-step guide

### 1. Set up the project and parser schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the schema for parsed resumes, extracted candidates, experiences, and education. Set up a private Storage bucket for resume files.

```
// Paste this prompt into V0's AI chat:
// Build a resume parser. Create a Supabase schema with:
// 1. parsed_resumes: id (uuid PK), uploader_id (uuid FK), original_file_url (text), original_filename (text), parsed_data (jsonb), confidence_score (numeric), status (text check uploading/parsing/completed/failed), error_message (text nullable), created_at (timestamptz)
// 2. extracted_candidates: id (uuid PK), parsed_resume_id (uuid FK unique), full_name (text), email (text), phone (text), location (text), summary (text), total_experience_years (numeric), skills (text[]), created_at (timestamptz)
// 3. extracted_experiences: id (uuid PK), candidate_id (uuid FK), company (text), title (text), start_date (text), end_date (text), description (text), position (integer)
// 4. extracted_education: id (uuid PK), candidate_id (uuid FK), institution (text), degree (text), field (text), year (text)
// Create a private Supabase Storage bucket 'resumes'.
// RLS: authenticated users can CRUD their own parsed_resumes.
// Generate SQL migration and TypeScript types.
```

**Expected result:** Supabase is connected with parser tables and a private resumes Storage bucket. RLS policies protect uploaded files and parsed data.

### 2. Build the upload interface with drag-and-drop

Create the upload page with a drag-and-drop zone that accepts PDF files. The upload flow stores the file in Supabase Storage and creates a parsed_resumes record with status 'uploading', then triggers extraction.

```
// Paste this prompt into V0's AI chat:
// Build a resume upload page at app/parser/page.tsx.
// Requirements:
// - Drag-and-drop upload zone that accepts PDF files only (max 5MB)
// - Visual feedback: dashed border on drag-over, file icon, "Drop your resume here" text
// - On file drop/select:
//   - Show filename and file size
//   - Show Progress bar during upload
//   - Upload to Supabase Storage 'resumes' private bucket
//   - Create parsed_resumes record with status 'uploading'
//   - Call /api/parser/extract to trigger AI extraction
//   - Redirect to /parser/[id] when extraction starts
// - Below the upload zone: Table of previously parsed resumes
//   - Columns: filename, status Badge (uploading=gray, parsing=yellow, completed=green, failed=red), confidence score, parsed date
//   - Each row links to /parser/[id]
// - Use shadcn/ui Card for upload zone, Progress, Badge, Table, Skeleton
// - 'use client' for drag-and-drop and file handling
```

**Expected result:** A drag-and-drop upload zone with progress indicator. Uploaded resumes appear in a Table below with status Badges and confidence scores.

### 3. Create the AI extraction API route

Build the extraction endpoint that reads the uploaded PDF, sends the text to OpenAI with a strict JSON schema, and stores the structured result. Uses OpenAI's structured output mode for guaranteed valid JSON.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

export const maxDuration = 60

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { resume_id, file_path } = await req.json()

  await supabase
    .from('parsed_resumes')
    .update({ status: 'parsing' })
    .eq('id', resume_id)

  const { data: fileData } = await supabase.storage
    .from('resumes')
    .download(file_path)

  if (!fileData) {
    await supabase.from('parsed_resumes').update({
      status: 'failed',
      error_message: 'File not found',
    }).eq('id', resume_id)
    return NextResponse.json({ error: 'File not found' }, { status: 404 })
  }

  const text = await fileData.text()

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [
        {
          role: 'system',
          content: 'Extract structured resume data from the following text. Be precise with dates and job titles.',
        },
        { role: 'user', content: text },
      ],
      response_format: {
        type: 'json_schema',
        json_schema: {
          name: 'resume_extraction',
          strict: true,
          schema: {
            type: 'object',
            properties: {
              full_name: { type: 'string' },
              email: { type: 'string' },
              phone: { type: 'string' },
              location: { type: 'string' },
              summary: { type: 'string' },
              total_experience_years: { type: 'number' },
              skills: { type: 'array', items: { type: 'string' } },
              experiences: {
                type: 'array',
                items: {
                  type: 'object',
                  properties: {
                    company: { type: 'string' },
                    title: { type: 'string' },
                    start_date: { type: 'string' },
                    end_date: { type: 'string' },
                    description: { type: 'string' },
                  },
                  required: ['company', 'title', 'start_date', 'end_date', 'description'],
                  additionalProperties: false,
                },
              },
              education: {
                type: 'array',
                items: {
                  type: 'object',
                  properties: {
                    institution: { type: 'string' },
                    degree: { type: 'string' },
                    field: { type: 'string' },
                    year: { type: 'string' },
                  },
                  required: ['institution', 'degree', 'field', 'year'],
                  additionalProperties: false,
                },
              },
            },
            required: ['full_name', 'email', 'phone', 'location', 'summary', 'total_experience_years', 'skills', 'experiences', 'education'],
            additionalProperties: false,
          },
        },
      },
    }),
  })

  const result = await response.json()
  const parsed = JSON.parse(result.choices[0].message.content)

  const { data: candidate } = await supabase
    .from('extracted_candidates')
    .insert({
      parsed_resume_id: resume_id,
      full_name: parsed.full_name,
      email: parsed.email,
      phone: parsed.phone,
      location: parsed.location,
      summary: parsed.summary,
      total_experience_years: parsed.total_experience_years,
      skills: parsed.skills,
    })
    .select()
    .single()

  if (candidate && parsed.experiences) {
    await supabase.from('extracted_experiences').insert(
      parsed.experiences.map((exp: any, i: number) => ({
        candidate_id: candidate.id,
        ...exp,
        position: i + 1,
      }))
    )
  }

  if (candidate && parsed.education) {
    await supabase.from('extracted_education').insert(
      parsed.education.map((edu: any) => ({
        candidate_id: candidate.id,
        ...edu,
      }))
    )
  }

  await supabase.from('parsed_resumes').update({
    status: 'completed',
    parsed_data: parsed,
    confidence_score: 0.85,
  }).eq('id', resume_id)

  return NextResponse.json({ success: true, candidate_id: candidate?.id })
}
```

**Expected result:** The extraction API reads the uploaded PDF, sends text to OpenAI with structured output schema, and stores extracted data in normalized tables.

### 4. Build the parsed result viewer with editable fields

Create the result page showing extracted data with editable fields for manual corrections. Each field has a confidence indicator, and users can fix any extraction errors before finalizing.

```
// Paste this prompt into V0's AI chat:
// Build a parsed result page at app/parser/[id]/page.tsx.
// Requirements:
// - Fetch the parsed_resume with extracted_candidates, experiences, and education
// - If status='parsing', show Skeleton loading with "AI is extracting data..." message
// - If status='completed', show editable result:
//   - Personal Info Card: Input fields for name, email, phone, location (pre-filled with extracted data)
//   - Summary Textarea (pre-filled)
//   - Skills: Badge list with X to remove, Input to add new skills
//   - Experiences: Cards for each with editable company, title, dates, description Inputs
//   - Education: Cards for each with editable institution, degree, field, year
//   - Badge next to each field showing confidence (high=green, medium=yellow, low=red)
// - "Save Corrections" Button calls Server Action updateParsedField()
// - "Export JSON" Button downloads the extracted data as a JSON file
// - If status='failed', show error message with AlertDialog to retry
// - Use shadcn/ui Card, Input, Badge, Textarea, Separator, Skeleton, Toast
```

> Pro tip: Use V0's Vars tab for storing OPENAI_API_KEY without NEXT_PUBLIC_ prefix — it is a secret key for server-side extraction only.

**Expected result:** A result page showing extracted resume data with editable fields, confidence Badges, and save/export functionality. AI-extracted values are pre-filled and ready for human review.

## Complete code example

File: `app/api/parser/extract/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

export const maxDuration = 60

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { resume_id, file_path } = await req.json()

  await supabase
    .from('parsed_resumes')
    .update({ status: 'parsing' })
    .eq('id', resume_id)

  const { data: file } = await supabase.storage
    .from('resumes')
    .download(file_path)

  if (!file) {
    await supabase.from('parsed_resumes').update({
      status: 'failed',
      error_message: 'File download failed',
    }).eq('id', resume_id)
    return NextResponse.json({ error: 'File not found' }, { status: 404 })
  }

  const text = await file.text()

  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [
        { role: 'system', content: 'Extract structured data from this resume text.' },
        { role: 'user', content: text },
      ],
      response_format: {
        type: 'json_schema',
        json_schema: {
          name: 'resume',
          strict: true,
          schema: {
            type: 'object',
            properties: {
              full_name: { type: 'string' },
              email: { type: 'string' },
              phone: { type: 'string' },
              skills: { type: 'array', items: { type: 'string' } },
            },
            required: ['full_name', 'email', 'phone', 'skills'],
            additionalProperties: false,
          },
        },
      },
    }),
  })

  const data = await res.json()
  const parsed = JSON.parse(data.choices[0].message.content)

  await supabase.from('parsed_resumes').update({
    status: 'completed',
    parsed_data: parsed,
  }).eq('id', resume_id)

  return NextResponse.json({ success: true })
}
```

## Common mistakes

- **Using regex to parse resume text instead of AI structured output** — Resumes have wildly inconsistent formatting. Regex patterns break on every new resume layout, requiring constant maintenance. Fix: Use OpenAI's structured output mode (response_format: json_schema) with a strict schema. This guarantees valid JSON matching your expected structure regardless of the resume format.
- **Not setting maxDuration for the extraction API route** — AI extraction involves file download, text processing, and an OpenAI API call — this chain easily exceeds the default 10-second timeout. Fix: Set export const maxDuration = 60 in the extract API route to allow sufficient time for the full extraction pipeline.
- **Exposing OPENAI_API_KEY with NEXT_PUBLIC_ prefix** — The OpenAI API key is billed per call. Exposing it client-side allows anyone to make expensive API calls on your account. Fix: Store OPENAI_API_KEY in the Vars tab without any prefix. Only call OpenAI from API routes (server-side), never from client components.

## Best practices

- Use OpenAI structured output (json_schema) for guaranteed valid JSON extraction results
- Set maxDuration = 60 in the extraction API route for the full file + AI processing pipeline
- Store OPENAI_API_KEY in Vars tab without NEXT_PUBLIC_ prefix — server-only for extraction
- Store uploaded resumes in a Supabase Storage private bucket with signed URLs for authenticated access only
- Add a human review step with editable fields so extracted data can be corrected before use
- Use V0's Vars tab for all API keys alongside Supabase credentials from the Connect panel

## Frequently asked questions

### What V0 plan do I need for a resume parser?

V0 Free works for the basic build, but Premium ($20/month) is recommended for prompt queuing to generate the upload interface, extraction pipeline, and result viewer more efficiently.

### How accurate is the AI extraction?

OpenAI structured output with gpt-4o achieves high accuracy for standard resume formats — names, emails, and dates are extracted reliably. Complex layouts or unusual formatting may need the human review step for corrections.

### How much does OpenAI extraction cost per resume?

A typical resume has 500-1000 tokens of text. With gpt-4o at $2.50/$10 per million tokens input/output, parsing costs roughly $0.01-0.03 per resume.

### Can it parse DOCX files as well as PDFs?

The base build handles PDF text extraction. For DOCX support, add a server-side DOCX parser library (like mammoth) to extract text before sending to OpenAI.

### How do I deploy the resume parser?

Click Share then Publish to Production in V0. Set OPENAI_API_KEY and SUPABASE_SERVICE_ROLE_KEY in the Vars tab without NEXT_PUBLIC_ prefix. File uploads and AI extraction run entirely server-side.

### Can RapidDev help build a custom resume parsing system?

Yes. RapidDev has built 600+ apps including HR platforms with AI-powered resume parsing, skill matching, and candidate scoring. Book a free consultation to discuss your specific requirements.

---

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