
Every hiring team eventually hits the same wall. Applications arrive by email, someone copies names into a spreadsheet, resumes pile up in a shared drive, and by the time a good candidate gets a reply they have already accepted somewhere else. An applicant tracking system fixes that by giving every application one place to live and one path to follow.
We have built and maintained ATS platforms for recruitment agencies and in-house talent teams, nearly all of them on Node.js, and this guide is the engineering walkthrough we wish existed when we started. It covers how an ATS works under the hood, whether you should build one at all, what a custom build actually costs in effort, and then the full technical path: schema design, resume parsing, scoring, REST APIs, a React front end, integrations, testing, and deployment
If you are evaluating rather than building, the first three sections will tell you most of what you need. If you are here to write code, skip to the development environment.
How an ATS Actually Works: From Application to Hire
Most explanations of how an ATS works stop at “it filters resumes.” That undersells it and also misleads people into thinking there is a black box making decisions. There isn’t. An ATS is a pipeline, and every stage is something you can inspect.
1. Intake. An application enters the system from a careers page form, a job board feed, an email inbox, or a referral link. The system stores the raw file exactly as submitted and creates a candidate record.
2. Extraction. The resume gets converted from PDF or DOCX into plain text, then specific fields are pulled out: name, email, phone, employers, dates, education, skills.
3. Normalization. Extracted values get standardized. “Sr. Software Engineer,” “Senior Software Eng.,” and “Senior SWE” need to resolve to the same thing before any comparison is meaningful. Same for “JS” and “JavaScript,” or date formats across a dozen resume templates.
4. Scoring and ranking. Normalized candidate data gets compared against the requisition’s requirements, producing a match score. This is the stage people mean when they say a resume “got rejected by the ATS,” and in a well-built system it produces a ranking, not a verdict.
5. Workflow. The application moves through stages: applied, screening, interview, offer, hired, rejected. Each transition can trigger something else.
6. Communication. Status changes fire emails, interview invitations, and scheduling requests. Every message is logged against the candidate. If recruiters need to watch the pipeline move without refreshing, that is a job for WebSockets or server-sent events.
7. Reporting. The system aggregates the pipeline into metrics that tell you where hiring is actually breaking: time-to-hire, source quality, stage-level drop-off, offer acceptance rate.
The thing worth internalizing before you write any code: stages 3 and 4 are where ATS projects succeed or fail. Intake and workflow are ordinary CRUD. Normalization and scoring are where messy real-world input meets your assumptions, and where most of your engineering time will go.
Understanding ATS Components and What Each One Owns
The pipeline above maps onto five functional components. Getting the boundaries right early saves painful refactoring later.
Job posting. Recruiters create requisitions with titles, descriptions, qualifications, location, employment type, and salary band, then publish to a careers site and external boards. This component owns the requisition as the source of truth for what “qualified” means on that role.
Applicant tracking. The candidate journey from submission to decision. Filtering, sorting, stage management, notes, interview feedback, and collaborator access. This is the component recruiters live in all day, so its usability determines whether the system gets adopted or quietly abandoned.
Resume parsing. Extraction and structuring of resume content. Deliberately separate from applicant tracking, because parsing is a pure transformation you will want to re-run, version, and test in isolation.
Communication. Templated and ad-hoc email, interview scheduling, calendar integration, and a complete message history per candidate. Underestimated constantly. Deliverability, threading, and timezone handling are each a real chunk of work.
Reporting. Recruitment metrics and pipeline analytics. Build the event log early even if you build the dashboards late, because you cannot retroactively report on state changes you never recorded.
How to Implement an ATS: Build, Buy, or Customize
This is the decision that should come before any architecture discussion, and it is the one most teams skip. If you are researching how to implement an ATS, you have three real options.
| Buy SaaS | Customize a platform | Build custom | |
|---|---|---|---|
| Time to first use | Days | Weeks | Months |
| Upfront cost | Low | Moderate | High |
| Ongoing cost | Per seat, forever | Per seat plus dev | Hosting plus maintenance |
| Workflow fit | Whatever the vendor built | Close, within limits | Exact |
| Data ownership | Vendor’s infrastructure | Vendor’s infrastructure | Yours |
| Integration freedom | Available connectors only | API-dependent | Unlimited |
| Who maintains it | Vendor | Shared | You |
Buy off-the-shelf when your hiring process is reasonably conventional, your team is under roughly fifty seats, and you would rather pay a subscription than own a codebase. Greenhouse, Lever, Workable, Ashby, and Zoho Recruit all handle standard corporate recruiting well. Be honest about whether your process is genuinely unusual or just undocumented — most teams discover it is the latter.
Customize an existing platform when the core is a good fit but one or two workflows are wrong. You extend through the vendor’s API and webhooks. The catch is that you are now maintaining integration code against a roadmap you do not control, and a breaking change on their side becomes your emergency.
Build custom when at least two of these apply:
- Your hiring workflow is genuinely non-standard — high-volume hourly, technical assessment gates, multi-agency submission, staffing-firm placement tracking with client-side approvals
- You need deep integration with systems that have no existing connector: an in-house HRIS, a legacy payroll system, a proprietary assessment tool — each one its own piece of backend and API work
- Compliance requires data residency or audit controls the vendors will not give you
- Per-seat pricing has become the dominant line item because of headcount, and a one-time build plus maintenance is cheaper across three years
- The ATS is the product — you are building a recruitment platform to sell, not to use
If none of those apply, buy. We say that as an agency that gets paid to build these. A custom ATS that duplicates what Workable already does is an expensive way to reach the same place slower.
Custom ATS Software Development: Scope, Timeline, and Cost
Custom ATS software development gets quoted badly because “an ATS” describes anything from a job board with a database behind it to a multi-tenant recruitment platform. Here is how we actually scope one, in developer-weeks, split between a working MVP and a production system that a real hiring team can depend on.
| Module | MVP | Production |
|---|---|---|
| Authentication, roles, user management | 1 | 2-3 |
| Job requisitions and public careers page | 1-2 | 3-4 |
| Resume parsing and scoring | 2-3 | 4-6 |
| Pipeline and stage management | 1-2 | 3-4 |
| Email, templates, interview scheduling | 1-2 | 3-5 |
| Reporting and dashboards | 1 | 3-4 |
| External integrations | — | 1-3 per system |
| QA, security review, deployment, docs | 1-2 | 3-4 |
| Total | 10-15 developer-weeks | 24-35 developer-weeks |
Convert that to money with your own blended weekly rate rather than trusting anyone’s headline figure — rates vary several-fold between regions, and a quote without a scope table behind it is a guess. Then add ongoing maintenance; we plan for 15–20% of the original build cost annually for dependency updates, integration drift, and small feature work. Treat that as a planning rule of thumb, not a law.
What actually moves the number:
- Integration count. Each external system is its own project. Job boards, HRIS, calendars, assessment tools, background check vendors, e-signature — every one has its own auth model and failure modes.
- Parsing accuracy targets. Getting to roughly 80% field accuracy is fast. Getting past 95% across every resume format your candidates use is a long tail of edge cases, and each increment costs more than the last.
- Multi-tenancy. If agencies or clients need isolated data in one deployment, that decision has to be made on day one. Retrofitting tenant isolation into a single-tenant schema is close to a rewrite, because it reaches into every layer of the stack.
- Compliance scope. GDPR right-to-erasure, consent tracking, retention policies, and audit logging are each real work, not a checkbox.
- Volume. A system handling 200 applications a month and one handling 50,000 are different systems. Background job infrastructure, storage strategy, and search all change.
The cheapest way to control all of this is to ship the MVP scope, run real hiring through it for a month, and let the production scope be shaped by what actually broke.

