# How to Build a Recruitment Platform with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build an internal Applicant Tracking System (ATS) in Replit using Express and PostgreSQL in 1-2 hours. You'll manage job postings, a Kanban-style candidate pipeline, interview scheduling, and pipeline history — a Greenhouse or Lever lite. Replit Auth handles recruiter logins and Autoscale handles deployment.

## Before you start

- A Replit account (Free tier is sufficient)
- A SendGrid account (free tier) for rejection and interview invitation emails
- Basic understanding of your hiring workflow — know what pipeline stages you use
- SENDGRID_API_KEY added to Replit Secrets before deploying

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Create a new Replit App and paste this prompt. Agent builds the complete ATS backend with all five tables, core routes, and a Kanban React frontend.

```
// Build a recruitment platform (ATS) with Express and PostgreSQL using Drizzle ORM.
// Use Replit Auth for authentication.
//
// Tables:
// 1. job_postings: id serial primary key, title text not null, department text,
//    description text not null, requirements text, location text,
//    type text default 'full_time' (enum: full_time/part_time/contract/remote),
//    salary_min integer, salary_max integer,
//    status text default 'draft' (enum: draft/open/closed), posted_by text not null,
//    created_at timestamp default now()
// 2. candidates: id serial primary key, name text not null, email text unique not null,
//    phone text, resume_url text, linkedin_url text,
//    source text (enum: direct/referral/linkedin/indeed/other),
//    notes text, created_at timestamp default now()
// 3. applications: id serial primary key,
//    job_posting_id integer references job_postings not null,
//    candidate_id integer references candidates not null,
//    stage text default 'applied' (enum: applied/screening/interview/offer/hired/rejected),
//    cover_letter text, applied_at timestamp default now(),
//    updated_at timestamp default now(), unique(job_posting_id, candidate_id)
// 4. interviews: id serial primary key,
//    application_id integer references applications not null,
//    interviewer text not null, scheduled_at timestamp not null,
//    duration_minutes integer default 60,
//    type text default 'video' (enum: phone/video/onsite),
//    location text, feedback text, score integer (1-5),
//    status text default 'scheduled' (enum: scheduled/completed/cancelled),
//    created_at timestamp default now()
// 5. pipeline_history: id serial primary key,
//    application_id integer references applications not null,
//    from_stage text, to_stage text not null,
//    changed_by text not null, notes text, created_at timestamp default now()
//
// Routes:
// GET/POST /api/jobs, PATCH /api/jobs/:id/status
// GET /api/jobs/:id/applications, POST /api/candidates
// POST /api/applications, PATCH /api/applications/:id/stage
// POST/GET /api/interviews, PATCH /api/interviews/:id/feedback
// GET /api/pipeline/:jobId (Kanban — applications grouped by stage)
// GET /api/reports/time-to-hire, GET /api/reports/source-effectiveness
//
// Bind to 0.0.0.0:3000.
```

> Pro tip: After scaffolding, insert a test job posting and 3 test candidates via the API before building the frontend. Having sample data makes it much easier to verify the Kanban view renders correctly.

**Expected result:** Running Express app with all five tables. GET /api/jobs returns an empty array. The React frontend shows a nav with 'Jobs', 'Candidates', and 'Pipeline' sections.

### 2. Build the pipeline stage transition with audit logging

The PATCH /api/applications/:id/stage route is the most critical — it advances or moves back an application's stage, logs the change, and optionally sends emails.

