Back to Home
Completed
Aug 2024 – Oct 2024

HireCrowd.

A MERN hiring platform where the marketing surface is as considered as the product. An animated, motion-led landing page fronts two complete workflows: a virtualized job feed for candidates and an applicant-tracking console for recruiters.

Framer MotionScroll RevealsInfinite MarqueeAutoplay CarouselVirtualized FeedATS PipelineResume BuilderInvite CodesOpenAPI Docs
FrontendReact 18 + Vite
BackendExpress 4
DatabaseMongoDB
Server StateTanStack Query
AuthJWT + RBAC
API DocsSwagger / OpenAPI

Overview

HireCrowd is a full-stack hiring platform connecting job seekers with recruiters through one unified application workflow. It is deliberately two products sharing one backend:

  • A marketing surface — a motion-led landing page built to earn attention before asking for a signup.
  • A candidate surface — discover, filter, bookmark, build a resume once, and apply everywhere from stored profile data.
  • A recruiter surface — post jobs, review applicants, and move candidates through a hiring pipeline from a single ATS console.

The landing page targets three distinct audiences rather than one generic "job seekers" segment: students, companies, and colleges. Each gets its own narrative card, since a student and a hiring manager need completely different reasons to continue.


System Architecture

High-Level System Architecture

A single Express API serves three client surfaces. Mongoose models back five collections; validation, rate limiting, and auth are middleware, not per-route boilerplate.

HTTP / RPC
Event Driven
Component Breakdown
Client (React 18 + Vite, React Router)
├── Landing — motion-led marketing surface
├── Candidate — job feed, filters, bookmarks, resume builder
└── Recruiter — ATS dashboard, job form, applicant review
 
Middleware (runs before every protected route)
├── express-rate-limit — abuse ceiling on auth + application endpoints
├── express-validator — schema-checked request bodies
└── auth.middleware — JWT verify → attach user → role guard
 
Express API — 6 route groups
├── /auth      register, verify-email, login, refresh
├── /jobs      list (paginated), detail, create, update
├── /companies employer profile + job ownership
├── /applications  apply, list by job, update status
├── /user      profile, professional details, resume
└── /invitecode   employer invites candidates by code
 
MongoDB via Mongoose — 5 models
├── User        candidates + recruiters, role field
├── Job         listings owned by a recruiter
├── Company     employer profile
├── Application job ↔ applicant ref + status enum
└── InviteCode  gated registration
 
External
├── Cloudinary (multer) — resume & avatar storage
├── Nodemailer — verification mail
└── Swagger UI — generated from swagger-jsdoc annotations

Impact & Results

3Audiences

Students, companies, and colleges each get a tailored narrative on the landing page.

5Pipeline Stages

applied → reviewing → interview → hired, with rejected as the terminal off-ramp.

6 RoutesAPI Surface

Swagger-documented route groups with schema validation on every write path.


The Landing Experience

The landing page was the primary design investment. The brief was to avoid the templated hero-plus-three-cards pattern, so the page is built from layered motion and a deliberately non-generic palette.

Scroll-triggered reveals. A small TextAnimation wrapper drives framer-motion's whileInView with viewport={{ once: true }} — content rises into place as it enters the viewport and never re-animates on scroll-back. Staggered delays let a group of elements arrive in sequence rather than all at once.

An infinite marquee headline. The "Built for" band runs on react-fast-marquee at speed 70, with alternating opacity (odd:opacity-50 even:opacity-100) so the repeated words read as a texture rather than duplicated text.

A self-advancing stats carousel. The "Numbers we are proud of" section is a Swiper instance on a 2-second Autoplay delay with EffectFade, cross-fading between metrics. It is also a Headless UI tab group, so the same content is manually navigable on desktop and auto-advances on mobile — with a useMediaQuery branch at 768px and 718px deciding which of the two treatments renders.

A custom vertical slider. VerticleSlider is a hand-rolled vertical track that swaps panels on a vertical axis — the kind of component that is normally reached for in a library but written by hand here to control the easing and the snap behaviour.

A three-audience role grid. Instead of one generic value proposition, the page addresses students, companies, and colleges with their own card, icon treatment, and accent colour — teal #0AA482, coral #FF6E76, violet #693CF3 on tinted backgrounds.

Palette and motion discipline. The page runs on a near-black #150f04 base with a single gold accent #ddb15c, set in font-grotesk. Transitions lean on a custom cubic-bezier (.215, .61, .355, 1) at 700ms — slow enough to feel deliberate rather than snappy.

