Screen hundreds of resumes in seconds using AI. HireIQ scores candidates, identifies skill gaps, generates interview questions, and tracks candidate pipelines — so you can focus on interviewing the best talent.
Built with Next.js 16, Firebase, Google Gemini AI, and Tailwind CSS v4.
- Instant AI Scoring — Upload a job description and candidate resumes. HireIQ returns a 0–100 match score with breakdown across technical skills, experience, education, and role match.
- Skill Gap Analysis — See exactly which required skills a candidate has and which are missing.
- AI Interview Questions — Auto-generated technical, behavioral, and role-specific questions with difficulty levels.
- Batch Uploads — Upload multiple PDF resumes at once for parallel screening.
- Candidate Pipeline — Track candidates through applied → screened → shortlisted → interview → hired/rejected.
- Recommendation Filters — Sort candidates by Strong Fit, Possible Fit, or Not a Fit.
- Privacy-First — PII (names, emails, phones, addresses, age/gender) is removed client-side before AI processing. Configurable auto-delete of old resumes.
- Analytics Dashboard — Score distribution, skills frequency, and monthly screening trends.
- CSV Export — Download candidate results as CSV with all columns including status and score breakdown.
- Plan-based Quotas — Free tier (20 screens/month) and Pro tier (unlimited, ₹999/month) via Razorpay.
- Dark/Light Mode — Class-based theming via
next-themes(light default).
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ │ Vercel CDN │ │ UploadThing │
│ (Next.js) │◄──►│ (Edge Cache) │ │ (File Host) │
└──────┬───────┘ └──────────────┘ └──────▲───────┘
│ │
│ Firebase Auth │ Upload files
▼ │
┌───────────────────────────────────────────────────┐
│ Next.js App Router │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
│ │ Dashboard │ │Analytics │ │ API Routes │ │
│ │ Pages │ │Page │ │ /screen │ │
│ │ │ │ │ │ /candidates/* │ │
│ │Privacy │ │Billing │ │ /cron/cleanup │ │
│ │Page │ │Page │ │ /razorpay/* │ │
│ └──────────┘ └──────────┘ │ /uploadthing/* │ │
│ └─────────┬───────┘ │
└───────────────────────────────────────────┼─────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Firebase │ │ Google │ │ Upstash │ │ Razorpay │
│ Firestore│ │ Gemini AI│ │ Redis │ │ Payment │
│ (DB) │ │ (Screening)│ │(RateLimit)│ │ Gateway │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
Upload PDF → UploadThing (hosts file)
↓
Extract text via pdf.js (client-side)
↓
Anonymize PII (client-side, before sending to AI)
↓
POST /api/screen → verifyAuth (Firebase Admin)
↓
Rate limit check (Upstash Redis / in-memory fallback)
↓
Monthly quota check (Firestore usage doc)
↓
Google Gemini AI → structured JSON response
↓
Save candidate doc + screening_results subcollection + audit log
↓
Increment usage counters (Firestore transaction)
↓
Return result to client
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) + React 19 + TypeScript |
| Styling | Tailwind CSS v4 (@theme inline) |
| Auth | Firebase Authentication (email/password + Google) |
| Database | Cloud Firestore |
| AI | Google Gemini API (gemini-2.0-flash default) |
| Rate Limiting | Upstash Redis (in-memory fallback) |
| Payments | Razorpay (₹999/month Pro) |
| File Upload | UploadThing (PDF, max 8MB) |
| PDF Parsing | pdf.js (local worker, pinned version) |
| Testing | Vitest (unit) + Playwright (E2E) |
| Fonts | Plus Jakarta Sans, JetBrains Mono |
| Icons | Lucide React |
| Deployment | Vercel |
| Field | Type | Description |
|---|---|---|
email |
string | User's email address |
name |
string? | Display name |
role |
string | "owner" | "admin" | "member" |
companyId |
string? | References companies/{companyId} |
plan |
string | "free" | "pro" |
screensUsed |
number | Lifetime screens counter |
screensLimit |
number | Max screens allowed |
createdAt |
timestamp | Account creation time |
| Field | Type | Description |
|---|---|---|
name |
string | Company name |
ownerId |
string | References users/{userId} |
createdAt |
timestamp | Company creation time |
| Field | Type | Description |
|---|---|---|
title |
string | Job title |
description |
string | Full job description text |
createdBy |
string | References users/{userId} |
companyId |
string | References companies/{companyId} |
createdAt |
timestamp | Job creation time |
| Field | Type | Description |
|---|---|---|
jobId |
string | References jobs/{jobId} |
name |
string | Candidate name (from resume) |
resumeUrl |
string? | UploadThing URL |
score |
number (0-100) | Overall match score |
scoreBreakdown |
map | { technicalSkills, experience, education, roleMatch } |
matchedSkills |
string[] | Skills matching the job |
missingSkills |
string[] | Required skills the candidate lacks |
summary |
string | 2-sentence AI-generated summary |
recommendation |
string | "strong_fit" | "possible_fit" | "not_fit" |
interviewQuestions |
array | [{ question, type, difficulty }] |
candidateStatus |
string | "applied" | "screened" | "shortlisted" | "interview" | "rejected" | "hired" |
createdBy |
string | References users/{userId} |
companyId |
string | References companies/{companyId} |
processedAt |
timestamp | Screening completion time |
| Field | Type | Description |
|---|---|---|
candidateId |
string | References candidates/{candidateId} |
score |
number (0-100) | Overall match score |
scoreBreakdown |
map | { technicalSkills, experience, education, roleMatch } |
matchedSkills |
string[] | Skills matching the job |
missingSkills |
string[] | Required skills the candidate lacks |
summary |
string | 2-sentence AI-generated summary |
recommendation |
string | "strong_fit" | "possible_fit" | "not_fit" |
interviewQuestions |
array | [{ question, type, difficulty }] |
createdAt |
timestamp | Result creation time |
| Field | Type | Description |
|---|---|---|
userId |
string | References users/{userId} |
razorpaySubscriptionId |
string | Razorpay subscription ID |
razorpayPaymentId |
string? | Razorpay payment ID |
status |
string | "pending" | "success" | "failed" | "expired" |
plan |
string | "free" | "pro" |
expiryDate |
timestamp | Subscription expiry |
createdAt |
timestamp | Subscription creation time |
| Field | Type | Description |
|---|---|---|
userId |
string | User who performed the action |
action |
string | Action identifier (e.g. "screen_resume", "update_candidate_status", "cron_cleanup") |
details |
string? | Human-readable description |
timestamp |
timestamp | When the action occurred |
| Field | Type | Description |
|---|---|---|
userId |
string | References users/{userId} |
month |
string | "YYYY-MM" format |
screeningsUsed |
number | Screens used this month |
updatedAt |
timestamp | Last update time |
| Field | Type | Description |
|---|---|---|
autoDelete |
boolean | Auto-delete old resumes |
retentionDays |
number | Days to retain (default: 90) |
All API routes require authentication via Authorization: Bearer <Firebase ID Token> header unless noted.
Screen a resume against a job description.
Request Body:
{
"resumeText": "string (extracted PDF text)",
"jobDescription": "string",
"jobId": "string",
"candidateName": "string (optional)",
"resumeUrl": "string (optional)"
}Response 200:
{
"success": true,
"result": {
"score": 85,
"matchedSkills": ["JavaScript", "React"],
"missingSkills": ["TypeScript"],
"summary": "2-sentence summary",
"recommendation": "strong_fit",
"scoreBreakdown": {
"technicalSkills": 32,
"experience": 25,
"education": 14,
"roleMatch": 14
},
"interviewQuestions": [
{ "question": "...", "type": "technical", "difficulty": "intermediate" }
],
"removedPII": ["email", "phone"]
}
}Errors: 400 (missing fields), 401 (auth), 404 (user not found), 429 (rate limited), 403 (quota exceeded), 500 (AI/DB error)
Rate Limits: 60 requests/minute per user. Monthly quota: 20 (free), unlimited (pro).
Update a candidate's pipeline status.
Request Body:
{
"candidateId": "string",
"status": "applied | screened | shortlisted | interview | rejected | hired"
}Response 200: { "success": true }
Errors: 400 (missing/invalid status), 401 (auth), 403 (not owner), 404 (candidate not found)
Create a Razorpay Pro subscription (₹999/month).
Request Body:
{
"email": "string (optional, defaults to auth email)"
}Response 200: { "url": "razorpay checkout short_url" }
Errors: 400 (email required), 401 (auth), 500 (Razorpay config/API error)
Razorpay webhook handler for subscription lifecycle events.
Auth: Validates x-razorpay-signature using RAZORPAY_WEBHOOK_SECRET.
Events handled: payment.captured, subscription.authenticated, subscription.activated, subscription.charged, subscription.completed, subscription.pending, subscription.halted, subscription.paused
UploadThing file upload route. See UploadThing docs.
Constraints: PDF only, max 8MB.
Auth: Bearer token verified in middleware before upload starts.
Daily cron job to delete candidates older than the retention period.
Auth: Authorization: Bearer <CRON_SECRET> (custom secret, not Firebase)
Response: { "deleted": 5, "retentionDays": 90, "message": "..." }
Configure in vercel.json:
{
"crons": [
{
"path": "/api/cron/cleanup",
"schedule": "0 0 * * *"
}
]
}| Variable | Required | Default | Description |
|---|---|---|---|
NEXT_PUBLIC_FIREBASE_API_KEY |
Yes | — | Firebase Web API key |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN |
Yes | — | Firebase Auth domain |
NEXT_PUBLIC_FIREBASE_PROJECT_ID |
Yes | — | Firebase project ID |
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET |
Yes | — | Firebase storage bucket |
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID |
Yes | — | Firebase sender ID |
NEXT_PUBLIC_FIREBASE_APP_ID |
Yes | — | Firebase app ID |
FIREBASE_CLIENT_EMAIL |
Yes | — | Firebase Admin service account email |
FIREBASE_PRIVATE_KEY |
Yes | — | Firebase Admin private key |
FIREBASE_PROJECT_ID |
Yes | — | Firebase Admin project ID |
GEMINI_API_KEY |
Yes | — | Google Gemini API key |
GEMINI_MODEL |
No | gemini-2.0-flash |
Gemini model name |
UPLOADTHING_TOKEN |
Yes | — | UploadThing API token |
RAZORPAY_KEY_ID |
No | — | Razorpay API key ID |
RAZORPAY_KEY_SECRET |
No | — | Razorpay API secret |
RAZORPAY_WEBHOOK_SECRET |
No | — | Razorpay webhook signature secret |
NEXT_PUBLIC_APP_URL |
No | http://localhost:3000 |
Public app URL for redirects |
UPSTASH_REDIS_REST_URL |
No | — | Upstash Redis REST URL (rate limiting) |
UPSTASH_REDIS_REST_TOKEN |
No | — | Upstash Redis REST token |
CRON_SECRET |
No | — | Secret for cron endpoint auth |
RESUME_RETENTION_DAYS |
No | 90 |
Days before auto-deleting candidates |
Note: Upstash and Razorpay variables are optional. When absent, rate limiting falls back to in-memory, and billing features are disabled.
- Node.js 20+
- npm
- Firebase project (with Auth + Firestore enabled)
- Google Gemini API key
- UploadThing account
- Razorpay account (optional, for payment processing)
git clone <https://github.com/Hackbits/HireIQ.git>
cd resume-screener
npm installCopy .env.example to .env.local and fill in your values:
cp .env.example .env.localThen start the development server:
npm run devOpen http://localhost:3000.
npm run build
npm start- Push to a GitHub repository.
- Go to vercel.com and import the repo.
- Set the Framework to Next.js.
- Add all environment variables from the table above in the Vercel dashboard.
- Deploy.
The FIREBASE_PRIVATE_KEY must use actual newlines. In Vercel, paste the key as-is (with \n characters) — Vercel handles the escaping. Do not wrap it in quotes.
In your Vercel project:
- Go to Settings → Cron Jobs.
- Add a cron job with path
/api/cron/cleanup, schedule0 0 * * *(daily midnight). - Set the
CRON_SECRETenvironment variable. - The cron sends
Authorization: Bearer <CRON_SECRET>automatically.
- In the Razorpay dashboard, go to Settings → Webhooks.
- Add a webhook URL:
https://your-domain.vercel.app/api/razorpay/webhook - Subscribe to events:
payment.captured,subscription.* - Set the webhook secret and add it as
RAZORPAY_WEBHOOK_SECRETin Vercel.
Deploy the provided firestore.rules:
firebase deploy --only firestore:rules- Firebase ID tokens are verified server-side via Firebase Admin SDK in every API route.
- The user's
uidis always derived from the verified token, never trusted from the request body. - UploadThing middleware verifies Firebase ID tokens before accepting uploads.
- Production: Upstash Redis sliding window (60 requests/minute/user).
- Fallback: In-memory rate limiter when Upstash env vars are absent.
- Returns
Retry-Afterheader andX-RateLimit-Remainingon 429 responses.
- PII is removed client-side before resume text is sent to the Gemini API.
- Removed fields: emails, phone numbers, street addresses, age/gender indicators, candidate names.
- The API response includes a
removedPIIarray listing which fields were stripped. - Old resumes are auto-deleted via daily cron (configurable retention, default 90 days).
- Company-scoped access: users can only read candidates within their company.
- Writes are limited to document owners or company members.
- Audit logs are append-only (no updates/deletes allowed in rules).
resume-screener/
├── app/
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── signup/page.tsx # Creates company doc on signup
│ ├── api/
│ │ ├── candidates/status/route.ts # Candidate pipeline status
│ │ ├── cron/cleanup/route.ts # Privacy auto-delete cron
│ │ ├── razorpay/
│ │ │ ├── create-subscription/route.ts
│ │ │ └── webhook/route.ts
│ │ ├── screen/route.ts # AI screening (auth, rate-limit, quota)
│ │ └── uploadthing/{core,route}.ts # File upload with auth
│ ├── billing/page.tsx
│ ├── dashboard/
│ │ ├── [jobId]/page.tsx # Candidate results (search, filter, CSV)
│ │ ├── layout.tsx
│ │ └── page.tsx # Job listing dashboard
│ ├── privacy/page.tsx # Privacy settings (retention, export)
│ ├── profile/page.tsx
│ ├── globals.css # Design tokens & utilities
│ ├── layout.tsx
│ └── page.tsx # Landing page
├── components/
│ ├── ui/ # shadcn-style primitives
│ │ ├── badge.tsx, button.tsx, card.tsx, input.tsx, skeleton.tsx
│ │ └── __tests__/
│ ├── CandidateCard.tsx # Score ring, status, interview prep
│ ├── CompareView.tsx # Side-by-side comparison
│ ├── Navbar.tsx
│ ├── OnboardingTour.tsx
│ ├── PlanGate.tsx
│ ├── Sidebar.tsx # Dashboard, Analytics, Privacy, Billing
│ ├── UpgradeModal.tsx
│ └── UploadForm.tsx # PDF validation, progress bar
├── lib/
│ ├── __tests__/
│ │ ├── anonymizer.test.ts # 20 tests: PII removal
│ │ ├── auth-middleware.test.ts # 9 tests: token verification
│ │ ├── gemini.test.ts # 12 tests: AI response parsing
│ │ ├── rate-limit.test.ts # 6 tests: rate limiter
│ │ ├── utils.test.ts # 6 tests: utilities
│ │ └── setup.ts # Vitest global setup
│ ├── anonymizer.ts # PII removal (email, phone, address, age/gender, name)
│ ├── auth-context.tsx # Auth provider with role, companyId
│ ├── auth-middleware.ts # verifyAuth, verifyOptionalAuth, handleAuthError
│ ├── firebase.ts # Firebase client config
│ ├── firebase-admin.ts # Firebase Admin SDK singleton
│ ├── firestore-schema.ts # Full Firestore TypeScript interfaces
│ ├── gemini.ts # screenResume + generateInterviewQuestions
│ ├── rate-limit.ts # Deprecated in-memory limiter
│ ├── rate-limit-upstash.ts # Upstash Redis + in-memory fallback
│ ├── types.ts # Shared user-facing types
│ ├── use-toast.tsx
│ └── utils.ts
├── e2e/
│ └── happy-path.spec.ts # Playwright smoke tests
├── public/pdf.worker.min.mjs # Local PDF.js worker
├── firestore.rules # Security rules
├── vitest.config.ts # Vitest configuration
├── playwright.config.ts # Playwright configuration
└── .env.example
| Script | Description |
|---|---|
dev |
Start development server |
build |
Production build |
start |
Start production server |
lint |
Run ESLint |
test |
Run all unit tests (vitest run) |
test:watch |
Run tests in watch mode (vitest) |
test:e2e |
Run Playwright E2E tests |
Current test coverage: 94 unit tests across 13 files + 7 Playwright E2E tests (see lib/__tests__/ and e2e/).
The visual language is light, precise, and trustworthy — inspired by Linear, Vercel, and Razorpay.
- Light canvas (
#F8FAFC) with generous whitespace - Blue-600 (
#2563EB) as the single primary action color - Flat surfaces with hairline borders — no gradients, glows, or decorative noise
- Typography-driven hierarchy with Plus Jakarta Sans
- See
DESIGN.mdfor the full design system documentation.