REST APIs form the backbone of modern web and mobile applications. In this guide we will build a secure, scalable REST API in line with industry standards using Node.js and Express.js. From project structure to JWT authentication, from error handling to deployment, we cover every stage with real code examples.

Related guides: Software development processes · PostgreSQL optimization · Advanced Git commands · What is Redis, and how to use it · Deploying with Docker

Project Setup and Structure

A proper directory structure is critical for a scalable API project. We will use a layered architecture to achieve separation of concerns.

bash
# Create the project
mkdir my-api && cd my-api
npm init -y

# Core dependencies
npm install express dotenv cors helmet morgan
npm install jsonwebtoken bcryptjs
npm install pg          # for PostgreSQL
npm install joi         # for validation

# Development dependencies
npm install -D nodemon eslint
text
my-api/
├── src/
│   ├── config/
│   │   └── db.js          # Database connection
│   ├── middleware/
│   │   ├── auth.js         # JWT verification
│   │   ├── validate.js     # Request validation
│   │   └── errorHandler.js # Central error handling
│   ├── routes/
│   │   ├── auth.js         # Login/register routes
│   │   └── users.js        # User CRUD routes
│   ├── controllers/
│   │   ├── authController.js
│   │   └── userController.js
│   ├── models/
│   │   └── userModel.js
│   └── app.js              # Express application
├── .env
├── .gitignore
└── package.json

Creating the Express.js Application

Let's configure the Express application together with security middleware. Helmet hardens the HTTP headers, CORS controls cross-origin requests, and Morgan logs HTTP requests.

javascript
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
require('dotenv').config();

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const errorHandler = require('./middleware/errorHandler');

const app = express();

// Security and helper middleware
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(morgan('combined'));
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true }));

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// API routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);

// 404 handler
app.use((req, res) => {
  res.status(404).json({ error: 'Endpoint not found' });
});

