Building REST APIs with Node.js and Express — Step-by-Step Guide
In this tutorial, you'll learn about Building REST APIs with Node.js and Express. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Node.js with Express is a popular combination for building REST APIs, providing a lightweight, high-performance runtime with a minimalist web framework that scales from simple prototypes to enterprise applications.
What You'll Learn
You will build a complete REST API with Node.js and Express from scratch, including routing, middleware, request validation, database integration with PostgreSQL, error handling, authentication, and deployment.
Why Node.js and Express Matter
Node.js powers over 30 percent of web applications and handles thousands of requests per second with its event-driven, non-blocking architecture. Express is the most popular Node.js web framework, used by companies like IBM, Uber, and PayPal. At DodaTech, the Doda Browser sync service and DodaZIP update API are built with Express.
Real-World Use
DodaTech runs multiple Express APIs in production. Doda Browser sync API handles millions of bookmark sync requests daily, DodaZIP update server manages version distribution to thousands of clients, and internal Microservices for Durga Antivirus Pro threat analysis use Express for REST endpoints.
Node.js Express API Learning Path
flowchart LR
A[JavaScript Basics] --> B[Node.js Fundamentals]
B --> C[Express Setup]
C --> D[Routing & Middleware]
D --> E[Database Integration]
E --> F[Validation & Error Handling]
F --> G[Auth & Deployment]
B:::current
classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px
Prerequisites
Understand RESTful Api Design Best Practices and basic JavaScript Basics. Familiarity with HTTP Protocol Basics and JSON Data Format is required. Basic Command line knowledge helps.
Project Setup
Step 1: Initialize the Project
mkdir dodatech-api
cd dodatech-api
npm init -y
npm install express pg dotenv cors helmet morgan
npm install -D nodemon
Step 2: Project Structure
dodatech-api/
├── src/
│ ├── config/
│ │ └── database.js
│ ├── controllers/
│ │ └── userController.js
│ ├── middleware/
│ │ ├── auth.js
│ │ └── errorHandler.js
│ ├── models/
│ │ └── userModel.js
│ ├── routes/
│ │ └── userRoutes.js
│ ├── validators/
│ │ └── userValidator.js
│ └── app.js
├── .env
├── .env.example
└── package.json
Step 3: Create the Express App
// src/app.js
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
require("dotenv").config();
const userRoutes = require("./routes/userRoutes");
const errorHandler = require("./middleware/errorHandler");
const app = express();
// Global middleware
app.use(helmet()); // Security headers
app.use(cors()); // Cross-Origin Resource Sharing
app.use(morgan("dev")); // Request logging
app.use(express.json()); // Parse JSON body
// Routes
app.use("/api/v1/users", userRoutes);
// Health check
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Error handler
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`DodaTech API running on port ${PORT}`);
});
Database Integration with PostgreSQL
Step 1: Database Configuration
// src/config/database.js
const { Pool } = require("pg");
const pool = new Pool({
host: process.env.DB_HOST || "localhost",
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || "dodatech",
user: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWORD
});
module.exports = {
query: (text, params) => pool.query(text, params),
pool
};
Step 2: Database Migration
-- migrations/001_create_users.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
Creating Routes and Controllers
Step 1: Define Routes
// src/routes/userRoutes.js
const express = require("express");
const router = express.Router();
const userController = require("../controllers/userController");
const { validateCreateUser } = require("../validators/userValidator");
const { authenticate } = require("../middleware/auth");
router.get("/", authenticate, userController.listUsers);
router.get("/:id", authenticate, userController.getUser);
router.post("/", validateCreateUser, userController.createUser);
router.put("/:id", authenticate, userController.updateUser);
router.delete("/:id", authenticate, userController.deleteUser);
module.exports = router;
Step 2: Implement Controller
// src/controllers/userController.js
const userModel = require("../models/userModel");
exports.listUsers = async (req, res, next) => {
try {
const { page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
const users = await userModel.findAll(limit, offset);
const total = await userModel.count();
res.json({
success: true,
data: users,
pagination: {
page: Number(page),
limit: Number(limit),
total: Number(total),
pages: Math.ceil(total / limit)
}
});
} catch (error) {
next(error);
}
};
exports.getUser = async (req, res, next) => {
try {
const user = await userModel.findById(req.params.id);
if (!user) {
return res.status(404).json({
success: false,
error: "User not found"
});
}
res.json({ success: true, data: user });
} catch (error) {
next(error);
}
};
exports.createUser = async (req, res, next) => {
try {
const { name, email, password } = req.body;
const existing = await userModel.findByEmail(email);
if (existing) {
return res.status(409).json({
success: false,
error: "Email already exists"
});
}
const user = await userModel.create({ name, email, password });
res.status(201).json({ success: true, data: user });
} catch (error) {
next(error);
}
};
exports.updateUser = async (req, res, next) => {
try {
const user = await userModel.update(req.params.id, req.body);
if (!user) {
return res.status(404).json({
success: false,
error: "User not found"
});
}
res.json({ success: true, data: user });
} catch (error) {
next(error);
}
};
exports.deleteUser = async (req, res, next) => {
try {
const deleted = await userModel.delete(req.params.id);
if (!deleted) {
return res.status(404).json({
success: false,
error: "User not found"
});
}
res.status(204).send();
} catch (error) {
next(error);
}
};
Step 3: Implement Model
// src/models/userModel.js
const db = require("../config/database");
const bcrypt = require("bcrypt");
const findAll = async (limit, offset) => {
const result = await db.query(
"SELECT id, name, email, role, created_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
[limit, offset]
);
return result.rows;
};
const findById = async (id) => {
const result = await db.query(
"SELECT id, name, email, role, created_at FROM users WHERE id = $1",
[id]
);
return result.rows[0] || null;
};
const findByEmail = async (email) => {
const result = await db.query(
"SELECT id, name, email FROM users WHERE email = $1",
[email]
);
return result.rows[0] || null;
};
const create = async ({ name, email, password }) => {
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
const result = await db.query(
`INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, name, email, role, created_at`,
[name, email, passwordHash]
);
return result.rows[0];
};
const update = async (id, { name, email, role }) => {
const result = await db.query(
`UPDATE users
SET name = COALESCE($1, name),
email = COALESCE($2, email),
role = COALESCE($3, role),
updated_at = NOW()
WHERE id = $4
RETURNING id, name, email, role, created_at, updated_at`,
[name, email, role, id]
);
return result.rows[0] || null;
};
const delete = async (id) => {
const result = await db.query(
"DELETE FROM users WHERE id = $1 RETURNING id",
[id]
);
return result.rows.length > 0;
};
module.exports = { findAll, findById, findByEmail, create, update, delete };
Middleware
Authentication Middleware
// src/middleware/auth.js
const jwt = require("jsonwebtoken");
exports.authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({
success: false,
error: "Authentication required"
});
}
const token = authHeader.split(" ")[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({
success: false,
error: "Invalid or expired token"
});
}
};
Error Handler Middleware
// src/middleware/errorHandler.js
module.exports = (err, req, res, next) => {
console.error("Error:", err);
const statusCode = err.statusCode || 500;
const message = err.message || "Internal server error";
res.status(statusCode).json({
success: false,
error: message,
...(process.env.NODE_ENV === "development" && { stack: err.stack })
});
};
Validation Middleware
// src/validators/userValidator.js
const Joi = require("joi");
const createUserSchema = Joi.object({
name: Joi.string().min(2).max(100).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required()
});
exports.validateCreateUser = (req, res, next) => {
const { error } = createUserSchema.validate(req.body, { abortEarly: false });
if (error) {
const errors = error.details.map(d => ({
field: d.path.join("."),
message: d.message
}));
return res.status(422).json({
success: false,
error: "Validation failed",
errors
});
}
next();
};
Running the API
# Start in development mode
npm run dev
Expected output:
[nodemon] starting `node src/app.js`
DodaTech API running on port 3000
Test with curl:
curl -X POST HTTP://localhost:3000/API/v1/users \
-H "Content-Type: application/JSON" \
-d '{"name":"Alice","email":"alice@example.com","password":"securepass123"}'
Expected response:
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Alice",
"email": "alice@example.com",
"role": "member",
"created_at": "2026-06-23T10:00:00.000Z"
}
}
Common Errors
Not handling async errors — Express does not catch errors in async route handlers automatically. Wrap async handlers in a try-catch or use express-async-errors.
Hardcoded configuration — Putting database credentials and API keys in the code. Use environment variables with dotenv and never commit .env files.
Missing input validation — Accepting user input without validation. Use Joi or express-validator to validate every request body and query parameter.
No Rate Limiting — Exposing endpoints without rate limits. Use express-rate-limit to prevent abuse, especially on auth endpoints.
Returning Stack traces in production — Sending error Stack traces to clients. Use different error formatting for development and production environments.
Not using HTTP status codes properly — Returning 200 for all responses including errors. Use 201 for creation, 204 for deletion, 400 for bad requests, 401 for unauthorized.
Memory leaks from unclosed connections — Not closing database connections on shutdown. Implement graceful shutdown handlers that close database pools and server connections.
Practice Questions
- What is the purpose of middleware in Express?
- How do you handle errors in async Express route handlers?
- Why should you use environment variables for configuration?
- What is the difference between 401 and 403 status codes?
- How do you implement pagination in a REST API?
Challenge
Build a complete REST API for a task management application with Express and PostgreSQL. Include: CRUD for tasks with title, description, status, priority, and due date, user authentication with JWT, input validation with Joi, pagination and filtering by status and priority, Rate Limiting on auth endpoints, comprehensive error handling, and a Migration script for the database schema. Write a test suite using Jest and supertest.
FAQ
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro