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.
What you're building
A recruitment platform is an internal tool for your hiring team — a place to manage job openings, track candidates through interview stages, schedule interviews, and measure how efficiently you hire. Most teams start in spreadsheets, but lose track of candidate status, miss follow-ups, and can't measure their pipeline health. An ATS solves all three problems.
Replit Agent builds this as an Express backend with five tables and a Kanban-style React frontend. The core workflow is the pipeline stage system: each application moves through stages (Applied → Screening → Interview → Offer → Hired or Rejected), and every stage change is logged in pipeline_history with who made the change and when. This audit trail lets you calculate time-to-hire and identify where candidates drop off.
The most technically useful feature is the reporting layer. Two queries give you immediate hiring insights: time-to-hire (average days from application to hired status, by job) and source effectiveness (candidates by source with their hire rates). These queries run against the pipeline_history table, which is why logging every stage change matters. Replit Auth restricts the platform to authenticated users, and Autoscale handles the deployment — recruitment tools are used during business hours with predictable traffic patterns.
Final result
A fully functional ATS with job management, candidate pipeline Kanban, interview scheduling, pipeline audit history, and hiring analytics — running on your Replit PostgreSQL database.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Build a recruitment platform (ATS) with Express and PostgreSQL using Drizzle ORM.2// Use Replit Auth for authentication.3//4// Tables:5// 1. job_postings: id serial primary key, title text not null, department text,6// description text not null, requirements text, location text,7// type text default 'full_time' (enum: full_time/part_time/contract/remote),8// salary_min integer, salary_max integer,9// status text default 'draft' (enum: draft/open/closed), posted_by text not null,10// created_at timestamp default now()11// 2. candidates: id serial primary key, name text not null, email text unique not null,12// phone text, resume_url text, linkedin_url text,13// source text (enum: direct/referral/linkedin/indeed/other),14// notes text, created_at timestamp default now()15// 3. applications: id serial primary key,16// job_posting_id integer references job_postings not null,17// candidate_id integer references candidates not null,18// stage text default 'applied' (enum: applied/screening/interview/offer/hired/rejected),19// cover_letter text, applied_at timestamp default now(),20// updated_at timestamp default now(), unique(job_posting_id, candidate_id)21// 4. interviews: id serial primary key,22// application_id integer references applications not null,23// interviewer text not null, scheduled_at timestamp not null,24// duration_minutes integer default 60,25// type text default 'video' (enum: phone/video/onsite),26// location text, feedback text, score integer (1-5),27// status text default 'scheduled' (enum: scheduled/completed/cancelled),28// created_at timestamp default now()29// 5. pipeline_history: id serial primary key,30// application_id integer references applications not null,31// from_stage text, to_stage text not null,32// changed_by text not null, notes text, created_at timestamp default now()33//34// Routes:35// GET/POST /api/jobs, PATCH /api/jobs/:id/status36// GET /api/jobs/:id/applications, POST /api/candidates37// POST /api/applications, PATCH /api/applications/:id/stage38// POST/GET /api/interviews, PATCH /api/interviews/:id/feedback39// GET /api/pipeline/:jobId (Kanban — applications grouped by stage)40// GET /api/reports/time-to-hire, GET /api/reports/source-effectiveness41//42// 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.
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.
1const express = require('express');2const { db } = require('../db');3const { applications, pipelineHistory, interviews } = require('../schema');4const { eq, and } = require('drizzle-orm');5const { withDbRetry } = require('../lib/retryDb');67const router = express.Router();89const VALID_STAGES = ['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'];1011router.patch('/api/applications/:id/stage', express.json(), async (req, res) => {12 if (!req.user) return res.status(401).json({ error: 'Login required' });13 const { stage, notes } = req.body;1415 if (!VALID_STAGES.includes(stage)) {16 return res.status(400).json({ error: `Invalid stage. Must be: ${VALID_STAGES.join(', ')}` });17 }1819 const appId = parseInt(req.params.id);2021 // Get current application22 const [app] = await db.select().from(applications)23 .where(eq(applications.id, appId)).limit(1);24 if (!app) return res.status(404).json({ error: 'Application not found' });2526 if (app.stage === stage) {27 return res.status(400).json({ error: 'Application is already in this stage' });28 }2930 // Update stage and log to pipeline_history atomically31 await withDbRetry(async () => {32 await db.update(applications)33 .set({ stage, updatedAt: new Date() })34 .where(eq(applications.id, appId));3536 await db.insert(pipelineHistory).values({37 applicationId: appId,38 fromStage: app.stage,39 toStage: stage,40 changedBy: req.user.id,41 notes: notes || null,42 });43 });4445 return res.json({ id: appId, stage, movedFrom: app.stage });46});4748// GET Kanban view — applications grouped by stage for a job49router.get('/api/pipeline/:jobId', async (req, res) => {50 if (!req.user) return res.status(401).json({ error: 'Login required' });51 const jobId = parseInt(req.params.jobId);5253 const rows = await db.execute({54 sql: `55 SELECT a.id, a.stage, a.applied_at, a.updated_at,56 c.id AS candidate_id, c.name, c.email, c.source,57 c.linkedin_url, c.resume_url,58 EXTRACT(DAY FROM NOW() - a.updated_at) AS days_in_stage,59 COUNT(i.id) AS interview_count60 FROM applications a61 JOIN candidates c ON a.candidate_id = c.id62 LEFT JOIN interviews i ON i.application_id = a.id63 WHERE a.job_posting_id = $164 GROUP BY a.id, c.id65 ORDER BY a.updated_at DESC66 `,67 params: [jobId],68 });6970 // Group by stage for Kanban71 const pipeline = {};72 for (const stage of VALID_STAGES) pipeline[stage] = [];73 for (const row of rows.rows) {74 pipeline[row.stage]?.push(row);75 }7677 return res.json({ jobId, pipeline });78});7980module.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.
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.
1const express = require('express');2const { db } = require('../db');3const { interviews } = require('../schema');4const { eq, and } = require('drizzle-orm');56const router = express.Router();78router.post('/api/interviews', express.json(), async (req, res) => {9 if (!req.user) return res.status(401).json({ error: 'Login required' });10 const { applicationId, interviewer, scheduledAt, durationMinutes, type, location } = req.body;1112 if (!applicationId || !interviewer || !scheduledAt) {13 return res.status(400).json({ error: 'applicationId, interviewer, and scheduledAt are required' });14 }1516 const row = await db.insert(interviews).values({17 applicationId: parseInt(applicationId),18 interviewer,19 scheduledAt: new Date(scheduledAt),20 durationMinutes: durationMinutes || 60,21 type: type || 'video',22 location: location || null,23 }).returning();2425 return res.status(201).json(row[0]);26});2728router.get('/api/interviews', async (req, res) => {29 if (!req.user) return res.status(401).json({ error: 'Login required' });30 const { applicationId } = req.query;3132 const rows = await db.select().from(interviews)33 .where(applicationId ? eq(interviews.applicationId, parseInt(applicationId)) : undefined)34 .orderBy(interviews.scheduledAt);3536 return res.json({ interviews: rows });37});3839router.patch('/api/interviews/:id/feedback', express.json(), async (req, res) => {40 if (!req.user) return res.status(401).json({ error: 'Login required' });41 const { feedback, score, status } = req.body;4243 if (score && (score < 1 || score > 5)) {44 return res.status(400).json({ error: 'Score must be 1-5' });45 }4647 const updated = await db.update(interviews)48 .set({49 feedback: feedback || null,50 score: score ? parseInt(score) : null,51 status: status || 'completed',52 })53 .where(eq(interviews.id, parseInt(req.params.id)))54 .returning();5556 if (!updated[0]) return res.status(404).json({ error: 'Interview not found' });57 return res.json(updated[0]);58});5960module.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.
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.
1const express = require('express');2const { db } = require('../db');34const router = express.Router();56// Average days from application to hired, by job7router.get('/api/reports/time-to-hire', async (req, res) => {8 if (!req.user) return res.status(401).json({ error: 'Login required' });910 const result = await db.execute({11 sql: `12 SELECT13 jp.id AS job_id,14 jp.title AS job_title,15 COUNT(a.id) AS hired_count,16 ROUND(AVG(17 EXTRACT(DAY FROM ph_hired.created_at - a.applied_at)18 )) AS avg_days_to_hire19 FROM applications a20 JOIN job_postings jp ON a.job_posting_id = jp.id21 JOIN pipeline_history ph_hired22 ON ph_hired.application_id = a.id23 AND ph_hired.to_stage = 'hired'24 WHERE a.stage = 'hired'25 GROUP BY jp.id, jp.title26 ORDER BY avg_days_to_hire ASC27 `,28 params: [],29 });3031 return res.json({ report: result.rows });32});3334// Candidates by source with hire rates35router.get('/api/reports/source-effectiveness', async (req, res) => {36 if (!req.user) return res.status(401).json({ error: 'Login required' });3738 const result = await db.execute({39 sql: `40 SELECT41 c.source,42 COUNT(DISTINCT c.id) AS total_candidates,43 COUNT(DISTINCT CASE WHEN a.stage = 'hired' THEN c.id END) AS hired,44 COUNT(DISTINCT CASE WHEN a.stage = 'rejected' THEN c.id END) AS rejected,45 ROUND(46 100.0 * COUNT(DISTINCT CASE WHEN a.stage = 'hired' THEN c.id END)47 / NULLIF(COUNT(DISTINCT c.id), 0)48 ) AS hire_rate_pct49 FROM candidates c50 LEFT JOIN applications a ON a.candidate_id = c.id51 GROUP BY c.source52 ORDER BY hire_rate_pct DESC NULLS LAST53 `,54 params: [],55 });5657 return res.json({ report: result.rows });58});5960module.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.).
Build the Kanban pipeline React frontend
Ask Agent to build the Kanban board and candidate detail panel — the main interface recruiters use daily.
1// Ask Agent to build the React frontend with this prompt:2// Build a React ATS frontend with these pages:3//4// 1. Jobs List page (/):5// - Table of job_postings with columns: Title, Department, Type, Salary Range,6// Status badge (draft=gray, open=green, closed=red), Application Count7// - 'Create Job' button opens a modal form8// - Click job row navigates to the Pipeline page for that job9// - Status toggle button: open <-> closed10//11// 2. Pipeline Kanban page (/jobs/:id/pipeline):12// - Fetch GET /api/pipeline/:jobId13// - Six columns: Applied, Screening, Interview, Offer, Hired, Rejected14// - Each column has a count badge and a scrollable list of candidate cards15// - Candidate card shows: name, source badge, days-in-stage indicator16// (orange if >7 days, red if >14 days), interview count badge17// - Drag card between columns calls PATCH /api/applications/:id/stage18// - Click card opens the Candidate Detail panel19//20// 3. Candidate Detail panel (right-side sheet):21// - Shows: name, email, phone, source, resume link, LinkedIn link22// - Application history across all jobs as a timeline23// - Interview list with scheduled time, interviewer, score (star display)24// - 'Add Interview' button opens scheduling dialog25// - 'Add Note' textarea that updates candidate notes26// - Pipeline history timeline showing stage changes with timestamps27//28// 4. Reports page (/reports):29// - Two sections: Time to Hire (bar chart by job) and Source Effectiveness (table)30// - Bar chart uses recharts BarChart31//32// 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
1const express = require('express');2const { db } = require('../db');3const { applications, pipelineHistory, candidates } = require('../schema');4const { eq, and } = require('drizzle-orm');5const { withDbRetry } = require('../lib/retryDb');67const router = express.Router();8const VALID_STAGES = ['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'];910router.post('/api/applications', express.json(), async (req, res) => {11 if (!req.user) return res.status(401).json({ error: 'Login required' });12 const { jobPostingId, candidateId, coverLetter } = req.body;13 if (!jobPostingId || !candidateId) {14 return res.status(400).json({ error: 'jobPostingId and candidateId are required' });15 }16 const row = await withDbRetry(() =>17 db.insert(applications).values({18 jobPostingId: parseInt(jobPostingId),19 candidateId: parseInt(candidateId),20 coverLetter: coverLetter || null,21 }).returning()22 );23 return res.status(201).json(row[0]);24});2526router.patch('/api/applications/:id/stage', express.json(), async (req, res) => {27 if (!req.user) return res.status(401).json({ error: 'Login required' });28 const { stage, notes } = req.body;29 if (!VALID_STAGES.includes(stage)) {30 return res.status(400).json({ error: `Invalid stage. Must be: ${VALID_STAGES.join(', ')}` });31 }32 const appId = parseInt(req.params.id);33 const [app] = await db.select().from(applications)34 .where(eq(applications.id, appId)).limit(1);35 if (!app) return res.status(404).json({ error: 'Application not found' });36 if (app.stage === stage) {37 return res.status(400).json({ error: 'Already in this stage' });38 }39 await withDbRetry(async () => {40 await db.update(applications)41 .set({ stage, updatedAt: new Date() })42 .where(eq(applications.id, appId));43 await db.insert(pipelineHistory).values({44 applicationId: appId,45 fromStage: app.stage,46 toStage: stage,47 changedBy: req.user.id,48 notes: notes || null,49 });50 });51 return res.json({ id: appId, stage, previousStage: app.stage });52});5354router.get('/api/pipeline/:jobId', async (req, res) => {55 if (!req.user) return res.status(401).json({ error: 'Login required' });56 const result = await db.execute({57 sql: `SELECT a.id, a.stage, a.applied_at, a.updated_at,58 c.id AS candidate_id, c.name, c.email, c.source, c.resume_url,59 EXTRACT(DAY FROM NOW() - a.updated_at) AS days_in_stage60 FROM applications a JOIN candidates c ON a.candidate_id = c.id61module.exports = router;Customization ideas
Automated rejection emails
When an application is moved to 'rejected' stage, automatically send a rejection email via SendGrid using a customizable template. Store SENDGRID_API_KEY in Replit Secrets and call the API in the stage transition handler.
Candidate scoring matrix
Add a criteria_scores table where interviewers rate candidates on predefined dimensions (technical skill, communication, culture fit). Calculate an aggregate weighted score per candidate for objective comparison in the offer stage.
Job posting public page
Add a public GET /jobs/:id endpoint that renders job details without auth. Include an 'Apply' form that creates a candidate + application via POST /api/applications without requiring login — perfect for embedding on your company website.
Bulk import candidates from CSV
Add a POST /api/candidates/import route that accepts a CSV file with name, email, phone, and source columns. Parse with csv-parse npm package, bulk insert into candidates, and return a summary of how many were added vs duplicate emails skipped.
Common pitfalls
Pitfall: Not logging pipeline stage changes in pipeline_history
How to avoid: 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.
Pitfall: Missing the unique constraint on applications(job_posting_id, candidate_id)
How to avoid: 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.
Pitfall: Forgetting to add SENDGRID_API_KEY to Deployment Secrets
How to avoid: After testing locally, open the Publish pane → Secrets, add SENDGRID_API_KEY with the same value. Redeploy to activate.
Pitfall: Loading all candidate data for the Kanban view in one query
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a recruitment ATS with Express and PostgreSQL using Drizzle ORM. I have applications, pipeline_history, candidates, and job_postings tables. Help me write two reporting queries: (1) time-to-hire per job — average days from applied_at to the pipeline_history row where to_stage='hired', grouped by job_posting_id with job title; (2) source effectiveness — candidates by source with total count, hired count, rejected count, and hire rate percentage. Both queries should handle jobs with no hired candidates by returning null or 0 for those metrics.
Add email automation to the recruitment platform. When an application moves to 'interview' stage, automatically send a calendar invite email to the candidate via SendGrid. When moving to 'rejected', send a rejection email. Build a GET /api/email-templates route that returns customizable templates stored in an email_templates table (id, stage text, subject text, body_html text with {{candidate_name}} and {{job_title}} placeholders). Build a PUT /api/email-templates/:stage route for recruiters to update templates. In the PATCH /api/applications/:id/stage handler, after logging to pipeline_history, check if an email template exists for the new stage, render it with actual values, and call SendGrid. Store SENDGRID_API_KEY in Replit Secrets.
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'.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation