Combine everything you've learned to architect, build, and deploy a production-ready REST API from scratch.
Throughout this Node.js track, you have learned individual concepts in isolation — routing, middleware, databases, authentication, error handling, validation, caching, Docker, deployment. Now it is time to bring everything together into a single, cohesive, production-ready application. This is the capstone lesson where theory meets practice.
We are going to architect a Task Manager API — a real-world project that demonstrates every concept you have learned. This is not a toy example. Task management is a legitimate product domain (Todoist, Asana, Trello, Linear all started as variations of this idea), and building one from scratch forces you to make the same architectural decisions real engineering teams face.
Here is the complete feature set:
This project is your portfolio piece. When you apply for backend developer roles, a well-built REST API demonstrates that you understand not just the individual tools, but how they fit together to create a professional application.
A well-structured project is a joy to work in. A poorly structured one becomes a nightmare as it grows. Before writing a single route handler, invest time in setting up your project architecture correctly.
Initialize the project:
mkdir task-manager-api && cd task-manager-api
npm init -y
npm install express mongoose jsonwebtoken bcryptjs dotenv cors helmet express-rate-limit zod
npm install -D nodemon jest supertestFolder structure (MVC + Services pattern):
task-manager-api/
src/
config/ # Environment validation, DB connection
db.js
env.js
middleware/ # Auth, error handler, rate limiter, validation
auth.js
errorHandler.js
rateLimiter.js
validate.js
models/ # Mongoose schemas
User.js
Task.js
routes/ # Express routers
auth.routes.js
task.routes.js
controllers/ # Route handlers (thin — delegate to services)
auth.controller.js
task.controller.js
services/ # Business logic (thick — reusable)
auth.service.js
task.service.js
utils/ # Helpers, custom errors, constants
errors.js
asyncHandler.js
app.js # Express app setup (middleware, routes)
server.js # Entry point (starts HTTP server)
tests/ # Integration tests
.env # Environment variables (git-ignored)
.env.example # Template showing required vars
Dockerfile
docker-compose.yml
package.jsonWhy separate app.js and server.js? The app.js file creates and configures the Express application (middleware, routes, error handlers) and exports it. The server.js file imports the app and starts the HTTP server. This separation lets your tests import app.js directly without starting a real server — Supertest needs the app, not a running server.
Environment validation is your first line of defense. At startup, validate that all required environment variables are present. If DATABASE_URL or JWT_SECRET is missing, crash immediately with a clear error. Do not let the app start and fail later when someone tries to log in. Use a simple validation function or Zod to parse process.env.
Database connection belongs in config/db.js. Connect to MongoDB with Mongoose, handle connection errors, and log the connection status. In production, use connection pooling and reconnection logic. Export the connection function so server.js can call it before starting the HTTP server — your app should not accept requests until the database is connected.
Authentication is the foundation of your API. Every protected route depends on it, so it must be rock-solid.
User Model — Define a Mongoose schema for users with these fields: name (required, trimmed), email (required, unique, lowercase, validated with regex), password (required, minimum 8 characters, never returned in queries), and role (enum: 'user' or 'admin', defaults to 'user'). Add timestamps for createdAt and updatedAt.
The critical pattern is the pre-save hook for password hashing:
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});This hook runs before every save() call. The isModified('password') check is essential — without it, the password would be re-hashed every time you update any field on the user document. The cost factor (12) determines how computationally expensive the hash is — higher means more secure but slower. 12 is the current recommended minimum for production.
Add an instance method for password comparison:
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};Registration Route (POST /api/auth/register):
Login Route (POST /api/auth/login):
.select('+password') because the password field should be excluded from queries by default (set select: false in the schema)Auth Middleware:
const authMiddleware = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new AuthenticationError('No token provided');
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id);
if (!user) throw new AuthenticationError('User no longer exists');
req.user = user;
next();
};The middleware extracts the JWT from the Authorization header, verifies it, loads the user from the database (to ensure the user still exists and has not been deleted), and attaches the user to req.user for downstream route handlers to use. If any step fails, it throws an authentication error that the centralized error handler catches.
The Task model is the core of your application. It has a relationship with the User model — each task belongs to a user, and each user can have many tasks. This is a one-to-many relationship, implemented in MongoDB/Mongoose by storing the user's ObjectId in the task document.
Task Model:
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
description: { type: String, trim: true, maxlength: 2000 },
status: { type: String, enum: ['todo', 'in-progress', 'done'], default: 'todo' },
priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
dueDate: { type: Date },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true });Create Task (POST /api/tasks):
The user field is automatically set from req.user.id (from auth middleware). Users cannot create tasks for other users. Validate the request body with Zod.
List Tasks (GET /api/tasks): This is the most complex endpoint. It supports:
?status=todo&priority=high — only return tasks matching these criteria?sort=dueDate or ?sort=-createdAt (prefix - for descending)?page=2&limit=10 — return page 2 with 10 results per page{ user: req.user.id }). Admins see all tasks.async function listTasks(req, res) {
const filter = {};
if (req.user.role !== 'admin') filter.user = req.user.id;
if (req.query.status) filter.status = req.query.status;
if (req.query.priority) filter.priority = req.query.priority;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
const sort = req.query.sort || '-createdAt';
const [tasks, total] = await Promise.all([
Task.find(filter).sort(sort).skip(skip).limit(limit),
Task.countDocuments(filter),
]);
res.json({
success: true,
data: tasks,
pagination: {
page, limit,
total,
pages: Math.ceil(total / limit),
},
});
}Get Single Task (GET /api/tasks/:id):
Find by ID AND verify ownership: Task.findOne({ _id: id, user: req.user.id }). If the task does not exist OR belongs to another user, return 404. Admins bypass the ownership check.
Update Task (PATCH /api/tasks/:id):
Same ownership check. Use findOneAndUpdate with { new: true, runValidators: true }. Only allow updating specific fields (title, description, status, priority, dueDate) — never let the client change the user field.
Delete Task (DELETE /api/tasks/:id): Same ownership check. Return 204 No Content on success.
The ownership check pattern appears in get, update, and delete. Extract it into a reusable middleware or service method to keep your code DRY:
async function findUserTask(taskId, userId, isAdmin) {
const filter = { _id: taskId };
if (!isAdmin) filter.user = userId;
const task = await Task.findOne(filter);
if (!task) throw new NotFoundError('Task not found');
return task;
}// ── src/app.js — Express App Setup ────────────────────
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { errorHandler } = require('./middleware/errorHandler');
const authRoutes = require('./routes/auth.routes');
const taskRoutes = require('./routes/task.routes');
const app = express();
// ── Global Middleware ─────────────────────────────────
app.use(helmet()); // Security headers
app.use(cors({ origin: process.env.CORS_ORIGIN }));
app.use(express.json({ limit: '10kb' })); // Body parser with size limit
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests' }
}));
// ── Routes ────────────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);
// ── Health Check ──────────────────────────────────────
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// ── 404 Handler ───────────────────────────────────────
app.all('*', (req, res) => {
res.status(404).json({ error: `Cannot ${req.method} ${req.path}` });
});
// ── Error Handler (must be last) ──────────────────────
app.use(errorHandler);
module.exports = app;
// ── src/models/Task.js ────────────────────────────────
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
description: { type: String, trim: true, maxlength: 2000 },
status: { type: String, enum: ['todo', 'in-progress', 'done'], default: 'todo' },
priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
dueDate: { type: Date },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true });
taskSchema.index({ user: 1, status: 1 }); // Compound index for common queries
taskSchema.index({ dueDate: 1 });
module.exports = mongoose.model('Task', taskSchema);
// ── src/controllers/task.controller.js ────────────────
const Task = require('../models/Task');
const { NotFoundError } = require('../utils/errors');
exports.createTask = async (req, res) => {
const task = await Task.create({ ...req.body, user: req.user.id });
res.status(201).json({ success: true, data: task });
};
exports.getTasks = async (req, res) => {
const filter = {};
if (req.user.role !== 'admin') filter.user = req.user.id;
if (req.query.status) filter.status = req.query.status;
if (req.query.priority) filter.priority = req.query.priority;
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const sort = req.query.sort || '-createdAt';
const [tasks, total] = await Promise.all([
Task.find(filter).sort(sort).skip((page - 1) * limit).limit(limit),
Task.countDocuments(filter),
]);
res.json({
success: true,
data: tasks,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
};
exports.deleteTask = async (req, res) => {
const filter = { _id: req.params.id };
if (req.user.role !== 'admin') filter.user = req.user.id;
const task = await Task.findOneAndDelete(filter);
if (!task) throw new NotFoundError('Task not found');
res.status(204).send();
};
// ── src/routes/task.routes.js ─────────────────────────
const router = require('express').Router();
const { auth } = require('../middleware/auth');
const ctrl = require('../controllers/task.controller');
const asyncHandler = require('../utils/asyncHandler');
router.use(auth); // All task routes require authentication
router.post('/', asyncHandler(ctrl.createTask));
router.get('/', asyncHandler(ctrl.getTasks));
router.get('/:id', asyncHandler(ctrl.getTask));
router.patch('/:id', asyncHandler(ctrl.updateTask));
router.delete('/:id', asyncHandler(ctrl.deleteTask));
module.exports = router;Testing is not optional — it is the difference between code you trust and code you hope works. For a REST API, integration tests are the most valuable because they test the complete request-response cycle: middleware, route handlers, database operations, and error handling all working together.
Setup with Jest + Supertest:
const request = require('supertest');
const app = require('../src/app');
describe('Auth Endpoints', () => {
it('should register a new user', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ name: 'Alice', email: 'alice@test.com', password: 'password123' });
expect(res.status).toBe(201);
expect(res.body.token).toBeDefined();
expect(res.body.user.email).toBe('alice@test.com');
expect(res.body.user.password).toBeUndefined(); // Never expose password
});
it('should reject duplicate emails', async () => {
// Register twice with the same email
await request(app).post('/api/auth/register')
.send({ name: 'Alice', email: 'dup@test.com', password: 'password123' });
const res = await request(app).post('/api/auth/register')
.send({ name: 'Bob', email: 'dup@test.com', password: 'password456' });
expect(res.status).toBe(409);
});
});What to test:
End-to-end test scenario:
1. Register user A
2. Register user B
3. User A creates 3 tasks
4. User B creates 2 tasks
5. User A lists tasks → should see 3 (not B's tasks)
6. User A updates their task → should succeed
7. User A tries to update B's task → should get 404
8. Admin lists tasks → should see all 5
9. User A deletes their task → should succeed
10. User A lists tasks → should see 2Deployment checklist:
npm test)npm run lint)docker build -t task-manager .)GET /health)Congratulations — you have built a complete, production-ready REST API from scratch. You understand the full stack of backend development: HTTP fundamentals, routing, middleware, databases, authentication, authorization, validation, error handling, caching, containerization, and deployment. This is a significant achievement.
But this is just the beginning. Backend development is a vast field, and there are many directions you can grow from here:
TypeScript — If you have not already, learn TypeScript. Type safety catches entire categories of bugs at compile time rather than runtime. Most professional Node.js codebases use TypeScript. Frameworks like NestJS are built around it.
NestJS — An opinionated, full-featured Node.js framework inspired by Angular. It provides a structured architecture with modules, controllers, services, dependency injection, and decorators out of the box. If Express feels too bare-bones for large projects, NestJS adds the structure you need.
GraphQL — An alternative to REST where the client specifies exactly what data it needs. Instead of multiple REST endpoints returning fixed data shapes, you have one endpoint that responds to flexible queries. Great for complex frontends that need different data on different pages. Learn Apollo Server for the Node.js implementation.
Microservices Architecture — Instead of one monolithic API, split your application into small, independently deployable services. The auth service, task service, notification service, and payment service each run as their own process. They communicate via HTTP APIs, message queues (RabbitMQ, AWS SQS), or event streams (Kafka). This is how large-scale systems are built at companies like Netflix, Uber, and Amazon.
System Design — Learn how to design systems that scale: load balancing, horizontal scaling, database sharding, CDNs, message queues, event-driven architecture, CQRS (Command Query Responsibility Segregation), circuit breakers, and distributed caching. This knowledge is essential for senior backend roles and is heavily tested in system design interviews.
Real-Time Features — WebSockets (which you have already learned), Server-Sent Events (SSE), and WebRTC for real-time communication. Build chat applications, live dashboards, collaborative editors, and multiplayer games.
Open Source — Contributing to open-source Node.js projects is one of the best ways to learn from experienced developers. Look at the source code of Express, Fastify, Prisma, or any library you use daily. Open issues tagged "good first issue" are welcoming starting points.
The most important thing is to build projects. Reading tutorials and courses is valuable, but real learning happens when you face real problems. Pick a project that excites you — a social media API, a real-time chat app, an e-commerce backend, a developer tool — and build it. You have all the skills you need. Now go create something.
In a task management API, which middleware should run first on a protected route?