```
const express = require('express');
const { db } = require('../db');
const { applications, pipelineHistory, interviews } = require('../schema');
const { eq, and } = require('drizzle-orm');
const { withDbRetry } = require('../lib/retryDb');

const router = express.Router();

const VALID_STAGES = ['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'];

router.patch('/api/applications/:id/stage', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { stage, notes } = req.body;

  if (!VALID_STAGES.includes(stage)) {
    return res.status(400).json({ error: `Invalid stage. Must be: ${VALID_STAGES.join(', ')}` });
  }

  const appId = parseInt(req.params.id);

  // Get current application
  const [app] = await db.select().from(applications)
    .where(eq(applications.id, appId)).limit(1);
  if (!app) return res.status(404).json({ error: 'Application not found' });

  if (app.stage === stage) {
    return res.status(400).json({ error: 'Application is already in this stage' });
  }

  // Update stage and log to pipeline_history atomically
  await withDbRetry(async () => {
    await db.update(applications)
      .set({ stage, updatedAt: new Date() })
      .where(eq(applications.id, appId));

    await db.insert(pipelineHistory).values({
      applicationId: appId,
      fromStage: app.stage,
      toStage: stage,
      changedBy: req.user.id,
      notes: notes || null,
    });
  });

  return res.json({ id: appId, stage, movedFrom: app.stage });
});

// GET Kanban view — applications grouped by stage for a job
router.get('/api/pipeline/:jobId', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const jobId = parseInt(req.params.jobId);

  const rows = await db.execute({
    sql: `
      SELECT a.id, a.stage, a.applied_at, a.updated_at,
        c.id AS candidate_id, c.name, c.email, c.source,
        c.linkedin_url, c.resume_url,
        EXTRACT(DAY FROM NOW() - a.updated_at) AS days_in_stage,
        COUNT(i.id) AS interview_count
      FROM applications a
      JOIN candidates c ON a.candidate_id = c.id
      LEFT JOIN interviews i ON i.application_id = a.id
      WHERE a.job_posting_id = $1
      GROUP BY a.id, c.id
      ORDER BY a.updated_at DESC
    `,
    params: [jobId],
  });

  // Group by stage for Kanban
  const pipeline = {};
  for (const stage of VALID_STAGES) pipeline[stage] = [];
  for (const row of rows.rows) {
    pipeline[row.stage]?.push(row);
  }

  return res.json({ jobId, pipeline });
});

module.exports = router;
```

> Pro tip: Add automatic actions on specific stage transitions. Moving to 'interview' stage can auto-open the interview scheduling dialog in the frontend. Moving to 'rejected' can trigger a SendGrid rejection email — add the email send call after the pipeline_history insert.

**Expected result:** PATCH /api/applications/1/stage with {stage: 'interview'} moves the candidate and logs a pipeline_history row. GET /api/pipeline/1 returns applications grouped into six stage buckets.

### 3. Add interview scheduling and feedback

Interviews need to be scheduled, completed, and scored. This step adds the interview routes and the feedback capture that feeds into candidate ranking.

```
const express = require('express');
const { db } = require('../db');
const { interviews } = require('../schema');
const { eq, and } = require('drizzle-orm');

const router = express.Router();

router.post('/api/interviews', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { applicationId, interviewer, scheduledAt, durationMinutes, type, location } = req.body;

  if (!applicationId || !interviewer || !scheduledAt) {
    return res.status(400).json({ error: 'applicationId, interviewer, and scheduledAt are required' });
  }

  const row = await db.insert(interviews).values({
    applicationId: parseInt(applicationId),
    interviewer,
    scheduledAt: new Date(scheduledAt),
    durationMinutes: durationMinutes || 60,
    type: type || 'video',
    location: location || null,
  }).returning();

  return res.status(201).json(row[0]);
});

router.get('/api/interviews', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { applicationId } = req.query;

  const rows = await db.select().from(interviews)
    .where(applicationId ? eq(interviews.applicationId, parseInt(applicationId)) : undefined)
    .orderBy(interviews.scheduledAt);

  return res.json({ interviews: rows });
});

router.patch('/api/interviews/:id/feedback', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { feedback, score, status } = req.body;

  if (score && (score < 1 || score > 5)) {
    return res.status(400).json({ error: 'Score must be 1-5' });
  }

  const updated = await db.update(interviews)
    .set({
      feedback: feedback || null,
      score: score ? parseInt(score) : null,
      status: status || 'completed',
    })
    .where(eq(interviews.id, parseInt(req.params.id)))
    .returning();

  if (!updated[0]) return res.status(404).json({ error: 'Interview not found' });
  return res.json(updated[0]);
});

module.exports = router;
```

