
# 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 (2)
I kept chewing on the anonymous-until-accept design after reading. Row Level Security decides which rows an employer can read, but it can't hide columns. So if employer queries hit the same profiles table that stores the real name and contact info, anonymity depends entirely on whatever strips those fields out. Is JN-2847 read from a separate view or table, or does the app remove identity fields after the select? I ask because the app-layer version burned me once: my UI never showed emails, but the underlying view returned them to anyone holding the anon key.
Great catch. You're right to flag this — anonymity currently relies on the app layer not selecting identity fields, which is exactly the pattern you got burned by. The profiles table (where names/emails live) is separate from candidates, and RLS on profiles restricts reads to the owning user. But profile_id is exposed on the candidates table, and that's a valid concern if someone bypasses the UI with the public anon key.
I'm adding a Postgres VIEW that only exposes anonymous fields to employer-role sessions, and switching employer queries off select('*').
Appreciate the sharp eye — this is the kind of feedback that makes the product better.