// Central error handling
app.use(errorHandler);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API server running on port ${PORT}`);
});

module.exports = app;

Routing and Controller Structure

We make the routes modular with the Express Router. Each route file forwards to the relevant controller functions. That way the business logic is separated from the route definitions.

javascript
// src/routes/users.js
const router = require('express').Router();
const { getUsers, getUserById, updateUser, deleteUser } = require('../controllers/userController');
const auth = require('../middleware/auth');
const { validateUser } = require('../middleware/validate');

router.get('/',          auth, getUsers);
router.get('/:id',       auth, getUserById);
router.put('/:id',       auth, validateUser, updateUser);
router.delete('/:id',    auth, deleteUser);

module.exports = router;
javascript
// src/controllers/userController.js
const pool = require('../config/db');

exports.getUsers = async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 20;
    const offset = (page - 1) * limit;

    const { rows } = await pool.query(
      'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2',
      [limit, offset]
    );

    const { rows: countResult } = await pool.query('SELECT count(*) FROM users');
    const total = parseInt(countResult[0].count);

    res.json({
      data: rows,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit)
      }
    });
  } catch (err) {
    next(err);
  }
};

exports.getUserById = async (req, res, next) => {
  try {
    const { rows } = await pool.query(
      'SELECT id, name, email, created_at FROM users WHERE id = $1',
      [req.params.id]
    );
    if (!rows.length) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json({ data: rows[0] });
  } catch (err) {
    next(err);
  }
};

The Middleware Layer

Middleware are functions that sit in the middle of the request-response cycle. We write cross-cutting concerns such as authentication, validation, logging and error handling as middleware.

Validation Middleware

javascript
// src/middleware/validate.js
const Joi = require('joi');

const userSchema = Joi.object({
  name: Joi.string().min(2).max(100).required()
    .messages({ 'string.min': 'Name must be at least 2 characters' }),
  email: Joi.string().email().required()
    .messages({ 'string.email': 'Enter a valid email address' }),
  password: Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/).required()
    .messages({ 'string.pattern.base': 'Password must contain at least 1 uppercase letter, 1 lowercase letter and 1 digit' })
});

exports.validateUser = (req, res, next) => {
  const { error } = userSchema.validate(req.body, { abortEarly: false });
  if (error) {
    const messages = error.details.map(d => d.message);
    return res.status(400).json({ errors: messages });
  }
  next();
};

Error Handling Middleware

javascript
// src/middleware/errorHandler.js
module.exports = (err, req, res, next) => {
  console.error(`[${new Date().toISOString()}] ${err.stack || err.message}`);

  // Known error types
  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: err.message });
  }
  if (err.name === 'UnauthorizedError' || err.status === 401) {
    return res.status(401).json({ error: 'Unauthorized access' });
  }
  if (err.code === '23505') { // PostgreSQL unique violation
    return res.status(409).json({ error: 'This record already exists' });
  }

  // Generic error
  const statusCode = err.status || 500;
  res.status(statusCode).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Server error'
      : err.message
  });
};

JWT Authentication

The JSON Web Token (JWT) is a stateless authentication mechanism. When a user logs in, a token is generated and sent in the Authorization header on subsequent requests. The server identifies the user by verifying the token on every request.

javascript
// src/middleware/auth.js
const jwt = require('jsonwebtoken');

module.exports = (req, res, next) => {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Token required' });
  }

  const token = header.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // { id, email, role }
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
};
javascript
// src/controllers/authController.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const pool = require('../config/db');

exports.register = async (req, res, next) => {
  try {
    const { name, email, password } = req.body;

    // Email check
    const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
    if (existing.rows.length) {
      return res.status(409).json({ error: 'This email is already registered' });
    }

    // Hash the password
    const salt = await bcrypt.genSalt(12);
    const hashedPassword = await bcrypt.hash(password, salt);

    // Create the user
    const { rows } = await pool.query(
      'INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email',
      [name, email, hashedPassword]
    );

    // Generate a token
    const token = jwt.sign(
      { id: rows[0].id, email: rows[0].email, role: 'user' },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );

    res.status(201).json({ user: rows[0], token });
  } catch (err) {
    next(err);
  }
};

exports.login = async (req, res, next) => {
  try {
    const { email, password } = req.body;

    const { rows } = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
    if (!rows.length) {
      return res.status(401).json({ error: 'Incorrect email or password' });
    }

    const user = rows[0];
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return res.status(401).json({ error: 'Incorrect email or password' });
    }

    const token = jwt.sign(
      { id: user.id, email: user.email, role: user.role || 'user' },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );

    res.json({ user: { id: user.id, name: user.name, email: user.email }, token });
  } catch (err) {
    next(err);
  }
};

PostgreSQL Database Connection

javascript
// src/config/db.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,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000
});

pool.on('error', (err) => {
  console.error('Database connection error:', err.message);
});

// Test the connection
pool.query('SELECT NOW()')
  .then(() => console.log('PostgreSQL connection successful'))
  .catch(err => console.error('PostgreSQL connection error:', err.message));

module.exports = pool;
bash
# .env file
PORT=3000
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=a-secret-key-of-at-least-32-characters
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapi_db
DB_USER=myapi_user
DB_PASS=strong_password

Rate Limiting and Security

To protect your API against abuse you need to apply rate limiting. With the express-rate-limit package you can cap the number of requests that can be made within a given time window.

bash
npm install express-rate-limit
javascript
// Rate limiter configuration
const rateLimit = require('express-rate-limit');

// General limiter — for all endpoints
const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // max requests per window
  message: { error: 'Too many requests — try again in 15 minutes' },
  standardHeaders: true,
  legacyHeaders: false
});

// Auth limiter — stricter for login/register
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many login attempts' }
});

app.use('/api', generalLimiter);
app.use('/api/auth', authLimiter);

Deployment (Publishing to a Server)

You can run your API on a VPS with the PM2 process manager. PM2 runs your application in the background, restarts it automatically on crashes and lets you use multiple CPU cores in cluster mode.

bash
# PM2 installation
npm install -g pm2

# Start the application
pm2 start src/app.js --name "my-api" -i max

# Useful PM2 commands
pm2 list              # Running applications
pm2 logs my-api       # Live logs
pm2 monit             # CPU/RAM monitoring
pm2 restart my-api    # Restart
pm2 save              # Save the current list
pm2 startup           # Start automatically on system boot
javascript
// ecosystem.config.js — PM2 configuration file
module.exports = {
  apps: [{
    name: 'my-api',
    script: 'src/app.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'development',
      PORT: 3000
    },
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    max_memory_restart: '500M',
    log_date_format: 'YYYY-MM-DD HH:mm:ss',
    error_file: 'logs/error.log',
    out_file: 'logs/app.log'
  }]
};

Testing the API

You can quickly test your API endpoints with curl:

bash
# Register
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Jane Doe","email":"jane@test.com","password":"Test1234"}'

# Login
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"jane@test.com","password":"Test1234"}'

# Protected endpoint (with a token)
curl http://localhost:3000/api/users \
  -H "Authorization: Bearer PASTE_YOUR_TOKEN_HERE"

# Health check
curl http://localhost:3000/health

Modern Software Development and DevOps Practices

A professional software development process rests on three pillars: source control (Git + a GitHub/GitLab pull request flow with mandatory code review), a CI/CD pipeline (automated test + lint + build + deploy), and observability (collecting logs, metrics and traces with Sentry/Datadog/Grafana). Guaranteeing code quality with the test pyramid (unit > integration > e2e), using Docker containers and Kubernetes orchestration in a microservice architecture, and keeping an OpenAPI/GraphQL Schema contract when designing a REST or GraphQL API are the modern standards. Throughout the software development life cycle (requirements → design → implementation → test → deploy → maintenance), Agile/Scrum sprints run 1-2 weeks and DevOps teams work on the principle of continuous delivery.