Scroll-Linked Reveals

framer-motion whileInView with once:true semantics and staggered per-index delays, so sections arrive in sequence and stay settled.

Infinite Marquee

react-fast-marquee headline band with alternating opacity so repeated words read as motion texture, not duplicated copy.

Autoplay Stats Carousel

Swiper Autoplay + EffectFade on a 2s cycle, paired with a Headless UI tab group so the same metrics stay manually navigable.

Hand-Rolled Vertical Slider

A custom vertical track component written to control easing and snap behaviour rather than pulling in a slider dependency.

Three-Audience Narrative

Students, companies, and colleges each get a distinct card, accent colour, and argument instead of one generic jobs pitch.

Responsive Composition

useMediaQuery at 768px and 718px swaps between desktop and mobile compositions, rather than letting one layout stretch and shrink.


Candidate Experience

The candidate side optimises for one thing: a candidate should never re-type information they have already given the platform.

  • Virtualized infinite feed. The job listing uses react-virtuoso for windowed rendering, driven by TanStack Query's useInfiniteQuery with fetchNextPage / hasNextPage. Only visible rows mount, so scroll depth stays flat regardless of listing size.
  • Debounced filtering. Filter state runs through a useFilters hook (itself built on useInfiniteQuery) with a useDebounce hook on input, so typing does not fire a request per keystroke.
  • A profile that becomes a resume. Professional details — experience, education, projects — are captured once through validated forms and rendered into a resume via react-quill for free-form description fields. That resume is what gets attached on apply.
  • One-click apply. Applying pulls the stored resume and profile rather than prompting for an upload, which was the single largest source of drop-off.
  • Bookmarks and applied-job tracking. Saved jobs and application history live in their own Settings views, so a candidate can always answer "what did I apply to, and where does it stand?"

Candidate Job Feed — windowed rendering

Pagination is handled by useInfiniteQuery; virtualization by react-virtuoso. The two are independent concerns and splitting them keeps the cache queryable while keeping the DOM small.

Component Breakdown
Candidate job feed
├── useDebounce — coalesces keystrokes into one request
├── useFilters — owns filter state, wraps useInfiniteQuery
├── useInfiniteQuery — cursor pagination, cached per filter set
└── react-virtuoso — windowed rows, DOM size independent of result count
 
Filter changes produce a new query key → new cache entry, so
switching back to a previous filter is instant and costs no request.

Recruiter Experience

Recruiters get the mirror image: a console for turning a pile of applications into a decision.

  • A dashboard with real aggregates. Recent applications, recent job postings, and candidate counts by status — the counts come from the status enum, not a separate analytics store.
  • Job authoring with rich text. react-quill powers the job description editor, so postings keep formatting instead of degrading to plain text.
  • Applicant review in context. An application drawer surfaces the applicant's card, contact details, and resume without navigating away from the job.
  • A visual pipeline. CandidateStatus renders the five-stage pipeline as a stepper inside a sheet, and writes the new status through a TanStack mutation that invalidates the application queries — the dashboard counts update without a manual refresh.
  • Invite-by-code onboarding. Employers onboard candidates with InviteCodes. The code can be deep-linked: Register.jsx reads inviteCode from the query string and pre-fills the field, so the candidate lands pre-filled instead of retyping.

Recruiter Pipeline — status transitions

The Application model enforces the stage enum in the schema, so an invalid status cannot be persisted regardless of what the client sends.

Component Breakdown
Application.status — Mongoose enum, default "applied"
  applied · reviewing · interview · hired · rejected
 
Both hired and rejected are terminal — no transition out.
 
Recruiter action
├── CandidateStatus stepper (Radix Sheet) → Select → mutation
├── onSuccess: queryClient.invalidateQueries(applications)
└── Result: dashboard status counts re-derive, no manual refresh
 
Schema-level guarantee: the enum lives on the Mongoose schema,
so a hand-crafted request cannot write a stage outside the pipeline.

Engineering Decisions

Why virtualization instead of pagination controls?

A job board is a scanning surface, not a document. Pagination forces a candidate to stop and click; windowed infinite scroll keeps the browsing gesture continuous. react-virtuoso handles the windowing while TanStack Query owns the cursor, so the two concerns stay separable.

Why TanStack Query alongside Zustand?

