A free, open-source resume builder with a live editor and instant PDF preview — built with Next.js, React, and Redux Toolkit. No sign-up, no watermark.
HireReady lets you fill out a structured resume form and see a live, print-ready PDF preview update as you type. Your data is saved automatically to your browser's localStorage, so you can close the tab and pick up where you left off. When you're happy with the result, download it as a PDF.
- Six resume templates — ATS, Modern, Creative, Minimal, Executive, and Two Column. Each has a genuinely different layout (single column, sidebar, two-column), typography, spacing, and section order/arrangement — not just a recolor.
- Live editor with instant PDF preview — edit contact info, summary, education, experience, projects, skills, certificates, and languages, with the PDF preview panel updating on save or template change.
- In-editor template switching — swap templates from inside
/editorwith no page reload; the preview and download both update immediately. - Templates gallery (
/templates) — browse all templates with ATS-friendliness scores and recommended roles, open a full-size preview modal (zoom, next/previous, keyboard navigation), and select one to jump straight into the editor with it applied. - Auto-save — resume data and the selected template are persisted to
localStorage(debounced) and restored on your next visit, with schema validation so corrupted or stale data never crashes the app. - PDF export — download your resume as a PDF, generated client-side with
@react-pdf/renderer, matching whichever template is currently selected. - Resilient routing — invalid editor tab query params fall back to a sensible default instead of crashing; unknown routes show a proper 404 page; unexpected errors are caught by React error boundaries instead of a blank screen.
- Responsive, accessible UI — built with Tailwind CSS, with
aria-labels, keyboard navigation, and focus management on icon-only controls and modals. - Public resume sharing — publish a resume to a beautiful, SEO-optimized public URL (
/r/<slug>), with a custom-or-random slug, QR code, one-click social sharing, and view/download/share analytics. See Phase 6. - Resume import — upload an existing PDF, DOCX, or JSON resume; the server extracts the text in memory (never stored) and AI converts it into structured, editable resume data with a confidence score, missing-field report, duplicate detection, and automatic cleanup. See Phase 7.
- Multi-format export — download any resume as PDF, DOCX, HTML, Markdown, or JSON, all generated on demand in the browser. Full-account versioned JSON backups can be downloaded and restored (as new resumes, replace, or merge). See Phase 7.
┌──────────────┐ ┌───────────────────────┐ ┌────────────────────┐
│ Editor UI │ ───► │ Redux store │ ───► │ localStorage │
│ (Tabs/Input, │ ◄─── │ (resumeSlice: content │ ◄─── │ (debounced persist, │
│ TemplateSwitcher) │ │ + selectedTemplate) │ │ schema-validated) │
└──────────────┘ └───────────────────────┘ └────────────────────┘
│ │
│ ▼
│ config/templates.js (metadata: name, ATS score,
│ recommended roles, colors, thumbnail — no JSX)
│ │
▼ ▼
┌───────────────────────────────────────────────────┐
│ components/Resume/templates/registry.js │
│ → code-split `import()` per template id │
└───────────────────────────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Template component │ components/Resume/templates/<Name>/index.js
│ (layout + section order) │ + styles.js (typography/spacing/color)
│ built from shared renderers │ components/Resume/templates/shared/*
└────────────────────────────┘
│
▼
┌────────────────────┐
│ Live PDF Preview │ components/Resume/Preview.js (usePDF + react-pdf viewer)
│ + Download button │
└────────────────────┘
- State: a single Redux slice (
store/slices/resumeSlice.js) holds the resume content and theselectedTemplateid. Every field edit or template switch dispatches an action; the store is subscribed to persist both tolocalStorageon change (see Template Architecture). - Rendering:
components/Resume/Preview.jsresolves the selected template's PDF component viatemplates/registry.js(a plain code-splitimport(), notnext/dynamic/React.lazy, since react-pdf's custom reconciler doesn't support Suspense), builds the document element, and feeds it tousePDF. - Routing: Next.js App Router (
app/) with route groups for the home page, and dedicated routes for/editor,/templates, and/about.
Templates live in components/Resume/templates/, one folder per template:
components/Resume/templates/
shared/ Rendering logic reused by every template
Header.js Name/title/contact-links block
SectionHeading.js Titled section wrapper (title + underline + spacing)
ListItem.js Bulleted list row
Sections.js Summary/Education/Experience/Projects/Skills/
Certificates/Languages content renderers
ATS/
index.js <Document>/<Page> layout + section order
styles.js StyleSheet.create({...}) — typography, spacing, color
Modern/ Creative/ Minimal/ Executive/ TwoColumn/
... (same index.js + styles.js shape)
registry.js id → code-split import() loader, with caching
Separation of concerns: a template's index.js only decides layout — which sections appear, in what order, and how they're arranged (single column vs. sidebar vs. two-column). It never re-implements how an experience entry or bullet list renders — that logic lives once in shared/Sections.js and is parameterized entirely by the styles object each template passes in. This is what keeps six visually distinct templates from duplicating rendering code.
- Create
components/Resume/templates/YourTemplate/styles.js— aStyleSheet.create({...})implementing the same style keys used by the existing templates (page,header,headerName,sectionTitle,entryTitle,listRow, etc. — copy an existingstyles.jsas a starting point). - Create
components/Resume/templates/YourTemplate/index.js— import the shared section components from../shared/Sectionsand../shared/Header, and compose your layout (seeTwoColumn/index.jsfor a multi-column example, orCreative/index.jsfor a sidebar example). - Register it in
components/Resume/templates/registry.js'sloadersmap. - Add its metadata (id, name, description, thumbnail, ATS score, recommended roles, colors, layout type) to
config/templates.js. - Add a thumbnail image to
public/templates/.
No other file needs to change — the templates page, editor switcher, and PDF preview all read from config/templates.js and registry.js.
| Layer | Technology |
|---|---|
| Framework | Next.js 14 (App Router) |
| UI | React 18, Tailwind CSS, Framer Motion |
| State | Redux Toolkit, React Redux |
| @react-pdf/renderer, react-pdf | |
| Icons | react-icons, lucide-react |
| Analytics | @next/third-parties (Google Analytics) |
Prerequisites: Node.js 18+ and npm.
git clone https://github.com/Shanidhya01/Resume-Builder.git
cd Resume-Builder
npm installnpm run devVisit http://localhost:3000. The app hot-reloads as you edit files.
Other scripts:
npm run build # production build
npm run start # run the production build locally
npm run lint # run Next.js/ESLint checksapp/ Next.js App Router routes
(Home)/ Landing page
about/ About page (fetches GitHub contributors)
editor/ Resume editor route (?tab=<section>)
templates/ Template gallery (cards + preview modal)
error.js, not-found.js Error boundary & 404 page
components/
Editor/ Editor shell, single-field & repeatable-field editors
Resume/
templates/ All 6 template layouts + shared renderers (see above)
Preview.js Live PDF preview panel + download/preview actions
TemplateSwitcher.js In-editor template picker
TemplatePreviewModal.js Full-size preview with zoom/next/prev
UI/ Reusable form inputs
Header.js, Tabs.js Navigation
config/
ResumeFields.js Declarative field schema per resume section
templates.js Template metadata registry (name, ATS score, colors, etc.)
store/
index.js Redux store + localStorage persistence (content + template)
slices/resumeSlice.js Resume state reducers, incl. setTemplate
utils/
formatDate.js Date formatting helper used in the PDF
- Add automated tests (unit tests for reducers/utils, component/visual tests per template).
- Add a TypeScript migration path for stronger type safety.
- Add CI (lint + build) via GitHub Actions.
- Per-template color customization (using each template's
primaryColor/secondaryColoras user-editable accents). - Optional account/cloud sync for cross-device resume storage.
HireReady now supports Firebase Authentication + Firestore for multi-resume, cross-device storage, layered on top of the existing Redux/localStorage editor.
- Create a project at Firebase Console.
- Enable Authentication > Sign-in method > Email/Password and Google. For Google sign-in, set a support email in the provider config; no extra redirect URI setup is needed for
localhostor a Vercel deployment domain added under Authentication > Settings > Authorized domains. - Create a Firestore Database (production mode).
- Add a Web App (Project Settings > General > Your apps) and copy the SDK config values.
- Deploy the security rules in
firestore.rules:firebase deploy --only firestore:rules(requires the Firebase CLI andfirebase initto link the project), or paste the file's contents into Firestore's Rules tab in the console. - In Firestore, add a composite index if prompted when querying
resumesbyownerId(Firestore will emit a console link the first time the query is run if one is needed).
Runs entirely on the free Spark plan — Firebase Storage is intentionally not used, since file uploads require the paid Blaze plan. Profile pictures are set via a URL field on the Account page instead of a file upload.
Copy .env.example to .env.local and fill in the values from step 4 above:
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
.env.local is git-ignored. The Firebase client SDK does not validate these at build time, so npm run build succeeds even with placeholder values — only actual network calls (sign up, log in, Firestore reads/writes) require a real project.
resumes/{resumeId}—{ ownerId, name, selectedTemplate, contact, summary, education, experience, projects, skills, certificates, languages, isDefault, createdAt, updatedAt }. Documents live in a top-level collection (not nested underusers) so ownership queries are a simplewhere('ownerId', '==', uid).resumes/{resumeId}/versions/{versionId}— a snapshot of the resume content fields plussavedAt, written on autosave at most once every 2 minutes of active editing (the live document is updated on every autosave regardless).
See firestore.rules at the repo root: a resume is only readable/writable by the user whose uid matches its ownerId; the versions subcollection inherits the same check via a get() on the parent document. The Firestore service layer (lib/resumes.js) also checks ownership client-side before mutating, as defense in depth.
npm install- Set up
.env.localas above (placeholders are fine fornpm run build; a real Firebase project is required to actually sign up/log in). npm run dev- Visit
/registerto create an account, then/dashboardto manage resumes.
The original /editor route (no resume id) still works as a local/anonymous scratch editor backed by localStorage, unchanged from Phase 1/2. The primary authenticated flow is /dashboard → pick or create a resume → /editor/[resumeId], which persists to Firestore via a debounced autosave hook (hooks/useAutoSave.js) instead of localStorage.
- Get a free API key at openrouter.ai.
- Add to
.env.local:OPENROUTER_API_KEY=<your-key>. This variable is server-only (noNEXT_PUBLIC_prefix) and is only read insideapp/api/ai/*route handlers — it never reaches the browser bundle. - Optionally set
OPENROUTER_MODEL(defaults toopenai/gpt-oss-20b:free). Changing this value swaps the model used for every AI feature with no code changes.
openrouter.js— low-level client that calls the OpenRouter chat-completions endpoint with a 30s timeout (AbortController) and exponential-backoff retries (max 2) on 429/5xx.prompts.js— one prompt-builder per feature (buildSummaryPrompt,buildExperiencePrompt, etc.), each returning{ system, user }messages. System prompts require the model to use only user-provided information and respond with strict JSON matching a documented shape.parser.js—parseAIResponse(rawText)strips markdown code fences and recovers JSON from a response defensively, throwingAIParseErrorif unrecoverable.validator.js— onevalidate*Responsefunction per feature checking the parsed JSON's shape/types, throwingAIValidationErroron mismatch.index.js—generateAIResponse({ feature, input }), the single entry point every API route uses: looks up the prompt builder, calls OpenRouter, parses, validates, and returns structured data.rateLimit.js— in-memory sliding-window limiter (20 requests/minute per IP) shared by all AI routes.request.js— shared route handler (handleAIRoute) doing rate limiting, JSON body parsing, deep string sanitization (trim + 8000-char cap), and consistent error responses.
app/api/ai/{summary,experience,projects,skills,rewrite,grammar,review,cover-letter,ats,job-match}/route.js — each a POST handler returning the validated AI JSON on success, or { error } with 400 (bad input), 429 (rate limited), or 502/504 (upstream failure) on failure. These routes are intentionally unauthenticated: they only proxy prompts to OpenRouter and never touch Firestore or user data.
components/AIAssistant/AIAssistantPanel.js is a feature-switcher panel (lazy-loaded via next/dynamic({ ssr: false }), matching the existing VersionHistoryDrawer pattern) added as a new card in app/editor/[resumeId]/page.js. It pulls context from Redux, calls the routes via hooks/useAI.js, and offers Accept/Regenerate/Copy actions per result. AI state (loading/error/last response per feature, plus a capped 20-entry history) lives in store/slices/aiSlice.js, registered in store/index.js alongside the existing resume reducer.
Deterministic, dependency-free JS modules — no AI calls, no React — that score and analyze a resume object synchronously:
textUtils.js— shared text helpers (resumeToText,getBullets,splitSkills, syllable/sentence counting).keywords.js—extractKeywords,analyzeKeywords(matched/missing vs. a job description, overused, duplicate, unused skills, suggested industry keywords).readability.js— Flesch Reading Ease approximation.grammar.js— heuristic grammar/spelling/tone checks (repetition, punctuation, first-person pronouns, capitalization).quality.js—analyzeBullets(weak verbs, passive voice, length, missing numbers/impact) andanalyzeSections(Excellent/Good/Needs Improvement/Missing per section, with reasons).score.js—computeAtsScorecombines the above into a weighted overall score (0-100) plus 10 section scores (header, summary, experience, projects, education, skills, certificates, formatting, keyword match, readability).recruiter.js— simulates a recruiter's first-pass skim (reading time, first impression, visual hierarchy score, missing recruiter-facing info).recommendations.js— turns analysis output into a flat, deduplicated, priority-sorted (critical/high/medium/low) suggestion list with stable ids.comparison.js— diffs two resume snapshots + their analyses (added/removed skills & keywords, section score deltas).analysis.js— the single entry point,runAtsAnalysis(resume, jobDescription), composing every module above into one result object (overall,grade,sectionScores,sectionStatus,readability,keywordAnalysis,bulletAnalysis,grammar,recruiter,completion,insights,recommendations,recommendationsByPriority,wordCount,analyzedAt).
Job-description-specific recommendations that require reasoning beyond keyword matching (suggested skills/projects/certifications/resume changes) reuse the existing lib/ai/ OpenRouter pipeline via the jdInsights feature (app/api/ai/jd-insights/route.js), following the same prompt-builder/validator pattern as every other AI feature — no new AI provider was introduced.
Holds the cached analysis result (keyed by a content hash of the current resume so it's only recomputed when the resume actually changes — see hooks/useAtsAnalysis.js), the job-description analyzer state (jobDescription, jdAnalysis, jdInsights), per-suggestion Accept/Reject/Complete status (suggestionStatus), a saved comparison baseline (previousSnapshot), and lightweight analytics counters (history, totalAiGenerations, totalResumeEdits). Registered in store/index.js alongside resume/ai; only the lightweight fields persist to localStorage (analysis/loading state recomputes fresh on load). totalResumeEdits is incremented by a small Redux middleware (trackResumeEdits in store/index.js) that watches for content-mutating resume/* actions, so resumeSlice itself stays unaware of ATS analytics.
All pages below share components/Ats/DashboardNav.js for sub-navigation and pull data from useAtsAnalysis() (cached, resume-hash-gated) rather than recomputing analysis themselves:
/dashboard— resume list (Overview), now with the dashboard sub-nav./dashboard/analytics— full Resume Quality Dashboard: ATS score ring, grade, completion %, readability, grammar score, keyword coverage, recruiter impression, word count, last-analyzed timestamp, resume strength meter, section score bars + radar chart, section intelligence, missing sections, weak/strong bullets, insights, recruiter preview summary, job description analyzer, keyword intelligence, resume comparison, ATS score history / completion trend charts, and AI usage statistics./dashboard/job-match— dedicated Job Description Analyzer UI using the existingjobMatchandjdInsightsOpenRouter-backed routes (match %, matched/missing keywords, recommended skills/projects/certifications/changes)./dashboard/keywords— Keyword Dashboard (top resume keywords with a distribution chart, matched/missing keywords against the last analyzed job description, overused/duplicate keywords, unused skills, suggested industry keywords)./dashboard/recruiter— Recruiter Preview page (reading time, visual hierarchy score, first impression, important sections, missing recruiter info, recruiter/industry/ATS tips)./dashboard/comparison— Resume Comparison page (current vs. a saved baseline snapshot: added/removed skills & keywords, ATS score difference, improvement %, per-section score deltas)./improvements— Improvement Center: recommendations grouped into Critical/High/Medium/Low with per-suggestion Accept/Reject/Mark Complete, counts shown per priority filter, status persisted in Redux.
components/Ats/charts/* use Recharts (AtsHistoryChart, CompletionTrendChart, SectionRadarChart, KeywordDistributionChart) and are lazy-loaded per page via next/dynamic({ ssr: false }) with a skeleton fallback (components/Ats/Skeleton.js), since chart rendering only makes sense client-side and shouldn't block the initial page load.
useAtsAnalysis hashes the resume object and only re-runs runAtsAnalysis when the content actually changes, caching the result in Redux so every dashboard page reads the same cached analysis instead of recomputing it. Expensive list-rendering subcomponents (section score bars, section intelligence list, suggestion rows) are wrapped in React.memo.
Users can publish a resume to a public, recruiter-friendly URL (/r/<slug>) without exposing any editing controls, backed entirely by Firestore — no Firebase Storage, no server-persisted PDFs or QR images.
Sharing metadata is stored directly on the existing resumes/{id} document (no new top-level fields elsewhere in the app's schema were touched):
| Field | Type | Meaning |
|---|---|---|
isPublic |
boolean | Whether the resume is currently shareable. Flipping this to false makes the link inaccessible immediately (checked on every read). |
slug |
string | null | The currently active public slug (random or custom). |
customSlug |
string | null | Set only when the active slug was chosen by the user; null when it's an auto-generated random slug. Lets the UI distinguish "your custom URL" from a random one. |
viewCount, downloadCount, shareCount |
number | Atomic counters (increment()), bumped by the public API routes below. |
lastViewed |
Timestamp | Set on every view. |
createdPublicAt |
Timestamp | Set the first time a resume is published; preserved across unpublish/republish. |
updatedPublicAt |
Timestamp | Bumped on every sharing-related change (publish, unpublish, slug change, analytics event). |
A second top-level collection, publicSlugs/{slug} -> { resumeId, ownerId, createdAt }, maps a slug to its resume. Slugs are not looked up by querying resumes — this mapping collection, readable only by exact document id (never listed), is what makes slugs unguessable-by-enumeration (see Security, below). A resumes/{id}/visitors/{hashedVisitorId} subcollection holds one marker document per unique daily visitor (hashed IP, never stored raw) so unique-visitor counts can be computed with a getCountFromServer() aggregation query without ballooning the parent document.
- Random slugs: 8 characters from a 62-character alphabet (
randomSlug()), generated withglobalThis.crypto.getRandomValues— the Web Crypto API is available natively in both the browser and modern Node, so no extra dependency (e.g.nanoid) was needed. ~47.6 bits of entropy per slug. - Custom slugs:
validateCustomSlug()enforces lowercase letters, numbers, and single hyphens (^[a-z0-9]+(-[a-z0-9]+)*$), 3-40 characters, and rejects a reserved-word list (RESERVED_SLUGS) so a custom slug can never shadow an existing app route like/dashboardor/api. - Collision handling:
setCustomSlug()checks availability before committing and silently falls back to a fresh random slug if the desired one is taken (rather than failing the request) — the caller gets back{ slug, isCustom, requested }so the UI can toast the outcome either way. - Publish/unpublish/regenerate:
publishResume,unpublishResume, andregenerateSlugall use a FirestorewriteBatchto keep thepublicSlugsmapping and theresumesdocument's sharing fields consistent in one atomic write.
Dashboard card (SharePanel) ─┐
├─► lib/publicResumes.js ─► Firestore (resumes + publicSlugs, client SDK, owner-authenticated)
ShareDialog (owner + visitor)┘
Visitor's browser ─► /r/[slug] (Server Component)
│ resolvePublicResumeBySlug(slug) — React cache()-deduped Firestore read
├─► PublicResumeView (pure HTML/Tailwind resume layout, print-ready)
├─► PublicResumeActions (client island: Download PDF, Print, Share)
└─► ViewTracker (client island) ─► POST /api/public/view ─┐
POST /api/public/download │─► lib/publicResumes.js write helpers
POST /api/public/share ───┘ (rate-limited, IP-hashed)
- No Firebase Storage anywhere. QR codes are generated client-side into an in-memory
data:URL via theqrcodepackage (components/Share/QRCodeBlock.js, lazy-loaded withnext/dynamic) and never uploaded. PDFs are generated on-demand in the visitor's browser by reusing the exact same template components as the authenticated editor (components/Resume/templates/registry.js+@react-pdf/renderer'spdf().toBlob()) — the same PDF engine, zero duplication, and no server-side file storage. - Public page rendering (
components/Resume/PublicResumeView.js) is a brand-new, template-agnostic HTML layout (not one of the six PDF templates, which are react-pdf-only components that can't render to the DOM) — responsive, print-friendly via Tailwindprint:variants, and built from the same resume fields as the editor (contact, summary, experience, projects, education, skills, certificates, languages). The resume data model has no "achievements" field (seeconfig/ResumeFields.js), so that section from the original spec is intentionally omitted rather than inventing a new field across the editor, Redux slice, and all six PDF templates. - The global
Header(rendered by the root layout on every route) is suppressed on/r/*via a single pathname check, so the public page has zero dashboard/editor navigation — a clean, standalone surface.
viewCount/downloadCount/shareCount are incremented atomically via Firestore's increment(); lastViewed and unique-visitor markers are written alongside. Reads are owner-only (getPublicAnalytics) and surfaced in the dashboard through components/Share/AnalyticsPanel.js (Views, Unique Visitors, Downloads, Shares, Last Viewed). All writes happen server-side through app/api/public/{view,download,share}/route.js, reusing the same in-memory sliding-window rate limiter already used by the AI routes (lib/ai/rateLimit.js), keyed per IP per endpoint.
- No enumeration: slugs are resolved via
publicSlugs/{slug}— Firestore security rules grantget(single document, exact id) but neverliston that collection, and the same applies toresumesfor anonymous readers. There is no query path that can list public resumes or slugs. - Instant privacy:
resolvePublicResumeBySlugre-checksisPublicon every request (no caching of that decision across requests), and the Firestore rule mirrors it (allow get: if resource.data.isPublic == true) — disabling sharing revokes access on the very next read. - Field-scoped public writes: anonymous visitors can only ever update
viewCount,downloadCount,shareCount,lastViewed,updatedPublicAton a published resume (enforced viadiff().affectedKeys().hasOnly([...])infirestore.rules) — content, ownership,isPublic, and slug fields stay owner-only. - Ownership: every mutation in
lib/publicResumes.js(publish, unpublish, regenerate, custom slug) re-fetches the resume and assertsownerId === uidbefore writing, on top of the Firestore rules doing the same server-side. - Rate limiting: all three
/api/public/*routes are rate-limited per IP (reusinglib/ai/rateLimit.js), and unique-visitor hashing (sha256(salt:resumeId:ip:day)) means raw IP addresses are never persisted. - No editing surface: the public page is built from
PublicResumeView(a read-only presentational component) and two small client islands (PublicResumeActions,ViewTracker) — there is no code path on/r/[slug]that can mutate resume content. - 404s: an invalid or private slug renders
app/r/[slug]/not-found.jswith anoindexmeta tag. Known limitation: because this route is the app's first true async Server Component with data-dependentnotFound(), and the rootapp/loading.jsprovides an automatic streaming fallback, the raw HTTP status for that response is200rather than404(the visible content and SEOnoindextag are correct either way) — see Remaining Work.
generateMetadata builds a dynamic title/description from the resume's contact/summary fields, a canonical URL and Open Graph/Twitter Card metadata (using NEXT_PUBLIC_SITE_URL), and the page body embeds JSON-LD Person structured data (name, job title, sameAs social links). The resume fetch is wrapped in React's cache() so generateMetadata and the page component share a single Firestore read per request instead of two.
NEXT_PUBLIC_SITE_URL (see .env.example) — the public base URL used to build absolute share links and SEO metadata; defaults to http://localhost:3000 if unset.
A complete import/export system: bring existing resumes into the builder with AI parsing, and take any resume out in five formats — plus full backup & restore. No file is ever stored: uploads are processed in request memory on the server and discarded; exports are generated in the browser and downloaded directly.
Supported formats: PDF, DOCX, and JSON (single-resume exports, full HireReady backups, and jsonresume.org-style files).
Flow:
- Upload — drag & drop or browse (
react-dropzone), with a real upload progress bar, client-side size/type pre-checks, and clear error messages. - Extract (
POST /api/import/extract) — the server validates the upload (size cap, MIME type, magic bytes) and extracts plain text withpdfjs-dist(legacy build, lazy-loaded) ormammoth, entirely in memory. Extracted text is sanitized (control/bidi/zero-width characters stripped, length-capped). JSON files never hit this route — they are parsed in the browser. - AI parse (
POST /api/import/parse) — the existing OpenRouter integration (lib/ai/) converts the text into the app's exact resume schema via aresumeImportprompt/validator pair, returning the parsed resume plus a confidence score, missing fields, and suggestions. Results are memoized server-side by text hash (10-minute TTL). - AI cleanup (
POST /api/import/cleanup, runs automatically) — fixes grammar, normalizes dates toYYYY-MM, normalizes bullet points, detects skills demonstrated but not listed, and recommends ATS improvements — without changing facts. Can be re-run manually; failures never block the import. - Review — original extracted text side-by-side with the fully editable parsed resume (every section, add/remove entries), plus the confidence/missing-fields/suggestions panels.
- Duplicate detection — on save, the import is compared against your existing resumes (name/email match, per-entry experience/project/education similarity via token overlap, skill-list overlap). If a likely duplicate is found you choose Merge (union of entries + skills; existing content wins conflicts), Replace (a version snapshot is saved first so it's undoable), or Keep Both.
Key modules: lib/import/validation.js (shared client/server validation + sanitization), lib/import/extract.js (server-only text extraction), lib/import/normalize.js (coerces any parsed/imported shape into the app schema), lib/importExport/duplicates.js (detection + merging), components/ImportExport/* (dropzone, review editor, duplicate resolver).
Pick any resume and export it as:
| Format | How it's generated |
|---|---|
The existing @react-pdf/renderer template engine — identical output to the editor preview, lazy-loaded |
|
| DOCX | The docx package (headings, tab-aligned dates, bullet lists), lazy-loaded |
| HTML | Self-contained, print-friendly document with all values HTML-escaped |
| Markdown | Clean single-document markdown |
| JSON | Portable single-resume backup (re-importable) |
All files are generated in the browser on demand (lib/importExport/exporters/) and downloaded via an object URL — nothing is uploaded or stored.
- Backup — one click downloads a versioned JSON file (
format: "hireready-backup",version: 1, export timestamp) containing every resume's content and template choice, with Firestore metadata/sharing state stripped. - Restore — upload a backup and choose: restore as new resumes, replace the selected resume, or merge into it. Replace/merge save a version snapshot first, so both are reversible from the editor's Version History.
Every import attempt (including failures and backup restores) is logged to a new owner-scoped importHistory Firestore collection: file name, date, source format, status, parse success, confidence, and a link to the created resume. Entries can be deleted; history writes are best-effort and never block an import.
- Never stored — uploads live only in the request's memory; no Firebase Storage, no disk, no bucket.
- Magic-byte validation — the declared MIME type is not trusted; PDF (
%PDF-) and DOCX (PKzip) headers are verified server-side, so renamed/corrupted files are rejected with specific errors (413/415/422). - 5 MB upload cap, enforced client-side, via
Content-Length, and on the actual bytes. - Text sanitization — control characters, bidi overrides, and zero-width characters (prompt-injection/spoofing vectors) are stripped before the text reaches the AI or the browser; the import prompt also instructs the model to treat file content strictly as data.
- Rate limiting — extract/parse/cleanup routes have per-IP sliding-window limits (10/6/6 per minute), reusing
lib/ai/rateLimit.js. - Firestore rules —
importHistorydocuments are readable/deletable only by their owner and immutable once created.
importHistory/{entryId}
ownerId, fileName, source ('pdf'|'docx'|'json'|'backup'),
status ('success'|'failed'), parseSuccess, confidence,
resumeId, resumeName, error, importedAt
Phase 8 is a design-system and experience pass over the whole app — a polished, theme-aware SaaS surface — layered on top of the existing features without changing any business logic (auth, Firestore sync, AI, ATS, sharing, and import/export are untouched).
- Semantic colour tokens.
app/globals.scssdefines a set of CSS custom properties (--canvas,--surface,--surface-2,--fg,--fg-muted,--line,--accent,--accent-fg) as space-separated RGB channels, with light values on:rootand dark values on.dark.tailwind.config.jsmaps these to Tailwind colours (bg-canvas,bg-surface,text-fg,border-line,bg-accent, …) using thergb(var(--x) / <alpha-value>)pattern, so opacity modifiers (bg-surface/60) still work. New UI is built from these tokens and therefore adapts to light/dark and the accent picker automatically. - Dark mode uses
next-themes(attribute="class",defaultTheme="dark",enableSystem), wired incontext/ThemeProvider.jsand toggled from the header (components/UI/ThemeToggle.js) and Settings.<html suppressHydrationWarning>is set as next-themes requires. - Theme customization (
config/themePresets.js+context/ThemeProvider.js): accent colour, font family, font size, spacing, density, and one-click presets. Preferences live in a smalluseSyncExternalStore-backed store persisted tolocalStorage(hireready:preferences:v1) and are applied as CSS variables on<html>. Managed from/dashboard/settings.
Button, Card, Badge, ThemeToggle, plus the pre-existing Input, Skeleton, EmptyState, ErrorMessage, ConfirmModal (migrated to tokens). lib/cn.js wraps tailwind-merge for conflict-free class composition. Prefer these over ad-hoc markup for visual consistency.
- Dashboard (
/dashboard) — overview stat tiles (resume count, average completion, ATS score of the latest resume, public views/downloads/shares, AI usage) viacomponents/Dashboard/DashboardStats.js+StatCard.js, a Quick Actions grid, per-card completion bars, and a friendlier empty state. - Editor (
/editor/[resumeId]) — sticky toolbar with an autosave indicator, undo/redo (hooks/useUndoRedo.js, a dependency-free snapshot stack restored via therestoreContentreducer), keyboard shortcuts (Ctrl/⌘+Z,Ctrl/⌘+Shift+Z/Ctrl+Y,Ctrl/⌘+S), and a split-screen sticky preview. - Drag & drop ordering — repeatable sections (experience, projects, education, certificates, languages) are reorderable with
@dnd-kit(components/Editor/MultiEditor.js), persisted through thereorderListreducer and autosaved. Arrow buttons remain as a keyboard-accessible fallback, and dnd-kit'sKeyboardSensormakes the drag handle keyboard-operable. - Notifications (
context/ToastContext.js) — success/error/warning/info tones, optional action buttons (e.g. undo), auto-dismiss, and Framer Motion enter/exit. - Animations — Framer Motion page transitions (
app/template.js, excluded on/r/*to keep public pages crawlable), card/stat entrances, and toast/modal motion. A globalMotionConfig reducedMotion="user"makes every animation honour the OS "reduce motion" setting. - Error & offline states — redesigned
app/not-found.jsandapp/error.js, plus a global connectivity banner (components/UI/OfflineBanner.js).
- Global
:focus-visiblering, a skip-to-content link, and a<main id="main-content">landmark in the root layout. prefers-reduced-motionis respected in CSS (globals) and in JS animations (MotionConfig).- ARIA roles/labels on toggles, switches, toolbar buttons, drag handles, and icon-only controls; colour choices target WCAG-AA contrast in both themes.
- Heavy, client-only modules stay lazily loaded (
next/dynamic) — AI assistant, version history, and Recharts dashboards. - Preferences use
useSyncExternalStore(no setState-in-effect, no extra renders); list rows and cards are memoised; the dashboard derives metrics from data already fetched rather than making new round-trips. next-themessets the theme class before paint (no flash), anddisableTransitionOnChangeavoids transition flicker on toggle.
next-themes (theming), @dnd-kit/core · @dnd-kit/sortable · @dnd-kit/utilities (drag & drop). Framer Motion and tailwind-merge were already present.
The app is a standard Next.js project and deploys cleanly to Vercel (recommended) or any Node.js host that supports next build / next start. Set the NEXT_PUBLIC_FIREBASE_* and NEXT_PUBLIC_SITE_URL environment variables in your hosting provider's dashboard (the latter should be your production domain, e.g. https://your-app.vercel.app, so share links and SEO metadata resolve correctly). Deploy the updated firestore.rules (Firebase Console > Firestore > Rules, or firebase deploy --only firestore:rules) before shipping Phase 6/7 — public sharing and import history depend on it. Phase 7 also requires OPENROUTER_API_KEY (already needed since Phase 4) for AI resume parsing. The Google Analytics ID is currently hardcoded in app/layout.js.
Contributions are welcome! Please read CONTRIBUTING.md for setup instructions, coding standards, and PR guidelines, and follow our Code of Conduct.
Found a vulnerability? Please see SECURITY.md for responsible disclosure instructions instead of opening a public issue.
- Built and maintained by Shanidhya Kumar.
- Thanks to all contributors who have helped improve this project.
- Built with Next.js, React, Redux Toolkit, and react-pdf.
This project is licensed under the MIT License.
Made with ❤️ by Shanidhya Kumar