Building a Production-Ready REST API with Node.js and Express: From Design to Deployment
Node.js and Express have become a go-to stack for building REST APIs, but getting from “it works on my machine” to “production-ready” is a different game. It’s not just about wiring up routes — it’s about design, structure, validation, security, and deployment.
This tutorial walks step-by-step through:
- Designing your API
- Structuring an Express project
- Implementing routes, controllers, and models
- Adding validation, logging, and error handling
- Securing and configuring your API
- Testing and deploying to production
All examples use modern JavaScript (ES modules) and express@4 style APIs.
1. Plan Your API Before You Code
Before installing anything, clarify what you’re building. Let’s define a simple but realistic example: a Task Management API.
Requirements
- Users can:
- Create tasks
- List tasks
- Get a single task
- Update a task
- Delete a task
Example resource: Task
{
"id": "uuid",
"title": "Write blog post",
"description": "Write about building a production ready API",
"status": "pending",
"createdAt": "2026-01-15T10:00:00.000Z",
"updatedAt": "2026-01-15T10:00:00.000Z"
}
RESTful Endpoints
| Method |
Path |
Description |
Body |
| GET |
/tasks |
List tasks |
- |
| GET |
/tasks/:id |
Get task by id |
- |
| POST |
/tasks |
Create a new task |
title, description |
| PATCH |
/tasks/:id |
Update task partially |
any updatable fields |
| DELETE |
/tasks/:id |
Delete task |
- |
High-Level Architecture
graph TD
C[Client] -->|HTTP/JSON| A[Express App]
A --> B[Routes]
B --> D[Controllers]
D --> E[Services / Business Logic]
E --> F[(Database)]
D --> G[Error Handling Middleware]
A --> H[Logging & Monitoring]
We’ll follow this layered approach so the API remains maintainable and testable.

2. Set Up the Project
Create a new Node.js project:
mkdir task-api
cd task-api
npm init -y
npm install express dotenv morgan cors helmet joi
npm install --save-dev nodemon
express – the web framework
dotenv – loads environment variables from .env
morgan – HTTP request logger
cors – enable cross-origin resource sharing
helmet – secure HTTP headers
joi – request validation
nodemon – restarts server on file changes (development)
In package.json, enable ES modules and add scripts:
{
"type": "module",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js"
}
}
Project structure:
task-api/
src/
app.js
server.js
config/
index.js
routes/
index.js
task.routes.js
controllers/
task.controller.js
services/
task.service.js
models/
task.model.js
middleware/
errorHandler.js
notFound.js
validate.js
utils/
ApiError.js
logger.js
.env
package.json
3. Configuration and Environment Variables
Create .env:
PORT=3000
NODE_ENV=development
LOG_LEVEL=dev
Configuration loader in src/config/index.js:
import dotenv from 'dotenv';
dotenv.config();
const config = {
env: process.env.NODE_ENV || 'development',
port: process.env.PORT || 3000,
logLevel: process.env.LOG_LEVEL || 'dev'
};
export default config;
4. Build the Express App Shell
src/app.js:
import express from 'express';
import morgan from 'morgan';
import cors from 'cors';
import helmet from 'helmet';
import config from './config/index.js';
import routes from './routes/index.js';
import notFound from './middleware/notFound.js';
import errorHandler from './middleware/errorHandler.js';
const app = express();
// Security headers
app.use(helmet());
// CORS
app.use(cors());
// Logging (skip in test env)
if (config.env !== 'test') {
app.use(morgan(config.logLevel));
}
// Parse JSON bodies
app.use(express.json());
// Routes
app.use('/api', routes);
// 404 handler
app.use(notFound);
// Central error handler
app.use(errorHandler);
export default app;
src/server.js:
import app from './app.js';
import config from './config/index.js';
app.listen(config.port, () => {
console.log(`Server running on port ${config.port} in ${config.env} mode`);
});