Web-Based ATS vs Desktop and Hosted Options
A quick note on deployment shape, since it comes up early and changes your architecture.
A web-based ATS runs in the browser against a server you control. Recruiters, hiring managers, and interviewers reach it from anywhere, candidates apply through the same system, and you deploy fixes once. Every option below assumes this shape, and it is the right default for essentially all recruiting software — hiring is inherently multi-party and multi-location.
A desktop application makes sense only in narrow cases: an air-gapped network, or heavy local file processing you cannot move to a server. You lose candidate self-service entirely, since applicants cannot install your software. If someone is asking for a desktop ATS, they usually want an offline-capable web app instead, which is a different and easier problem.
Hosted SaaS means the vendor runs the web application. Same delivery model, someone else’s infrastructure and roadmap.
The rest of this guide builds a web-based ATS system: Node.js and Express on the server, MongoDB for storage, React in the browser.
Setting Up the Development Environment
Four things to install, plus a project skeleton.
1. Node.js. Install the current LTS release from nodejs.org. Check with node -v. If you work across projects on different versions, use nvm instead of a system-wide install.
2. npm. Ships with Node. Confirm with npm -v. pnpm or yarn work fine if you prefer them.
3. A code editor. VS Code with the ESLint and Prettier extensions is the common choice. Configure both before you write code, not after you have 5,000 inconsistent lines.
4. MongoDB. Either install locally, or create a free MongoDB Atlas cluster and skip local setup. Atlas is usually faster to get running and matches what you will deploy against.
Project structure
Two separate projects, backend and frontend. Do not nest them. If Express and REST fundamentals are new, start with the basics first and come back.
ats-backend/
├── src/
│ ├── app.js # Express app, no listen() call
│ ├── server.js # imports app, starts listening
│ ├── config/
│ │ └── db.js
│ ├── models/
│ │ ├── User.js
│ │ ├── Job.js
│ │ ├── Candidate.js
│ │ └── Application.js
│ ├── routes/
│ │ ├── auth.js
│ │ ├── jobs.js
│ │ ├── candidates.js
│ │ └── applications.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── upload.js
│ │ └── errorHandler.js
│ └── services/
│ ├── resumeParser.js
│ ├── scoring.js
│ └── mailer.js
├── tests/
├── .env.example
├── .gitignore
└── package.jsonThe split between app.js and server.js looks fussy and is not. Exporting the Express app without starting a listener is what makes the integration tests later in this guide possible.
Dependencies
$ mkdir ats-backend && cd ats-backend
$ npm init -y
$ npm install express mongoose dotenv cors cookie-parser bcrypt jsonwebtoken multer pdf-parse mammoth
$ npm install --save-dev jest supertestInstall fresh rather than copying a dependency list with pinned versions from any article, including this one. You want the current majors and their current security patches.
Add scripts to package.json:
{
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"test": "jest --runInBand"
}
}And an .env.example committed to the repo, with the real .env in .gitignore:
PORT=5000
MONGO_URI=mongodb://127.0.0.1:27017/ats
MONGO_URI_TEST=mongodb://127.0.0.1:27017/ats_test
JWT_SECRET=replace_me_with_a_long_random_string
CLIENT_ORIGIN=http://localhost:5173Building the Backend of the ATS Web Application
Express app setup
// src/app.js
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const jobRoutes = require('./routes/jobs');
const applicationRoutes = require('./routes/applications');
const errorHandler = require('./middleware/errorHandler');
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
origin: process.env.CLIENT_ORIGIN,
credentials: true, // required for the session cookie
})
);
app.use('/api/auth', authRoutes);
app.use('/api/jobs', jobRoutes);
app.use('/api/applications', applicationRoutes);
app.use(errorHandler);
module.exports = app;// src/server.js
require('dotenv').config();
const app = require('./app');
const connectDB = require('./config/db');
const PORT = process.env.PORT || 5000;
connectDB().then(() => {
app.listen(PORT, () => console.log(`API listening on ${PORT}`));
});Database connection
// src/config/db.js
const mongoose = require('mongoose');
async function connectDB() {
try {
await mongoose.connect(process.env.MONGO_URI);
console.log('MongoDB connected');
} catch (error) {
console.error(`MongoDB connection failed: ${error.message}`);
process.exit(1);
}
}
module.exports = connectDB;Standard Express middleware order applies: body parsing and cookies before routes, error handler last.
If you have seen older tutorials passing useNewUrlParser, useUnifiedTopology, useCreateIndex, and useFindAndModify here, drop all four. The first two became defaults and the last two were removed outright in Mongoose 6. Copying that snippet into a current project throws on startup.
Schema design
Four collections. Keeping Candidate separate from Application matters — one person applies to multiple roles, and you want their parsed profile stored once.
// src/models/Job.js
const mongoose = require('mongoose');
const jobSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true },
department: String,
location: String,
employmentType: {
type: String,
enum: ['full-time', 'part-time', 'contract', 'internship'],
default: 'full-time',
},
description: { type: String, required: true },
requiredSkills: [{ type: String, lowercase: true, trim: true }],
minYearsExperience: { type: Number, default: 0 },
status: { type: String, enum: ['draft', 'open', 'closed'], default: 'draft' },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
},
{ timestamps: true }
);
jobSchema.index({ status: 1, createdAt: -1 });
module.exports = mongoose.model('Job', jobSchema);// src/models/Application.js
const mongoose = require('mongoose');
const STAGES = ['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'];
const applicationSchema = new mongoose.Schema(
{
job: { type: mongoose.Schema.Types.ObjectId, ref: 'Job', required: true, index: true },
candidate: { type: mongoose.Schema.Types.ObjectId, ref: 'Candidate', required: true },
stage: { type: String, enum: STAGES, default: 'applied' },
matchScore: { type: Number, min: 0, max: 100 },
matchedSkills: [String],
missingSkills: [String],
resumePath: { type: String, required: true },
stageHistory: [
{
stage: { type: String, enum: STAGES },
changedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
changedAt: { type: Date, default: Date.now },
note: String,
},
],
},
{ timestamps: true }
);
applicationSchema.index({ job: 1, candidate: 1 }, { unique: true });
applicationSchema.index({ job: 1, matchScore: -1 });
module.exports = mongoose.model('Application', applicationSchema);Two details that pay off later. The compound unique index on job plus candidate stops duplicate applications at the database level instead of in application code you will forget to write. And stageHistory is the event log that makes time-in-stage reporting possible — add it now, because you cannot reconstruct it from a single stage field six months from now.
Authentication and authorization
Password hashing on the model, so no route can accidentally store a plaintext password.
// src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
passwordHash: { type: String, required: true },
role: {
type: String,
enum: ['admin', 'recruiter', 'hiring_manager'],
default: 'recruiter',
},
},
{ timestamps: true }
);
userSchema.methods.verifyPassword = function (plain) {
return bcrypt.compare(plain, this.passwordHash);
};
userSchema.statics.hashPassword = function (plain) {
return bcrypt.hash(plain, 12);
};
module.exports = mongoose.model('User', userSchema);The login route issues a JWT and stores it in an httpOnly cookie. Browser JavaScript cannot read it, which closes off the most common token-theft path.
// src/routes/auth.js
const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const router = express.Router();
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
// Identical response either way, so we don't leak which emails exist.
if (!user || !(await user.verifyPassword(password))) {
return res.status(401).json({ message: 'Incorrect email or password.' });
}
const token = jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: '8h',
});
res.cookie('ats_session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 8 * 60 * 60 * 1000,
});
res.json({ id: user.id, name: user.name, role: user.role });
} catch (error) {
next(error);
}
});
router.post('/logout', (req, res) => {
res.clearCookie('ats_session');
res.status(204).end();
});
module.exports = router;// src/middleware/auth.js
const jwt = require('jsonwebtoken');
function requireAuth(req, res, next) {
const token = req.cookies?.ats_session;
if (!token) return res.status(401).json({ message: 'Not authenticated.' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ message: 'Session expired.' });
}
}
function requireRole(...roles) {
return (req, res, next) =>
roles.includes(req.user.role)
? next()
: res.status(403).json({ message: 'Forbidden.' });
}
module.exports = { requireAuth, requireRole };One honest tradeoff: a stateless JWT cannot be revoked before it expires. Eight-hour expiry limits the damage, but if you need immediate revocation on termination — and in an ATS holding candidate PII, you probably do — use server-side sessions with express-session and a MongoDB store instead. Role-based access matters more here than in most CRUD apps, because a hiring manager should see their own requisitions and not the full candidate database.
Resume Parsing and Scoring in Node.js
This is the section that separates an ATS from a job application database, and the part people search for when they want an ATS resume validator or a resume builder that survives screening.
Extracting text
// src/services/resumeParser.js
const fs = require('node:fs/promises');
const path = require('node:path');
const pdfParse = require('pdf-parse');
const mammoth = require('mammoth');
async function extractText(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.pdf') {
const buffer = await fs.readFile(filePath);
const { text } = await pdfParse(buffer);
return text;
}
if (ext === '.docx') {
const { value } = await mammoth.extractRawText({ path: filePath });
return value;
}
throw new Error(`Unsupported resume format: ${ext}`);
}Two libraries do the work: pdf-parse for PDFs and mammoth for DOCX.
Accept PDF and DOCX, reject everything else at upload with a clear message. Legacy .doc needs a conversion step and is rarely worth supporting. A resume that is a scanned image produces empty text — detect that case and flag it for manual review rather than silently scoring the candidate at zero.
Pulling out fields
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/;
const PHONE_RE = /\+?\d[\d\s().-]{7,}\d/;
function extractContact(text) {
return {
email: text.match(EMAIL_RE)?.[0]?.toLowerCase() ?? null,
phone: text.match(PHONE_RE)?.[0]?.replace(/[\s().-]/g, '') ?? null,
};
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractSkills(text, skillDictionary) {
const haystack = text.toLowerCase();
return skillDictionary.filter((skill) => {
// Standard word boundaries break on skills like c++, c#, and .net,
// so we define our own boundary that excludes +, #, and .
const pattern = new RegExp(
`(^|[^a-z0-9+#.])${escapeRegex(skill.toLowerCase())}([^a-z0-9+#.]|$)`
);
return pattern.test(haystack);
});
}Skills come from a curated dictionary you maintain, seeded from the requiredSkills across your open requisitions and extended as you see real resumes. This beats trying to detect skills generically, and it keeps the whole thing debuggable.
Employment dates are the hardest field. Resumes write them as Jan 2023 - Present, 01/2023–current, 2023-2026, and every other permutation. Build a small set of pattern handlers, log every string that matches none of them, and add handlers based on that log. Expect this to be an ongoing task, not a completed one.
Scoring against the requisition
// src/services/scoring.js
function scoreApplication(parsed, job) {
const required = job.requiredSkills.map((s) => s.toLowerCase());
const matched = required.filter((skill) => parsed.skills.includes(skill));
const skillScore = required.length ? matched.length / required.length : 1;
const experienceScore = job.minYearsExperience
? Math.min(parsed.yearsExperience / job.minYearsExperience, 1)
: 1;
return {
score: Math.round((skillScore * 0.7 + experienceScore * 0.3) * 100),
matchedSkills: matched,
missingSkills: required.filter((skill) => !matched.includes(skill)),
};
}
module.exports = { scoreApplication };Deliberately simple, and deliberately transparent. A recruiter can look at any score and see exactly which skills matched and which did not. The 70/30 weighting is a starting point — tune it against roles you have already filled successfully, not against intuition.
Three rules we hold to on every build:
Rank, never auto-reject. Sort the pipeline by score and let a human decide. Parsing is imperfect, and a strong candidate with an unusual resume format should not be discarded by a regex.
Store the parsed output alongside the raw file. When you improve the parser, you re-run it against stored resumes and compare. Without the original file you cannot.
Keep scoring auditable. Persist matchedSkills and missingSkills on the application, as the schema above does. When a recruiter asks why someone scored 40, you can answer.
That last point is not only good practice. Automated hiring tools are increasingly regulated. New York City’s Local Law 144 has required an annual independent bias audit, a public summary of the results, and advance notice to candidates since enforcement began in July 2023 — the DCWP guidance sets out what counts as a covered tool. The EU AI Act classifies recruitment and candidate-screening systems as high-risk under Annex III, though the compliance deadline for those obligations has shifted. A scoring model you can explain, log, and audit is far easier to defend than an opaque one. Check where the rules currently stand for the jurisdictions you hire in.
Running parsing in the background
Parsing a resume takes a second or two. Do not make the candidate wait for it.
// src/routes/applications.js — excerpt
router.post('/', upload.single('resume'), async (req, res, next) => {
try {
const application = await Application.create({
job: req.body.jobId,
candidate: candidate.id,
resumePath: req.file.path,
stage: 'applied',
});
// Respond immediately; parse and score after.
res.status(201).json({ id: application.id, status: 'received' });
parseAndScore(application.id).catch((error) =>
console.error(`Parse failed for ${application.id}: ${error.message}`)
);
} catch (error) {
next(error);
}
});Fire-and-forget is fine at low volume. Past a few hundred applications a day, move this to a real queue — BullMQ with Redis — so failed parses retry instead of vanishing into a log line. More on how Node.js handles concurrency under load.
Building the Frontend of the ATS Web Application
Scaffolding the client
$ npm create vite@latest ats-frontend -- --template react
$ cd ats-frontend
$ npm install
$ npm install axios react-router-dom
$ npm run devCreate React App used to be the default here and is no longer recommended — the React team has deprecated it. Vite is the current standard for a client-side React app, and Next.js is the better choice if you want server rendering for the public careers pages, which helps those job listings get indexed.
API client
// src/api/client.js
import axios from 'axios';
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true, // sends the httpOnly session cookie
});
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.assign('/login');
}
return Promise.reject(error);
}
);
export default client;Note import.meta.env.VITE_* rather than process.env.REACT_APP_* — that is a Vite convention, and only variables prefixed VITE_ are exposed to the browser. Never put a secret in one.
withCredentials: true is what sends the session cookie, and it only works if the server’s CORS config sets credentials: true with an explicit origin. A wildcard origin silently breaks it.
Screens to build first
Four, in this order:
- Login. Everything else is gated behind it.
- Job list and job form. You need requisitions before applications mean anything.
- Pipeline board. Applications for one job, grouped by stage, sortable by match score. This is where recruiters spend their day, so it deserves the most design attention.
- Candidate detail. Parsed profile, matched and missing skills, resume preview, stage history, notes.
Then the public careers page and application form, which is a separate unauthenticated surface.
For styling, Tailwind CSS is the common default now; Bootstrap or MUI are fine if your team already knows them. Pick one and stop thinking about it — an ATS wins on information density and speed, not visual novelty.
Connecting the Frontend and Backend
REST API surface
The table below follows the conventions in our REST API guide: resource-based paths, verbs that carry the intent, status codes that mean something.
| Method | Endpoint | Access | Purpose |
|---|---|---|---|
| POST | /api/auth/login | Public | Start a session |
| POST | /api/auth/logout | Authenticated | End a session |
| GET | /api/jobs | Authenticated | List requisitions |
| POST | /api/jobs | Recruiter, admin | Create a requisition |
| PATCH | /api/jobs/:id | Recruiter, admin | Update or publish |
| GET | /api/jobs/:id/applications | Authenticated | Pipeline for one job |
| POST | /api/applications | Public | Candidate submission |
| PATCH | /api/applications/:id/stage | Recruiter, admin | Move stage |
| GET | /api/candidates/:id | Authenticated | Parsed profile |
Note which endpoints are public. POST /api/applications has to be, because candidates are not logged in — which makes it the one route needing rate limiting and file-type validation from day one. express-rate-limit plus a size cap on uploads handles the obvious abuse.
Example protected route
// src/routes/jobs.js
const express = require('express');
const Job = require('../models/Job');
const { requireAuth, requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/', requireAuth, async (req, res, next) => {
try {
const filter = req.user.role === 'hiring_manager' ? { createdBy: req.user.sub } : {};
const jobs = await Job.find(filter).sort({ createdAt: -1 }).lean();
res.json(jobs);
} catch (error) {
next(error);
}
});
router.post('/', requireAuth, requireRole('recruiter', 'admin'), async (req, res, next) => {
try {
const job = await Job.create({ ...req.body, createdBy: req.user.sub });
res.status(201).json(job);
} catch (error) {
next(error);
}
});
module.exports = router;Central error handling
// src/middleware/errorHandler.js
module.exports = (error, req, res, next) => {
if (error.name === 'ValidationError') {
return res.status(400).json({
message: 'Validation failed.',
fields: Object.keys(error.errors),
});
}
if (error.code === 11000) {
return res.status(409).json({ message: 'This record already exists.' });
}
console.error(error);
res.status(500).json({ message: 'Something went wrong.' });
};Error code 11000 is a MongoDB duplicate key violation. With the compound index from earlier, that is what a repeat application looks like, and a 409 with a clear message is much better than a 500.
Integrating Your ATS with Job Boards, HRIS, and Legacy Systems
An ATS that does not talk to anything else creates a new silo instead of removing one. Integration work is usually where the real value is, and it is also the most commonly underestimated part of the project.
Outbound job distribution. Publishing requisitions to LinkedIn, Indeed, and niche boards. Some accept XML or JSON feeds you host and they poll; others have posting APIs with their own auth. Build an internal adapter interface so each board is a small module implementing the same contract, rather than board-specific branches scattered through your posting logic.
Inbound applications. Applications arriving from external sources need to hit the same intake pipeline as your careers page. Normalize at the boundary: each source gets an adapter that converts its payload into your internal application shape, and everything downstream stays source-agnostic.
HRIS and payroll sync. When a candidate is marked hired, an employee record should appear in the HR system without anyone retyping it. Sync one direction only unless you have a genuine reason otherwise — bidirectional sync means conflict resolution, and conflict resolution means bugs that corrupt employee data.
Calendar and email. Google Calendar or Microsoft Graph for interview scheduling. Both use OAuth, both need token refresh handled properly, and both will surface every timezone bug in your codebase.
Integration middleware. Once you have more than two or three integrations, put a thin middleware layer between your ATS and the outside world: a queue for outbound calls, retry with exponential backoff, a dead letter queue, and structured logs of every request and response. Without it, one flaky vendor API takes down your application intake, and you will not know why.
Migrating off a legacy ATS. Rarely a clean export. Plan for it as its own phase, the way you would any legacy system migration:
- Export everything the old system will give you, including attachments, and archive the raw export untouched.
- Map fields explicitly. Stage names never match; write the mapping table down and get recruiters to sign off on it.
- Import into a staging environment and have a recruiter verify a real sample against the old system.
- Run both systems in parallel for one hiring cycle. New applications go to the new ATS, in-flight candidates finish in the old one.
- Keep the legacy system readable for as long as your data retention policy requires.
Budget one to three developer-weeks per integration, and more for the migration if the legacy export is poor.
Testing and Launching Your ATS
Integration tests
ATS application testing is mostly integration testing, because the risks live at the seams: does an unauthenticated request get rejected, does a duplicate application fail cleanly, does a parsed resume produce the score you expect. This is where exporting app separately from server pays off.
// tests/jobs.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../src/app');
const User = require('../src/models/User');
describe('POST /api/jobs', () => {
let agent;
beforeAll(async () => {
await mongoose.connect(process.env.MONGO_URI_TEST);
await User.create({
name: 'Test Recruiter',
email: 'recruiter@example.com',
passwordHash: await User.hashPassword('password123'),
role: 'recruiter',
});
agent = request.agent(app);
await agent
.post('/api/auth/login')
.send({ email: 'recruiter@example.com', password: 'password123' });
});
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
it('rejects unauthenticated requests', async () => {
await request(app).post('/api/jobs').send({ title: 'Backend Engineer' }).expect(401);
});
it('creates a job for an authenticated recruiter', async () => {
const response = await agent
.post('/api/jobs')
.send({
title: 'Backend Engineer',
description: 'Node.js and MongoDB',
requiredSkills: ['node.js', 'mongodb'],
})
.expect(201);
expect(response.body.title).toBe('Backend Engineer');
expect(response.body.status).toBe('draft');
});
});request.agent(app) keeps cookies between requests, which is how you test authenticated flows without hand-rolling tokens. Point tests at a separate test database — MONGO_URI_TEST — and drop it in teardown.
Unit-test the parser against a fixture folder of real resumes with known expected output. It is the highest-value test suite in the project, because parser regressions are silent: nothing errors, scores just quietly get worse — and silent failures are the hardest kind to track down.
Deployment
Heroku is still in a lot of older tutorials, and its free tier ended in 2022, so git push heroku master is no longer the cheap default it once was. Current options:
- A VPS with PM2 and nginx. Most control and the best cost at scale. PM2 keeps the Node process alive and clustered; nginx terminates TLS and serves the built React files. This is what we run for most client deployments.
- A managed platform — Render, Railway, or Fly.io. Push to deploy, less to operate, more per month. Good for an MVP you want live this week.
- A container on AWS, GCP, or Azure. Right answer if you are already there or have compliance requirements pointing that way.
Store resumes in object storage — S3, Cloudflare R2, or equivalent — not on the application server’s disk. The moment you run two instances, local files stop being visible to both, and container filesystems do not survive a redeploy.
Pre-launch checklist
- Uploads validated by type and size; rate limiting on the public application endpoint (the OWASP Top Ten covers the rest)
- Resumes in object storage with private ACLs and time-limited signed URLs
- Automated database backups, and a restore you have actually tested
- Secrets in environment variables, never in the repo;
JWT_SECRETlong and random - HTTPS everywhere;
secureandhttpOnlyset on session cookies in production - Role-based access verified per endpoint, including the negative cases
- GDPR basics: retention policy, consent capture on the application form, working deletion path
- Error tracking and uptime monitoring wired up before launch, not after the first outage
- One real requisition run end to end by an actual recruiter before anyone else gets access
That last item catches more problems than the rest of the list combined. For the wider picture, see our data security practices.
Conclusion
An ATS is not a hard system to build badly. Job posting, application intake, and a pipeline board are a weekend for an experienced Node.js developer. What takes months is everything around them: parsing that survives real resumes, scoring you can explain to a recruiter and defend to a regulator, integrations that fail gracefully, and a migration off whatever the team is using now.
If you are deciding whether to build at all, the honest answer is that most conventional hiring teams should buy. Build when your workflow is genuinely unusual, when integration requirements rule the vendors out, when data control is non-negotiable, or when the ATS is the product you are selling.
And if you are building, the sequence that works is small: authentication, requisitions, application intake, a pipeline board. Run one real role through it. Let what breaks decide what you build next.
We do custom ATS software development and recruitment platform work, including migrations off existing systems. We also build and run Emplyft, our own HR operations product, so most of the problems above are ones we have hit ourselves. If you want a scoped estimate against the module table rather than a generic quote, get in touch.