**Expected result:** POST /api/interviews creates a scheduled interview. PATCH /api/interviews/1/feedback with {score: 4, feedback: 'Strong communicator', status: 'completed'} updates the interview record.

### 4. Add the hiring analytics reports

Two SQL queries turn your pipeline_history data into actionable hiring metrics: time-to-hire by job and source effectiveness showing which channels produce the most hires.

```
const express = require('express');
const { db } = require('../db');

const router = express.Router();

// Average days from application to hired, by job
router.get('/api/reports/time-to-hire', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });

  const result = await db.execute({
    sql: `
      SELECT
        jp.id AS job_id,
        jp.title AS job_title,
        COUNT(a.id) AS hired_count,
        ROUND(AVG(
          EXTRACT(DAY FROM ph_hired.created_at - a.applied_at)
        )) AS avg_days_to_hire
      FROM applications a
      JOIN job_postings jp ON a.job_posting_id = jp.id
      JOIN pipeline_history ph_hired
        ON ph_hired.application_id = a.id
        AND ph_hired.to_stage = 'hired'
      WHERE a.stage = 'hired'
      GROUP BY jp.id, jp.title
      ORDER BY avg_days_to_hire ASC
    `,
    params: [],
  });

  return res.json({ report: result.rows });
});

// Candidates by source with hire rates
router.get('/api/reports/source-effectiveness', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });

  const result = await db.execute({
    sql: `
      SELECT
        c.source,
        COUNT(DISTINCT c.id) AS total_candidates,
        COUNT(DISTINCT CASE WHEN a.stage = 'hired' THEN c.id END) AS hired,
        COUNT(DISTINCT CASE WHEN a.stage = 'rejected' THEN c.id END) AS rejected,
        ROUND(
          100.0 * COUNT(DISTINCT CASE WHEN a.stage = 'hired' THEN c.id END)
          / NULLIF(COUNT(DISTINCT c.id), 0)
        ) AS hire_rate_pct
      FROM candidates c
      LEFT JOIN applications a ON a.candidate_id = c.id
      GROUP BY c.source
      ORDER BY hire_rate_pct DESC NULLS LAST
    `,
    params: [],
  });

  return res.json({ report: result.rows });
});

module.exports = router;
```

> Pro tip: Add a date range filter to both report routes with ?startDate= and ?endDate= query params — this lets you compare this quarter's hiring performance against last quarter.

**Expected result:** GET /api/reports/time-to-hire returns average days per job. GET /api/reports/source-effectiveness shows hire rates by candidate source (LinkedIn, referral, etc.).

### 5. Build the Kanban pipeline React frontend

Ask Agent to build the Kanban board and candidate detail panel — the main interface recruiters use daily.

```
// Ask Agent to build the React frontend with this prompt:
// Build a React ATS frontend with these pages:
//
// 1. Jobs List page (/):
//    - Table of job_postings with columns: Title, Department, Type, Salary Range,
//      Status badge (draft=gray, open=green, closed=red), Application Count
//    - 'Create Job' button opens a modal form
//    - Click job row navigates to the Pipeline page for that job
//    - Status toggle button: open <-> closed
//
// 2. Pipeline Kanban page (/jobs/:id/pipeline):
//    - Fetch GET /api/pipeline/:jobId
//    - Six columns: Applied, Screening, Interview, Offer, Hired, Rejected
//    - Each column has a count badge and a scrollable list of candidate cards
//    - Candidate card shows: name, source badge, days-in-stage indicator
//      (orange if >7 days, red if >14 days), interview count badge
//    - Drag card between columns calls PATCH /api/applications/:id/stage
//    - Click card opens the Candidate Detail panel
//
// 3. Candidate Detail panel (right-side sheet):
//    - Shows: name, email, phone, source, resume link, LinkedIn link
//    - Application history across all jobs as a timeline
//    - Interview list with scheduled time, interviewer, score (star display)
//    - 'Add Interview' button opens scheduling dialog
//    - 'Add Note' textarea that updates candidate notes
//    - Pipeline history timeline showing stage changes with timestamps
//
// 4. Reports page (/reports):
//    - Two sections: Time to Hire (bar chart by job) and Source Effectiveness (table)
//    - Bar chart uses recharts BarChart
//
// Use React Router and Replit Auth login gate.
```

**Expected result:** The Kanban board renders six columns. Dragging a card calls PATCH /api/applications/:id/stage. The Candidate Detail panel shows interview history and pipeline timeline.

## Complete code example

File: `server/routes/applications.js`

```javascript
const express = require('express');
const { db } = require('../db');
const { applications, pipelineHistory, candidates } = require('../schema');
const { eq, and } = require('drizzle-orm');
const { withDbRetry } = require('../lib/retryDb');

const router = express.Router();
const VALID_STAGES = ['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'];

router.post('/api/applications', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { jobPostingId, candidateId, coverLetter } = req.body;
  if (!jobPostingId || !candidateId) {
    return res.status(400).json({ error: 'jobPostingId and candidateId are required' });
  }
  const row = await withDbRetry(() =>
    db.insert(applications).values({
      jobPostingId: parseInt(jobPostingId),
      candidateId: parseInt(candidateId),
      coverLetter: coverLetter || null,
    }).returning()
  );
  return res.status(201).json(row[0]);
});

router.patch('/api/applications/:id/stage', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { stage, notes } = req.body;
  if (!VALID_STAGES.includes(stage)) {
    return res.status(400).json({ error: `Invalid stage. Must be: ${VALID_STAGES.join(', ')}` });
  }
  const appId = parseInt(req.params.id);
  const [app] = await db.select().from(applications)
    .where(eq(applications.id, appId)).limit(1);
  if (!app) return res.status(404).json({ error: 'Application not found' });
  if (app.stage === stage) {
    return res.status(400).json({ error: 'Already in this stage' });
  }
  await withDbRetry(async () => {
    await db.update(applications)
      .set({ stage, updatedAt: new Date() })
      .where(eq(applications.id, appId));
    await db.insert(pipelineHistory).values({
      applicationId: appId,
      fromStage: app.stage,
      toStage: stage,
      changedBy: req.user.id,
      notes: notes || null,
    });
  });
  return res.json({ id: appId, stage, previousStage: app.stage });
});

router.get('/api/pipeline/:jobId', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const result = await db.execute({
    sql: `SELECT a.id, a.stage, a.applied_at, a.updated_at,
      c.id AS candidate_id, c.name, c.email, c.source, c.resume_url,
      EXTRACT(DAY FROM NOW() - a.updated_at) AS days_in_stage
      FROM applications a JOIN candidates c ON a.candidate_id = c.id