Server state and client state have different lifecycles. Job listings, applications, and profiles are all cacheable server state with background refetch — that is TanStack Query's job. Zustand is reserved for genuinely local UI state. Putting fetched data in a global store would mean hand-rolling staleness, retries, and deduplication.

Why an enum in the schema rather than app-level validation?

The hiring pipeline has a fixed shape. Encoding it as a Mongoose enum means the database refuses any stage outside the pipeline, so an invalid transition is impossible even from a hand-crafted request — not merely rejected by a client that might be bypassed.

Why invite codes instead of open registration?

Employers onboard candidates they intend to hire. An InviteCode gates registration so a recruiter controls who reaches the platform, and the code travels as a link with the value pre-filled.

Why Swagger UI?

The API was the contract between two independently-built surfaces. swagger-jsdoc annotations generate the spec from the route definitions, so the docs cannot drift out of sync with the implementation the way hand-written docs do.


Technical Challenges

Keeping Scroll Performance Flat

The Problem

An infinite job feed that mounts every row degrades quickly — DOM node count grows with result count, and scroll stutters as the list lengthens.

The Solution

Split the concerns: TanStack Query's useInfiniteQuery handles cursor pagination and caching per filter set, while react-virtuoso windowed the rendered rows so only what is in the viewport is mounted. Filter keystrokes were debounced so typing did not fan out into a request per character.

Result: DOM size stays proportional to viewport height rather than result count, and each filter combination is cached — returning to a previous filter costs no network request.

Making Re-Entry Lossless for Candidates

The Problem

The classic job-portal failure: every application asks the candidate to re-upload a resume they already supplied, and a meaningful share of intended applications die at that step.

The Solution

Professional details (experience, education, projects) are captured once behind RHF + zod validated forms, then composed into a resume at apply time. The candidate's one-time investment is reused on every subsequent application.

Result: Apply is a single action with no upload step, and the resume stays consistent across every application submitted from the profile.

A Landing Page That Doesn't Read as a Template

The Problem

Job portals are the most templated site category on the web — hero, search bar, three feature cards. A page that looks like every other one fails before the product is ever seen.

The Solution

Built the surface from layered motion instead of a static hero: whileInView scroll reveals with staggered delays, an infinite marquee headline, a 2-second autoplaying Swiper stats carousel paired with a manually navigable tab group, and a hand-rolled vertical slider. Grounded in a near-black base with a single gold accent and a deliberate custom easing curve.

Result: Three distinct audience narratives (students, companies, colleges) with their own cards and accent colours, and a page whose composition changes by breakpoint rather than stretching one layout.

API Reference


Engineering Details

OptimizationResult
Job feed renderingreact-virtuoso windowing
Server stateTanStack Query v5 (infinite + cache)
Input handlingDebounced filter queries
Form validationReact Hook Form + zod
Rich textQuill (jobs + resume)
UI primitivesRadix UI (shadcn pattern)
AuthJWT + bcrypt + role middleware
Abuse controlexpress-rate-limit
Request validationexpress-validator
File uploadsMulter → Cloudinary
Transactional emailNodemailer
API documentationSwagger UI (swagger-jsdoc)

Lessons Learned

  • Motion is a design decision, not decoration. The scroll reveals, marquee, and autoplay carousel each carry information — pacing the page, or surfacing metrics — rather than existing to look busy.
  • One breakpoint strategy beats one flexible layout. Branching composition at 768px and 718px produced a better mobile page than letting a desktop layout compress.
  • Server state and client state are different problems. TanStack Query for anything cacheable, Zustand for local UI — mixing them costs staleness bugs.
  • Schema-level constraints beat application-level checks. Putting the pipeline enum on the Mongoose model made invalid states unrepresentable rather than merely rejected.
  • Reuse is a product feature. Generating the resume from stored profile data removed an upload step from every application — the highest-leverage friction reduction in the project.
  • Invite links should arrive pre-filled. Reading inviteCode from the query string is a two-line change that removes a manual step from onboarding.

Screenshots


Design References

The three images below are third-party design references that informed this project — concept work sourced from Behance and Dribbble while planning the interface. They are not screenshots of HireCrowd, and none of the imagery is my own. Included for attribution and design context only. All application screenshots appear in the gallery above.

The job-listing layout was inspired by a public Dribbble job-search concept. The implemented feed diverges substantially — it adds cursor pagination and windowed virtualization that the static concept has no equivalent for.

Liked this project?

Check out more of my work or get in touch.