# 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.
| 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 |
This is the most interesting part technically. Here's how match scoring works:
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.
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).
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.
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.
Supabase RLS is powerful but tricky with multi-role apps. My setup:
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());
The SECURITY DEFINER
prevents infinite recursion — without it, the policy checks itself while checking itself.
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".
The single most impactful feature for engagement: email notifications via Resend.
Every meaningful action triggers an email:
Without these, users sign up and forget. With them, they come back.
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