module.exports = router;
```

## Common mistakes

- **Not logging pipeline stage changes in pipeline_history** — Without the audit trail, you can't calculate time-to-hire, identify bottlenecks, or prove compliance with hiring process requirements. Fix: Always insert a pipeline_history row inside the same transaction as the application stage update. If the history insert fails, roll back the stage update too — use withDbRetry wrapping both operations.
- **Missing the unique constraint on applications(job_posting_id, candidate_id)** — Without this constraint, the same candidate can be applied to the same job multiple times, creating duplicate Kanban cards and confusing pipeline metrics. Fix: The schema includes unique(job_posting_id, candidate_id). On duplicate application attempts, the POST /api/applications route gets a unique violation error — return 409 Conflict with a helpful message.
- **Forgetting to add SENDGRID_API_KEY to Deployment Secrets** — Workspace Secrets are separate from Deployment Secrets in Replit. Automated rejection emails work in development but silently fail after deployment. Fix: After testing locally, open the Publish pane → Secrets, add SENDGRID_API_KEY with the same value. Redeploy to activate.
- **Loading all candidate data for the Kanban view in one query** — Joining all candidate fields, interviews, and comments on every Kanban load gets slow as the candidate pool grows. Fix: The /api/pipeline/:jobId route returns only the minimal fields needed for Kanban cards (name, source, days_in_stage, interview_count). Load full candidate details only when a specific card is clicked.

## Best practices

- Log every stage transition in pipeline_history — this is the source of truth for time-to-hire reports and hiring process audits.
- Use the unique constraint on job_posting_id + candidate_id to prevent duplicate applications at the database level, not just in application logic.
- Store resume files in a cloud storage service (AWS S3, Cloudflare R2) and only save the URL in Replit's PostgreSQL — binary files eat through the 10GB limit quickly.
- Use Drizzle Studio (Database tab in Replit sidebar) to inspect pipeline_history during development and verify stage transitions are being recorded correctly.
- Add the days_in_stage calculation in the Kanban query (EXTRACT(DAY FROM NOW() - updated_at)) and highlight cards with red/orange badges when candidates have been waiting too long.
- Deploy on Autoscale — recruitment tools have predictable business-hours usage patterns and cold starts of 10-30 seconds are acceptable for a tool recruiter teams check a few times per day.
- Use Replit Auth roles if multiple recruiters need different access — senior recruiters can make offers, junior recruiters can only move to screening/interview stages.

## Frequently asked questions

### How is this different from a public job board?

A recruitment platform is an internal tool — only your hiring team uses it. It manages the full candidate lifecycle from application through hire, with pipeline tracking and reporting. A job board is public-facing and lets job seekers browse listings and submit applications. The job-board build guide covers that pattern.

### Can multiple recruiters use this at the same time?

Yes. Replit Auth supports multiple users, and the pipeline_history table records which user made each stage change. For larger teams, add a role column to track which recruiters can make offers (senior) vs. just move applications to screening (junior) — use a middleware check before the PATCH /api/applications/:id/stage handler.

### How do I handle resume file uploads?

Store resume files in cloud storage (AWS S3 or Cloudflare R2) and save only the URL in the candidates table. Never store binary files in Replit's PostgreSQL — a single 5MB PDF eats into the 10GB database limit quickly. Use the multer npm package for file handling and upload to S3 using the AWS SDK.

### What Replit plan do I need?

Free tier is sufficient. Autoscale deployment is included, PostgreSQL is free with 10GB storage, and Replit Auth is built-in. The only external cost is SendGrid (free tier: 100 emails/day — more than enough for a small hiring team).

### Can I use this for contractor or freelance hiring, not just full-time?

Yes. The job_postings table has a type column with enum values including 'contract'. You can add additional types (freelance, internship) by updating the enum constraint in the schema. The pipeline stages work identically for all employment types.

### Should I deploy on Autoscale or Reserved VM?

Autoscale works well for a recruitment platform. Your team uses it during business hours with predictable patterns — the cold start of 10-30 seconds is acceptable when checking the pipeline a few times per day. Reserved VM makes sense only if you need the webhook from a job board integration (like LinkedIn application notifications).

### Can RapidDev help build a custom ATS for my company's hiring workflow?

Yes. RapidDev builds custom recruitment tools with advanced features like resume parsing, video interview links, offer letter generation, and HRIS integrations. We've built hiring platforms for 600+ companies. Free consultation available.

### How do I prevent duplicate applications for the same candidate and job?

The applications table has a unique constraint on (job_posting_id, candidate_id). If you try to insert a duplicate, PostgreSQL throws a unique violation error. In the POST /api/applications handler, catch this error and return a 409 Conflict response with 'Candidate already applied to this job'.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/recruitment-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/recruitment-platform
