
# 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) |
| Resend | |
| PDF Parsing | unpdf (WASM-based, works serverless) |
| Styling | Tailwind CSS + Framer Motion |
| State | Zustand |
How It Works
- Candidate uploads resume
- AI extracts skills, experience, headline, location
- Anonymous profile created (JN-2847, not their real name)
- Employers browse talent pool, see AI match scores
- Employer sends interview request with salary upfront
- 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 || '{}')
}
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))
}
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}%`
}
],
})
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
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 DEFINERfunction:
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());
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.`
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
- Start with email-only MVP — the full dashboard was overkill for validation
- Build in public earlier — the LinkedIn post got more users in 1 day than 2 weeks of building
- Test PDF parsing on Vercel from day 1 — would've saved hours of debugging
- 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)