Further reading: Harnessing the Power of Node.js for Scalable and Fast Web Development
Frequently asked questions
Any general-purpose backend language works. Java, C#, Python, PHP, Ruby, and Node.js all run production ATS platforms today. This guide uses Node.js because resume parsing and scoring are I/O-heavy work that suits its concurrency model, and because sharing JavaScript across server and browser reduces the context switching on a small team. Pick what your team can maintain over the next five years, not what is fashionable now.
Yes. Vue, Svelte, and Angular all work against the same REST API, and Next.js is worth considering if you want server-rendered public careers pages for better job listing indexing. Only the scaffolding and component code change; the API surface stays identical.
Roughly 10 to 15 developer-weeks for an MVP covering authentication, job posting, application intake, resume parsing, and a pipeline board. A production system with integrations, reporting, scheduling, and compliance features runs closer to 24 to 35 developer-weeks. Elapsed calendar time depends on team size and how fast decisions get made — a two-developer team typically ships the MVP in two to three months. Anyone quoting days is describing a prototype, not a system a hiring team can depend on.
Buy unless you have a specific reason not to. Build when your hiring workflow is genuinely non-standard, when you need integration with systems no vendor supports, when compliance requires data control the vendors will not provide, or when per-seat pricing across your headcount exceeds a build plus its maintenance over three years. See the build-versus-buy comparison earlier in this guide.
Around 80% field-level accuracy is achievable quickly with the techniques in this guide. Getting past 95% across every format your candidates submit is a long tail of edge cases, each costing more than the last. Design for imperfection: rank candidates rather than rejecting them, flag low-confidence extractions for manual review, and store the original file so you can re-parse when the parser improves.
That is usually the main reason to build one. Anything with an API or a file-based feed can be integrated — job boards for distribution, HRIS for hire handoff, calendars for scheduling, assessment and background check vendors mid-pipeline. Budget one to three developer-weeks per integration and put a middleware layer with retries and logging between your ATS and every external system.
Hosting, database, object storage, and email delivery for a small-to-mid volume system land in the low hundreds of dollars a month on a VPS or managed platform. The larger ongoing number is maintenance: we plan for 15–20% of the original build cost per year covering dependency updates, integration drift, and small feature work. Compare that against per-seat SaaS pricing multiplied by your headcount over three years to see whether building actually pays back.