5. Routing and Controllers
Route Registration
src/routes/index.js:
import { Router } from 'express';
import taskRoutes from './task.routes.js';
const router = Router();
router.use('/tasks', taskRoutes);
export default router;
src/routes/task.routes.js:
import { Router } from 'express';
import * as taskController from '../controllers/task.controller.js';
import validate from '../middleware/validate.js';
import { createTaskSchema, updateTaskSchema } from '../models/task.model.js';
const router = Router();
router
.route('/')
.get(taskController.getTasks)
.post(validate(createTaskSchema), taskController.createTask);
router
.route('/:id')
.get(taskController.getTaskById)
.patch(validate(updateTaskSchema), taskController.updateTask)
.delete(taskController.deleteTask);
export default router;
Controller Layer
src/controllers/task.controller.js:
import * as taskService from '../services/task.service.js';
import ApiError from '../utils/ApiError.js';
export const getTasks = async (req, res, next) => {
try {
const tasks = await taskService.listTasks();
res.json(tasks);
} catch (err) {
next(err);
}
};
export const getTaskById = async (req, res, next) => {
try {
const task = await taskService.getTask(req.params.id);
if (!task) {
throw new ApiError(404, 'Task not found');
}
res.json(task);
} catch (err) {
next(err);
}
};
export const createTask = async (req, res, next) => {
try {
const task = await taskService.createTask(req.body);
res.status(201).json(task);
} catch (err) {
next(err);
}
};
export const updateTask = async (req, res, next) => {
try {
const task = await taskService.updateTask(req.params.id, req.body);
if (!task) {
throw new ApiError(404, 'Task not found');
}
res.json(task);
} catch (err) {
next(err);
}
};
export const deleteTask = async (req, res, next) => {
try {
const deleted = await taskService.deleteTask(req.params.id);
if (!deleted) {
throw new ApiError(404, 'Task not found');
}
res.status(204).send();
} catch (err) {
next(err);
}
};
6. Data Layer: Models and Services
To keep the tutorial focused on API design, we’ll use an in-memory store. In a real application, this would be replaced with a database (PostgreSQL, MongoDB, etc).
Validation Schemas
src/models/task.model.js:
import Joi from 'joi';
import { v4 as uuidv4 } from 'uuid';
// In-memory data store
const tasks = [];
export const taskStatuses = ['pending', 'in_progress', 'done'];
export const createTaskSchema = Joi.object({
title: Joi.string().min(3).max(255).required(),
description: Joi.string().max(2000).optional(),
status: Joi.string().valid(...taskStatuses).default('pending')
});
export const updateTaskSchema = Joi.object({
title: Joi.string().min(3).max(255),
description: Joi.string().max(2000),
status: Joi.string().valid(...taskStatuses)
}).min(1); // at least one field
// Data access helpers
export const getAllTasks = () => tasks;
export const getTaskById = (id) => tasks.find((t) => t.id === id);
export const createTask = (data) => {
const now = new Date().toISOString();
const task = {
id: uuidv4(),
title: data.title,
description: data.description || '',
status: data.status || 'pending',
createdAt: now,
updatedAt: now
};
tasks.push(task);
return task;
};
export const updateTaskById = (id, updates) => {
const task = getTaskById(id);
if (!task) return null;
Object.assign(task, updates, { updatedAt: new Date().toISOString() });
return task;
};
export const deleteTaskById = (id) => {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return false;
tasks.splice(index, 1);
return true;
};
Service Layer
src/services/task.service.js:
import {
getAllTasks,
getTaskById,
createTask as createTaskModel,
updateTaskById,
deleteTaskById
} from '../models/task.model.js';
export const listTasks = async () => {
// Business rules would go here (filtering, pagination, etc)
return getAllTasks();
};
export const getTask = async (id) => {
return getTaskById(id);
};
export const createTask = async (data) => {
return createTaskModel(data);
};
export const updateTask = async (id, updates) => {
return updateTaskById(id, updates);
};
export const deleteTask = async (id) => {
return deleteTaskById(id);
};
7. Validation Middleware
We already wired up a validate middleware. Let’s implement it.
src/middleware/validate.js:
import ApiError from '../utils/ApiError.js';
const validate = (schema) => (req, res, next) => {
const options = {
abortEarly: false,
allowUnknown: true,
stripUnknown: true
};
const { error, value } = schema.validate(req.body, options);
if (error) {
const details = error.details.map((d) => d.message);
return next(new ApiError(400, 'Validation error', details));
}
req.body = value;
return next();
};
export default validate;
Custom error class in src/utils/ApiError.js:
class ApiError extends Error {
constructor(statusCode, message, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
export default ApiError;
8. Centralized Error Handling and 404
src/middleware/notFound.js:
const notFound = (req, res, next) => {
res.status(404).json({
message: 'Not Found',
path: req.originalUrl
});
};
export default notFound;
src/middleware/errorHandler.js:
import config from '../config/index.js';
const errorHandler = (err, req, res, next) => {
// If it's an expected ApiError, use its status
const statusCode = err.statusCode || 500;
const response = {
message: err.message || 'Internal Server Error'
};
if (err.details) {
response.details = err.details;
}
if (config.env === 'development') {
response.stack = err.stack;
}
// eslint-disable-next-line no-console
console.error(err);
res.status(statusCode).json(response);
};
export default errorHandler;
9. Logging and Monitoring Basics
We already use morgan for HTTP logging. For application logs, a simple logger abstraction helps later if you switch to a more advanced solution.
src/utils/logger.js:
/* Simple logger abstraction */
const logger = {
info: (...args) => console.log('[INFO]', ...args),
error: (...args) => console.error('[ERROR]', ...args),
warn: (...args) => console.warn('[WARN]', ...args),
debug: (...args) => {
if (process.env.NODE_ENV === 'development') {
console.log('[DEBUG]', ...args);
}
}
};
export default logger;
Use it in controllers or services:
// in task.service.js
import logger from '../utils/logger.js';
// inside a function
logger.info('Creating task', data.title);
In production, you’d typically:
- Log in JSON format
- Ship logs to a central system
- Add correlation IDs for tracing requests across services
10. Security and Hardening
Even simple APIs need basic security considerations.
Key Practices
| Concern |
Practice |
Example |
| HTTP headers |
Use helmet |
app.use(helmet()) |
| Transport |
Terminate SSL (HTTPS) at load balancer or reverse proxy |
Nginx, cloud load balancer |
| CORS |
Restrict origins in production |
cors({ origin: 'https://app.com' }) |
| Input validation |
Use Joi for body, query, and params |
validate(createTaskSchema) |
| Rate limiting |
Limit requests per IP |
E.g., express-rate-limit |
| Authentication |
Use JWT or session-based auth |
Authorization: Bearer <token> |
Example: Restrictive CORS in app.js for production:
const corsOptions = {
origin: config.env === 'production' ? 'https://your-frontend.com' : '*'
};
app.use(cors(corsOptions));
11. Testing the API
Use a tool like curl, HTTP clients, or test frameworks to verify endpoints.
Manual Testing with curl
Create a task:
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write documentation", "description": "API tutorial"}'
Get tasks:
curl http://localhost:3000/api/tasks
Update a task:
curl -X PATCH http://localhost:3000/api/tasks/<id> \
-H "Content-Type: application/json" \
-d '{"status": "done"}'
Automated Tests (Example with Jest)
Install Jest:
npm install --save-dev jest supertest
Add to package.json:
"scripts": {
"test": "NODE_ENV=test jest"
}
Simple test: tests/task.test.js:
import request from 'supertest';
import app from '../src/app.js';
describe('Task API', () => {
it('should create and fetch a task', async () => {
const createRes = await request(app)
.post('/api/tasks')
.send({ title: 'Test task' })
.expect(201);
expect(createRes.body).toHaveProperty('id');
const taskId = createRes.body.id;
const getRes = await request(app)
.get(`/api/tasks/${taskId}`)
.expect(200);
expect(getRes.body.title).toBe('Test task');
});
});
12. Preparing for Deployment
Production readiness goes beyond “it runs”. You want reliability, observability, and predictable behavior.
Environment-Specific Config
Use environment variables to configure:
PORT
NODE_ENV
- Database connection strings
- API keys
Never hard-code secrets. Use .env for development and environment variables in production.
Process Management
In production, run Node with:
- A process manager (e.g., PM2, systemd, containers/orchestrator)
- Auto-restarts on crashes
- Graceful shutdowns
Example graceful shutdown in server.js:
import app from './app.js';
import config from './config/index.js';
import logger from './utils/logger.js';
const server = app.listen(config.port, () => {
logger.info(`Server running on port ${config.port} in ${config.env} mode`);
});
const shutdown = () => {
logger.info('Shutting down gracefully...');
server.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
// Force shutdown after a timeout
setTimeout(() => {
logger.error('Forced shutdown');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Basic Deployment Flow
sequenceDiagram
participant Dev as Developer
participant Git as Git Repo
participant CI as CI Pipeline
participant Prod as Production Server
Dev->>Git: Push code
Git->>CI: Trigger build
CI->>CI: Install deps, run tests
CI->>Prod: Deploy (e.g., Docker image)
Prod->>Prod: Start Node.js API
Regardless of whether you use containers, VMs, or PaaS, the key is:
- Build once (immutable artifact)
- Run the same artifact in each environment
- Configure only via environment variables
13. Versioning and Backward Compatibility
As your API evolves, you’ll want to version your endpoints to avoid breaking existing clients.
Common approaches:
- URL versioning:
/api/v1/tasks, /api/v2/tasks
- Header-based versioning (less common for smaller services)
With URL versioning:
// routes/index.js
import { Router } from 'express';
import taskRoutesV1 from './task.routes.js';
import taskRoutesV2 from './task.v2.routes.js';
const router = Router();
router.use('/v1/tasks', taskRoutesV1);
router.use('/v2/tasks', taskRoutesV2);
export default router;
When making breaking changes:
- Keep old version running for a deprecation period
- Communicate changes clearly
- Provide migration guidance
14. Common Production Pitfalls (and How to Avoid Them)
| Pitfall |
Symptom |
Prevention Strategy |
| Lack of validation |
Inconsistent or corrupted data |
Use schemas for body, params, and query |
| No centralized error handling |
Unhandled rejections, inconsistent responses |
Implement a global error handler and use next(err) |
| Hard-coded configuration |
Different behavior across environments |
Use environment variables and config files |
| No rate limiting |
API abuse or DoS |
Add throttling and IP-based limits |
| Poor logging |
Hard to debug production issues |
Structured logs, correlation IDs, centralized logging |
| Missing timeouts |
Hanging HTTP requests |
Server and client-side timeouts |
| No health checks |
Orchestrator thinks app is fine when it’s not |
Implement /health endpoints and readiness checks |
Example health check route:
// in routes/index.js
router.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
Conclusion
Building a production-ready REST API with Node.js and Express is less about fancy frameworks and more about solid fundamentals:
- Design your API resources and endpoints thoughtfully.
- Structure your project with clear separation between routes, controllers, services, and models.
- Validate inputs and handle errors centrally.
- Secure your API with headers, CORS, auth, and rate limiting.
- Observe your API with logging, health checks, and monitoring.
- Deploy with environment-based configuration, process management, and a repeatable pipeline.
Once this foundation is in place, you can confidently evolve your API — add authentication, connect to real databases, introduce versioning — without constantly fighting your architecture.