
PeerReview
GitHub Pull Requests for resumes upload a resume, get an instant structured review across 8 scoring dimensions, match it against real job descriptions, track applications, and let the community leave inline, line-anchored comments version by version until it's ready to ship.
Getting honest resume feedback is either impossible or expensive. Friends say it looks great, recruiters never reply, and paid review services charge for one round of generic advice with no way to iterate. Meanwhile the resume artifact itself is unmanaged: there's no history, no record of what changed between the version that got an interview and the one that didn't, and no way for feedback to point at the exact line it's about.
I built PeerReview around a mechanic developers already trust: the pull request. Every resume is a repository, every edit is a version, and feedback is either an inline comment anchored to a quoted line or a structured multi-category review. An instant heuristic analyzer scores every version the moment it's uploaded so nobody waits on a human for the obvious fixes, and a job-description matcher plus an application tracker extend the loop from 'is my resume good' to 'is my resume good for this specific job I'm applying to today'.
- Live production SaaS at peerreview.occazi.in with a real payment flow, email verification, and a Postgres-backed multi-instance-safe rate limiter.
- 8-dimension instant resume analysis (ATS, grammar, projects, experience, skills, design, achievements, completeness) that runs locally with zero external API keys and no per-request cost.
- GitHub-style version control for resumes — word-level diffs between versions, with reviews and comments bound to the specific version they were written about.
- Job Match Score with an explicit missing-keyword breakdown, wired into a Kanban application tracker so every logged application carries the score of the resume actually sent.
- Five real PDF templates with a live in-browser preview that renders the exact same component as the final server-generated PDF — one source of truth, no visual drift.
- Concurrency-verified rate limiting: 100 genuinely simultaneous requests against one key returned 100 consecutive unique counts with zero duplicates against a real Postgres 16 instance.
Project Overview
PeerReview is a production resume-improvement platform built on the idea that a resume should be iterated on the way code is — in public, in versions, with feedback attached to specific lines. A user uploads a PDF or DOCX, the app extracts the text, scores it instantly across eight dimensions, and publishes it (optionally anonymously) to a showcase where other users review it across seven star-rated categories and leave inline comments tied to a quoted fragment.
Around that core loop sit the things that make it a real product rather than a demo: a job-description matcher that names the keywords the JD wants and the resume lacks, a Kanban application tracker that remembers the match score of the exact resume sent to each company, an interview-question generator that reads a resume back as likely questions, a five-template PDF builder with live preview, and an XP/badge/leaderboard system that makes reviewing other people's resumes worth doing.
The entire analysis layer is deliberately local heuristics rather than an LLM call. That keeps the app free to run, instant, deterministic, and inspectable — and every analyzer (`analyzeResume`, `computeJobMatch`, `generateInterviewQuestions`) sits behind a stable return type, so swapping in a real model later is a one-function change rather than a rewrite.
Problem Statement
Resume feedback is a broken market at both ends. Candidates get either polite non-feedback from people who don't want to discourage them, or nothing at all from recruiters who never respond. Paid services sell a single round of generic advice with no iteration, no version history, and no way to see whether the changes actually improved anything. On top of that, the resume artifact itself is unmanaged: people keep resume_final_v3_ACTUAL.pdf on a desktop, can't remember what changed between versions, and have no mechanism for feedback to point at a specific bullet point. Separately, the job-search workflow around the resume is fragmented across a spreadsheet of applications, a mental note of which resume version went where, and a manual re-read of every job description to guess which keywords an ATS is scanning for. PeerReview's goal was to collapse all of that into one versioned, reviewable, measurable loop.
Pain points addressed:Goals & Success Criteria
Functional goals:My Role & Responsibilities
Sole designer, developer, and operator — product definition through production deployment.
Designed the full relational data model (14 Prisma models) including the version/review/comment graph and the denormalized fields that keep public listings to a single indexed query.
Wrote the entire heuristic analysis layer: resume scorer, bullet rewriter, job-description keyword matcher, tailoring-suggestion generator, and interview-question generator.
Built a homegrown JWT session system on `jose` with bcrypt (cost factor 12) hashing and httpOnly, SameSite=Lax cookies, plus route-level middleware protection.
Implemented PDF/DOCX text extraction and a sanitized DOCX-to-HTML document viewer.
Built the word-level diff engine and the GitHub-style version compare view.
Built five @react-pdf/renderer templates and the live-preview builder, validating output against an independent renderer (Poppler).
Integrated Razorpay one-time payments with constant-time server-side signature verification.
Implemented SMTP email verification with single-use, resend-invalidating tokens and per-user resend rate limiting.
Ran a dedicated scaling and security pass: replaced the in-memory rate limiter with an atomic Postgres upsert, closed an unauthenticated file-parsing endpoint, bounded every unbounded query, and added health checks, loading skeletons, and error boundaries.
Designed the dark, diff-inspired visual system and the full responsive UI.
Tech Stack & Architecture
Next.js 15 App Router with Server Actions removes most of the API surface — mutations are typed server functions with built-in Origin-header CSRF protection instead of hand-rolled endpoints, and server components let heavy pages read straight from the database with no client-side waterfall. Prisma + Postgres was chosen over a real-time backend because the data is deeply relational (users → resumes → versions → reviews → votes) and the hard requirements were correct indexes and bounded queries, not live sync; Postgres also doubles as the rate-limit store, which removes Redis from the deployment entirely. Authentication is homegrown on `jose` + bcryptjs rather than a hosted provider, so a session stays a signed cookie with no vendor lock-in and no per-MAU cost. The analysis layer is local heuristics: free, instant, deterministic, debuggable, and hidden behind stable return types so a model call can replace it later without touching a single caller. @react-pdf/renderer lets one template component serve both the browser live preview and the server-side final PDF, which structurally prevents preview drift. Razorpay is the natural fit for a ₹99 one-time INR payment, and plain Nodemailer SMTP keeps email verification working on a free Gmail app password or Brevo tier with no third-party auth dependency.
Architecture Diagram
flowchart LR
Candidate --> Frontend[Next.js 15 App Router]
Reviewer --> Frontend
Frontend --> MW[JWT Middleware]
MW --> SA[Server Actions]
Frontend --> API[Route Handlers]
SA --> Prisma[Prisma Client]
API --> Prisma
Prisma --> DB[(PostgreSQL)]
API --> Parser[pdf-parse / mammoth]
SA --> Analyzer[Heuristic Analysis Layer]
Analyzer --> Score[8-Dimension Score]
SA --> PDF[react-pdf Renderer]
API --> RZP[Razorpay Signature Verify]
SA --> Mail[Nodemailer SMTP]
Prisma --> RL[(RateLimitEntry)]
DB --> XP[XP / Badges / Leaderboard]Key Features
Every edit creates a new version with a word-level GitHub-style diff, and reviews and comments stay bound to the exact version they were written about.
A local heuristic engine scores ATS, grammar, projects, experience, skills, design, achievements, and completeness the moment a resume is uploaded — no API key, no cost, no wait.
Reviewers quote a specific fragment of the resume and comment on it, so feedback points at a real bullet point instead of the document as a whole.
Paste a job description against any of your resumes and get a keyword-match percentage plus exactly which terms are matched and which are missing.
A Kanban board from Saved to Offer that keeps the match score, job description, and notes attached to every application you log.
Five distinct templates with a preview that renders the exact same component as the final downloaded PDF, so what you see cannot drift from what you get.
Reviewing, commenting, and being marked helpful earn XP across four levels and auto-awarded badges, surfaced on public profiles and a leaderboard.
Development Process & Challenges
Making the analyzer useful without an LLM: an 'AI review' that only counts keywords is worthless, so the scorer had to model real recruiter heuristics — strong versus weak action verbs, quantified outcomes, section completeness, bullet density — and produce written suggestions that name the specific gap rather than restating the score.
A real bug in keyword extraction: two-word phrases were being formed by sliding a window across the entire cleaned text, so phrases crossed unrelated sentence boundaries ('...code review is required. You should be comfortable...' produced 'review comfortable'), and stopwords were stripped before pairing, so words never adjacent in the source got paired anyway. The fix was to split on real sentence and clause boundaries first — while protecting compound terms like 'Node.js' from being treated as sentence-enders — and only form bigrams from genuinely adjacent words.
Rate limiting that survives horizontal scaling: the original in-memory Map limiter reset on restart and shared nothing across instances, which is exactly wrong at the traffic level where limits matter. Rather than adding Redis as a service to run and pay for, it moved to a Postgres table using a single atomic INSERT ... ON CONFLICT per check. Validated empirically against a real Postgres 16 instance — 100 genuinely concurrent requests against one key returned 100 consecutive unique counts with zero duplicates, which a naive read-then-write would have failed.
An unauthenticated expensive endpoint: /api/parse-resume — the most CPU-costly route in the app — had no auth check at all, so anyone could POST arbitrary files to it. Found and closed during the security pass, along with adding rate limits to PDF rendering, job matching, AI rewrite, interview-question generation, and both billing endpoints.
Unbounded queries that only break at scale: the showcase page loaded every public resume into memory to sort in JavaScript, the resume detail page loaded every historical version's full reviews and comments just to render the latest one, and profile pages capped reviews at 10 but left the adjacent resumes list uncapped. All three became bounded, indexed queries with separate lightweight aggregate counts so displayed averages stay accurate even when the list is capped — which is why `Resume.latestScore` exists as a denormalized field.
Storage growing with every edit: binary PDF/DOCX bytes were kept on every version even though only the latest version's file is ever served or reopened. Both the upload and template-builder save paths now clear file bytes and structured builder data from every version except the newest, keeping storage roughly one file per resume.
PDF templates that render without throwing but are still wrong: cross-checking all five templates against an independent renderer (Poppler) caught a name/title text overlap and an arrow character outside standard PDF font encoding that rendered as a stray glyph — neither of which produced an error at render time.
A subtle plan-state ambiguity: with a single non-expiring paid tier, `planExpiresAt` is null for both a free user and a lifetime user. The resolution was to make `planTier` the sole decider inside one function, `getEffectivePlanTier`, documented and tested so the two null cases can't be confused.
UX & Product Decisions
The 'git for resumes' concept is expressed as real mechanics, not decoration — actual diff-style green/red additions and deletions appear in the hero, the version compare view, and inline comments, because that is the genuine product mechanic.
The DOCX viewer deliberately renders on a white 'paper' card even though the rest of the UI is dark: a resume is a real document, and showing it on white reads as authentic rather than forcing it into a canvas nobody actually reads resumes on.
Removed the `$ git ...`-style terminal micro-copy that once topped every page — it read as a developer in-joke rather than a professional tool, and the diff language already carries the concept where it's real.
Dropped a fabricated 'average version count' stat strip from the landing page; inventing usage numbers for a product with no users yet is dishonest, not just informal.
Anonymous publishing is a first-class option, because the value of public review collapses if getting feedback requires exposing your name and contact details.
Email verification gates writing reviews and comments on other people's resumes, but not logging in or uploading your own — community trust is the surface fake accounts damage, and gating a user's private workflow would only punish real users.
Back navigation uses browser history rather than a hardcoded parent route, since a resume page or profile can legitimately be reached from the dashboard, showcase, leaderboard, or a review author's name.
Dashboard stat tiles link to where the data actually lives instead of being static numbers with nothing behind them, and logout asks for confirmation instead of firing on a single click.
The template gallery shows locked templates rather than hiding them, so the upgrade path is legible — with the lock enforced server-side in the save action, not just in the UI.
Tailoring suggestions include an explicit nudge not to add keywords that aren't true, because a keyword-stuffed resume that collapses in the interview is worse than a visible gap.
Future Improvements
Visuals & Diagrams
User Flow Diagramflowchart TD
A[Register] --> B[Verify Email]
B --> C{Create Resume}
C -->|Upload PDF/DOCX| D[Text Extraction]
C -->|Template Builder| E[Live PDF Preview]
D --> F[Instant 8-Dimension Score]
E --> F
F --> G[Publish to Showcase]
G --> H[Community Reviews & Inline Comments]
H --> I[New Version]
I --> J[Word-Level Diff vs Previous]
J --> F
F --> K[Paste Job Description]
K --> L[Match Score + Missing Keywords]
L --> M[Log Application in Tracker]
M --> N[Interview Prep Questions]
H --> O[XP, Badges, Leaderboard]