DEV Community

JobNex
JobNex

Posted on

How I Built an AI-Powered Reverse Hiring Platform with Next.js, Supabase, and OpenAI


 # How I Built an AI-Powered Reverse Hiring Platform with Next.js, Supabase, and OpenAI

The hiring process is fundamentally broken.

Candidates send hundreds of applications into a void of automated rejections. Companies pay €25,000 per hire to recruiting agencies who copy-paste LinkedIn profiles into emails. Everyone works harder, nobody gets better results.

So I asked: what if we flipped the entire model?

What if companies had to find candidates — not the other way around? What if salary was disclosed before the first conversation? What if candidates stayed anonymous until they chose to engage?

That's what I built. Here's how.


The Stack

Layer Technology
Framework Next.js 15 (App Router)
Database + Auth Supabase (PostgreSQL + RLS + Auth)
AI OpenAI GPT-4o-mini + text-embedding-3-small
Hosting Vercel (serverless)
Email Resend
PDF Parsing unpdf (WASM-based, works serverless)
Styling Tailwind CSS + Framer Motion
State Zustand

How It Works

  1. Candidate uploads resume
  2. AI extracts skills, experience, headline, location
  3. Anonymous profile created (JN-2847, not their real name)
  4. Employers browse talent pool, see AI match scores
  5. Employer sends interview request with salary upfront
  6. Candidate accepts or declines — identity revealed only on acceptance

The AI Matching Pipeline

This is the most interesting part technically. Here's how match scoring works:

Step 1: Resume Parsing

export async function parseResume(text: string) {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    temperature: 0.2,
    response_format: { type: 'json_object' },
    messages: [
      {
        role: 'system',
        content: `You extract structured profile data from resumes. 
        Return JSON with: headline (actual job title from resume, max 80 chars), 
        bio (2-3 sentence summary preserving substance, anonymous), 
        skills (array, max 15), experience_years (integer), 
        industry, location. 
        Important: Do NOT genericize. A "Senior Engineering Manager" 
        should NOT become "Software Engineer".`
      },
      { role: 'user', content: text }
    ],
  })
  return JSON.parse(response.choices[0].message.content || '{}')
}
Enter fullscreen mode Exit fullscreen mode

Key lesson: the prompt must explicitly say "do not genericize." Without that instruction, GPT-4o-mini tends to normalize every title to "Software Engineer" and writes generic bios.

Step 2: Embeddings for Matching

export async function generateEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  })
  return response.data[0].embedding
}

export function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0, magA = 0, magB = 0
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i]
    magA += a[i] * a[i]
    magB += b[i] * b[i]
  }
  return dot / (Math.sqrt(magA) * Math.sqrt(magB))
}
Enter fullscreen mode Exit fullscreen mode

The candidate's profile text and the company's hiring needs are both converted to embeddings, then compared via cosine similarity. A 60%+ score is a "Good Match" in embedding space (it rarely goes above 90% even for near-identical texts).

Step 3: Human-Readable Reasons

After getting the similarity score, GPT generates 3-5 short reasons why they match:

const response = await openai.chat.completions.create({
  model: 'gpt-4o-mini',
  messages: [
    {
      role: 'system',
      content: `Given a candidate and company, return JSON with "reasons": 
      array of 3-5 short reasons (max 8 words each) why they match.`
    },
    {
      role: 'user', 
      content: `Candidate: ${candidateProfile}\nCompany: ${companyNeeds}\nScore: ${score}%`
    }
  ],
})
Enter fullscreen mode Exit fullscreen mode

This gives employers actionable context: "Skills aligned", "Salary fit", "Remote compatible" — not just a number.


The PDF Problem on Serverless

This was my biggest headache. pdf-parse (the standard Node.js PDF library) does not work on Vercel serverless. It depends on a test file that Vercel strips during deployment.

What happens: the PDF buffer arrives fine, but pdf-parse silently fails. The fallback reads raw binary as text, sends garbled data to GPT, and GPT hallucinates a completely made-up profile.

The fix: I switched to unpdf — a WASM-based PDF text extraction library specifically designed for serverless environments:

import { extractText } from 'unpdf'

const buffer = Buffer.from(await file.arrayBuffer())
const { text } = await extractText(new Uint8Array(buffer))
const extracted = Array.isArray(text) ? text.join('\n') : text
Enter fullscreen mode Exit fullscreen mode

Works perfectly on Vercel. Lesson: always test your PDF parsing on the actual deployment target, not just locally.


Row-Level Security (RLS) Design

Supabase RLS is powerful but tricky with multi-role apps. My setup:

  • Candidates can only read/write their own profile
  • Employers can read candidate profiles (anonymized) but not modify them
  • Admins can read everything via a SECURITY DEFINER function:
CREATE OR REPLACE FUNCTION is_admin()
RETURNS boolean AS $$
  SELECT EXISTS (
    SELECT 1 FROM profiles 
    WHERE user_id = auth.uid() AND role = 'admin'
  );
$$ LANGUAGE sql SECURITY DEFINER;

CREATE POLICY "Admins can read all profiles" ON profiles
  FOR SELECT USING (auth.uid() = user_id OR is_admin());
Enter fullscreen mode Exit fullscreen mode

The SECURITY DEFINER prevents infinite recursion — without it, the policy checks itself while checking itself.


Market Value Estimation (Location-Aware)

The AI estimates salary based on candidate's location:

content: `You are a compensation analyst. Given a professional profile, 
estimate their market value based on their location. Return JSON with: 
estimated_salary (integer, annual in local currency), 
currency (3-letter code), demand_score (0-100), reasoning (one sentence). 
Adjust for country/city cost of living and local market rates. 
A senior engineer in San Francisco earns more than one in Helsinki.`
Enter fullscreen mode Exit fullscreen mode

The currency is then mapped on the frontend based on their country selection — so US candidates see "$150,000" and Finnish candidates see "€130,000".


Email Notifications That Drive Retention

The single most impactful feature for engagement: email notifications via Resend.

Every meaningful action triggers an email:

  • New match found → candidate gets notified
  • Interview request → candidate gets email with company name
  • Candidate accepts → employer gets email
  • New message → both parties notified

Without these, users sign up and forget. With them, they come back.


What I'd Do Differently

  1. Start with email-only MVP — the full dashboard was overkill for validation
  2. Build in public earlier — the LinkedIn post got more users in 1 day than 2 weeks of building
  3. Test PDF parsing on Vercel from day 1 — would've saved hours of debugging
  4. Cookie consent from the start — retrofitting GDPR is annoying

Results So Far

  • Built and launched in ~3 weeks (solo)
  • Live at jobnex.io
  • Real candidates and employers signing up
  • First AI matches being made
  • Total monthly cost: ~$30 (Vercel + OpenAI + domain)

Try It

If you're a developer tired of the application grind: jobnex.io — free during beta, 2 minutes to set up.

If you're hiring: browse the talent pool directly.

Happy to answer questions about the technical implementation in the comments.


Tags: #nextjs #ai #startup #webdev #supabase

Top comments (0)