Building a Production-Ready REST API with Node.js, Express, and PostgreSQL

Stephen NdegwaStephen Ndegwa
·
4 min read

Building a Production-Ready REST API with Node.js, Express, and PostgreSQL

Introduction

REST APIs are the backbone of modern web and mobile applications. Whether you’re building a SaaS platform, a mobile app backend, or integrating third-party services, a well-designed API is critical.

In this tutorial, you’ll learn how to build a production-ready REST API using:

  • Node.js – fast, event-driven runtime
  • Express.js – lightweight web framework
  • PostgreSQL – reliable relational database
  • JWT Authentication – secure user access
  • Best practices – structure, validation, error handling, and security

By the end, you’ll have a solid API foundation you can reuse for real-world projects.


What We’re Building

A simple User Management API with:

  • User registration & login
  • JWT-based authentication
  • Protected routes
  • PostgreSQL persistence
  • Clean project structure

Endpoints overview:

MethodEndpointDescription
POST/auth/registerCreate a new user
POST/auth/loginAuthenticate user
GET/users/meGet current user
GET/healthAPI health check

Prerequisites

You should be comfortable with:

  • JavaScript fundamentals
  • Basic HTTP concepts (GET, POST, status codes)
  • Command-line usage

Required software:

  • Node.js (v18+ recommended)
  • PostgreSQL
  • npm or yarn

Step 1: Project Setup

Create the project folder and initialize Node.js:

mkdir node-postgres-api
cd node-postgres-api
npm init -y

Install dependencies:

npm install express pg dotenv bcrypt jsonwebtoken express-validator
npm install --save-dev nodemon

Step 2: Project Structure

A clean structure improves maintainability:

src/
├── config/
│   └── db.js
├── controllers/
│   ├── auth.controller.js
│   └── user.controller.js
├── middlewares/
│   ├── auth.middleware.js
│   └── error.middleware.js
├── routes/
│   ├── auth.routes.js
│   └── user.routes.js
├── utils/
│   └── jwt.js
├── app.js
└── server.js
.env

Step 3: Environment Configuration

Create .env:

PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=super_secure_secret

⚠️ Never commit .env files to version control.


Step 4: Database Connection

src/config/db.js

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

module.exports = pool;

Step 5: Database Schema

Create a users table:

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT UNIQUE NOT NULL,
  password TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

Step 6: Express App Initialization

src/app.js

const express = require('express');
const app = express();

app.use(express.json());

app.get('/health', (_, res) => {
  res.json({ status: 'ok' });
});

module.exports = app;

src/server.js

require('dotenv').config();
const app = require('./app');

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Step 7: Authentication Logic

JWT Utility

src/utils/jwt.js

const jwt = require('jsonwebtoken');

exports.signToken = (payload) =>
  jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '1h' });

Register Controller

src/controllers/auth.controller.js

const bcrypt = require('bcrypt');
const pool = require('../config/db');
const { signToken } = require('../utils/jwt');

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

  const hashed = await bcrypt.hash(password, 10);

  const user = await pool.query(
    'INSERT INTO users(email, password) VALUES($1,$2) RETURNING id,email',

[email, hashed]

); const token = signToken({ id: user.rows[0].id }); res.status(201).json({ token }); };


Login Controller

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

  const user = await pool.query(
    'SELECT * FROM users WHERE email=$1',

[email]

); if (!user.rows.length) { return res.status(401).json({ error: ‘Invalid credentials’ }); } const valid = await bcrypt.compare(password, user.rows[0].password); if (!valid) { return res.status(401).json({ error: ‘Invalid credentials’ }); } const token = signToken({ id: user.rows[0].id }); res.json({ token }); };


Step 8: Authentication Middleware

src/middlewares/auth.middleware.js

const jwt = require('jsonwebtoken');

module.exports = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) return res.sendStatus(401);

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.sendStatus(403);
  }
};

Step 9: Protected User Route

src/controllers/user.controller.js

const pool = require('../config/db');

exports.me = async (req, res) => {
  const user = await pool.query(
    'SELECT id,email FROM users WHERE id=$1',

[req.user.id]

); res.json(user.rows[0]); };


Step 10: Routes

src/routes/auth.routes.js

const router = require('express').Router();
const ctrl = require('../controllers/auth.controller');

router.post('/register', ctrl.register);
router.post('/login', ctrl.login);

module.exports = router;

src/routes/user.routes.js

const router = require('express').Router();
const auth = require('../middlewares/auth.middleware');
const ctrl = require('../controllers/user.controller');

router.get('/me', auth, ctrl.me);

module.exports = router;

Mount routes in app.js:

app.use('/auth', require('./routes/auth.routes'));
app.use('/users', require('./routes/user.routes'));

Step 11: Error Handling Best Practice

Centralized error handling improves stability:

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

Step 12: Security & Production Tips

✅ Use HTTPS
✅ Enable rate limiting
✅ Validate inputs (express-validator)
✅ Rotate JWT secrets
✅ Use migrations (e.g., Prisma / Knex)
✅ Log errors (Winston / Pino)


Conclusion

You’ve built a clean, secure, and scalable REST API with:

  • JWT authentication
  • PostgreSQL persistence
  • Structured Express architecture
  • Production-ready practices

This foundation can power SaaS platforms, dashboards, mobile apps, or automation systems.


Next Steps

  • Add refresh tokens
  • Implement role-based access
  • Add tests (Jest + Supertest)
  • Dockerize the API
  • Add GraphQL layer (Apollo / WPGraphQL-style)

Share:

Related Tutorials

How to Fix “‘adb’ is not recognized as an internal or external command”

If you’re seeing this error when trying to use Android Debug Bridge (ADB), it means your system can’t find the ADB executable. This comprehensive guide will walk you through understanding the problem and multiple solutions to fix it. Understanding the Problem ADB (Android Debug Bridge) is a command-line tool that’s part of the Android SDK […]

Stephen Ndegwa
·

How to Use Claude Code with AWS Bedrock

This guide shows how to run Claude Code using Anthropic models hosted on Amazon Bedrock, instead of Anthropic’s direct API. Overview Claude Code supports Amazon Bedrock as a backend. When enabled, it: Prerequisites 1. AWS Account with Bedrock Enabled 👉 AWS Bedrock Consolehttps://console.aws.amazon.com/bedrock/ Important: The first time you use Anthropic models, AWS requires you to […]

Stephen Ndegwa
·

How to Enable ICMP (Ping) on Windows Public Firewall

This guide explains how to allow ICMP (Ping) traffic on a Windows machine using the Public firewall profile. This is useful for network troubleshooting, monitoring, and connectivity testing. ⚠️ Important: This only makes the Windows host pingable. It does not affect routers, public IPs on firewalls (e.g. MikroTik), or tunnel brokers like Hurricane Electric if […]

Stephen Ndegwa
·