Vucense

Build a REST API with Node.js and Express 2026: Complete Tutorial

🟡Intermediate

Build a production-ready REST API with Node.js 22 and Express 5 on Ubuntu 24.04 in 2026. Covers routing, middleware, JWT auth, PostgreSQL integration, input validation, error handling, and Docker deployment.

Divya Prakash

Author

Divya Prakash

AI Systems Architect & Founder

Published

Duration

Reading

19 min

Build

25 min

Build a REST API with Node.js and Express 2026: Complete Tutorial
Article Roadmap

Key Takeaways

  • Express 5 async support: Route handlers can be async functions — unhandled rejections automatically become 500 errors. No more .catch(next) boilerplate on every route.
  • Zod for validation: z.object({ email: z.string().email(), age: z.number().min(18) }).parse(req.body) throws a ZodError with field-level messages on invalid input — clean, type-safe validation in one line.
  • Parameterised queries always: pool.query('SELECT * FROM users WHERE id = $1', [userId]) prevents SQL injection. Never concatenate user input into SQL strings.
  • JWT lifecycle: Access tokens expire in 15 minutes, refresh tokens in 7 days. Refresh token rotation (issue new refresh token on each use) prevents replay attacks without requiring a token blacklist.

Introduction

Direct Answer: How do I build a REST API with Node.js and Express on Ubuntu 24.04 in 2026?

Install Node.js 22 LTS with curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - && sudo apt-get install -y nodejs. Create a project with npm init -y && npm install express zod pg jsonwebtoken bcrypt helmet cors. Create server.js with import express from 'express'; const app = express(); app.use(express.json()); app.get('/health', (req, res) => res.json({ status: 'ok' })); app.listen(3000). Run with node server.js. For a production-ready API add: helmet() for security headers, cors() for cross-origin requests, a PostgreSQL pool with the pg package, Zod schemas for request validation, JWT authentication middleware, and a global error handler. Structure routes in separate files under /routes/, middleware under /middleware/, and database queries under /db/. The complete stack covers: CRUD endpoints, authentication with JWT access + refresh tokens, input validation, error handling, and a Dockerfile for containerised deployment.


Setup

# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version && npm --version

Expected output:

v22.11.0
10.9.0
# Create project
mkdir -p ~/node-api && cd ~/node-api
npm init -y

# Install dependencies
npm install express@5 zod pg jsonwebtoken bcrypt helmet cors dotenv
npm install --save-dev nodemon jest supertest

# Use ES modules (modern Node.js style)
npm pkg set type=module

Part 1: Project Structure

node-api/
├── server.js              ← Entry point
├── app.js                 ← Express app (exported for testing)
├── .env                   ← Environment variables
├── routes/
│   ├── auth.js            ← POST /auth/register, /auth/login
│   └── users.js           ← GET/PUT /users/:id
├── middleware/
│   ├── auth.js            ← JWT verification middleware
│   └── validate.js        ← Zod request validation middleware
├── db/
│   └── pool.js            ← PostgreSQL connection pool
└── Dockerfile
mkdir -p routes middleware db

Part 2: Database Setup

# Create the database schema
sudo -u postgres psql << 'SQL'
CREATE DATABASE nodeapi;
CREATE USER apiuser WITH PASSWORD 'secure_password_here';
GRANT ALL PRIVILEGES ON DATABASE nodeapi TO apiuser;
\c nodeapi
CREATE TABLE users (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email       TEXT UNIQUE NOT NULL,
    password    TEXT NOT NULL,
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE refresh_tokens (
    id          BIGSERIAL PRIMARY KEY,
    user_id     UUID REFERENCES users(id) ON DELETE CASCADE,
    token       TEXT UNIQUE NOT NULL,
    expires_at  TIMESTAMPTZ NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);
GRANT ALL ON ALL TABLES IN SCHEMA public TO apiuser;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO apiuser;
SQL
echo "Database ready"

Expected output:

CREATE TABLE
CREATE TABLE
GRANT
Database ready
# .env file
cat > .env << 'EOF'
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://apiuser:secure_password_here@localhost:5432/nodeapi
JWT_SECRET=change_this_to_a_random_64_char_secret_in_production
JWT_REFRESH_SECRET=change_this_to_another_random_64_char_secret
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
EOF
// db/pool.js
import pg from 'pg';
import 'dotenv/config';

const { Pool } = pg;

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,                  // Max pool size
  idleTimeoutMillis: 30000, // Close idle connections after 30s
  connectionTimeoutMillis: 2000,
});

// Test connection on startup
pool.on('error', (err) => {
  console.error('Unexpected database error:', err);
  process.exit(1);
});

export async function query(text, params) {
  const start = Date.now();
  const res = await pool.query(text, params);
  const duration = Date.now() - start;
  if (process.env.NODE_ENV === 'development') {
    console.debug(`[DB] ${duration}ms — ${text.substring(0, 60)}`);
  }
  return res;
}

Part 3: Validation Middleware

// middleware/validate.js
import { ZodError } from 'zod';

/**
 * Zod validation middleware factory.
 * Usage: router.post('/users', validate(createUserSchema), handler)
 */
export function validate(schema) {
  return (req, res, next) => {
    try {
      req.body = schema.parse(req.body);   // Throws ZodError on invalid input
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        return res.status(400).json({
          error: 'Validation failed',
          details: err.errors.map(e => ({
            field: e.path.join('.'),
            message: e.message,
          })),
        });
      }
      next(err);
    }
  };
}

Part 4: Auth Routes (Register + Login + Refresh)

// routes/auth.js
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { query } from '../db/pool.js';
import { validate } from '../middleware/validate.js';

const router = Router();

// Validation schemas
const registerSchema = z.object({
  email:    z.string().email('Invalid email format'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  name:     z.string().min(2, 'Name must be at least 2 characters').max(100),
});

const loginSchema = z.object({
  email:    z.string().email(),
  password: z.string().min(1),
});

function generateTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN }
  );
  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
  );
  return { accessToken, refreshToken };
}

// POST /auth/register
router.post('/register', validate(registerSchema), async (req, res) => {
  const { email, password, name } = req.body;

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

  const hashedPassword = await bcrypt.hash(password, 12);
  const { rows } = await query(
    'INSERT INTO users (email, password, name) VALUES ($1, $2, $3) RETURNING id, email, name, created_at',
    [email, hashedPassword, name]
  );

  const user = rows[0];
  const { accessToken, refreshToken } = generateTokens(user.id);

  // Store refresh token
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  await query(
    'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)',
    [user.id, refreshToken, expiresAt]
  );

  res.status(201).json({
    user: { id: user.id, email: user.email, name: user.name },
    accessToken,
    refreshToken,
  });
});

// POST /auth/login
router.post('/login', validate(loginSchema), async (req, res) => {
  const { email, password } = req.body;

  const { rows } = await query('SELECT * FROM users WHERE email = $1', [email]);
  const user = rows[0];

  if (!user || !(await bcrypt.compare(password, user.password))) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  const { accessToken, refreshToken } = generateTokens(user.id);
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  await query(
    'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)',
    [user.id, refreshToken, expiresAt]
  );

  res.json({
    user: { id: user.id, email: user.email, name: user.name },
    accessToken,
    refreshToken,
  });
});

// POST /auth/refresh — refresh token rotation
router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) return res.status(400).json({ error: 'Refresh token required' });

  let payload;
  try {
    payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // Check token exists and hasn't expired
  const { rows } = await query(
    'DELETE FROM refresh_tokens WHERE token = $1 AND expires_at > NOW() RETURNING user_id',
    [refreshToken]
  );
  if (rows.length === 0) return res.status(401).json({ error: 'Refresh token expired or already used' });

  // Issue new token pair (rotation)
  const tokens = generateTokens(payload.sub);
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  await query(
    'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)',
    [rows[0].user_id, tokens.refreshToken, expiresAt]
  );

  res.json(tokens);
});

export default router;

Part 5: Auth Middleware + User Routes

// middleware/auth.js
import jwt from 'jsonwebtoken';

export function requireAuth(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token required' });
  }
  try {
    const payload = jwt.verify(auth.slice(7), process.env.JWT_SECRET);
    req.userId = payload.sub;
    next();
  } catch {
    res.status(401).json({ error: 'Invalid or expired access token' });
  }
}
// routes/users.js
import { Router } from 'express';
import { z } from 'zod';
import { query } from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';

const router = Router();

const updateSchema = z.object({
  name:  z.string().min(2).max(100).optional(),
  email: z.string().email().optional(),
}).refine(obj => Object.keys(obj).length > 0, { message: 'No fields to update' });

// GET /users/me — get current user profile
router.get('/me', requireAuth, async (req, res) => {
  const { rows } = await query(
    'SELECT id, email, name, created_at FROM users WHERE id = $1',
    [req.userId]
  );
  if (!rows[0]) return res.status(404).json({ error: 'User not found' });
  res.json(rows[0]);
});

// PUT /users/me — update current user
router.put('/me', requireAuth, validate(updateSchema), async (req, res) => {
  const updates = Object.entries(req.body);
  const setClauses = updates.map(([key], i) => `${key} = $${i + 1}`).join(', ');
  const values = [...updates.map(([, val]) => val), req.userId];

  const { rows } = await query(
    `UPDATE users SET ${setClauses}, updated_at = NOW()
     WHERE id = $${values.length}
     RETURNING id, email, name, updated_at`,
    values
  );
  res.json(rows[0]);
});

export default router;

Part 6: App and Server Entry Points

// app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import 'dotenv/config';
import authRouter from './routes/auth.js';
import usersRouter from './routes/users.js';

const app = express();

// Security middleware
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json({ limit: '10kb' }));

// Routes
app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
app.use('/auth',  authRouter);
app.use('/users', usersRouter);

// Global error handler (Express 5: catches async errors automatically)
app.use((err, req, res, _next) => {
  console.error(err);
  const status = err.status || 500;
  res.status(status).json({
    error: status === 500 ? 'Internal server error' : err.message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  });
});

export default app;
// server.js
import app from './app.js';
import { pool } from './db/pool.js';

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

// Verify DB connection on startup
await pool.query('SELECT 1');
console.log('Database connected');

const server = app.listen(PORT, () => {
  console.log(`API running on http://localhost:${PORT}`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
  server.close(async () => {
    await pool.end();
    process.exit(0);
  });
});
node server.js

Expected output:

Database connected
API running on http://localhost:3000

Part 7: Testing the API

# Health check
curl -s http://localhost:3000/health | python3 -m json.tool

Expected output:

{
    "status": "ok",
    "timestamp": "2026-04-22T10:00:00.000Z"
}
# Register a user
curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"securepass123","name":"Alice"}' \
  | python3 -m json.tool

Expected output:

{
    "user": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "email": "alice@example.com",
        "name": "Alice"
    },
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
# Get own profile (use access token from register response)
ACCESS_TOKEN="paste_token_here"
curl -s http://localhost:3000/users/me \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  | python3 -m json.tool

Expected output:

{
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "alice@example.com",
    "name": "Alice",
    "created_at": "2026-04-22T10:00:00.000Z"
}
# Test validation — missing fields
curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"not-an-email","password":"short"}' \
  | python3 -m json.tool

Expected output:

{
    "error": "Validation failed",
    "details": [
        {"field": "email", "message": "Invalid email format"},
        {"field": "password", "message": "Password must be at least 8 characters"},
        {"field": "name", "message": "Required"}
    ]
}

Dockerfile

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]

Troubleshooting

Cannot use import statement in a module error

Fix: Ensure "type": "module" in package.json, or rename files to .mjs.

JWT JsonWebTokenError: invalid signature

Cause: Different secret used to sign vs verify. Fix: Confirm JWT_SECRET is consistent in .env and not accidentally empty.

pg connection timeout

Fix: Check DATABASE_URL format — it must be postgresql://user:pass@host:port/db. Verify PostgreSQL is running: sudo systemctl is-active postgresql.


Conclusion

A production Node.js 22 + Express 5 API with: type-safe Zod validation, JWT auth with refresh token rotation, parameterised PostgreSQL queries via pg, security headers via helmet, and a global async error handler. The architecture — separate route files, middleware, and a database module — scales to large codebases without restructuring.

See Docker Compose Tutorial to containerise this API alongside PostgreSQL and Redis, and GitHub Actions CI/CD to automate testing and deployment on every push.


People Also Ask

Should I use Express or Fastify for a new Node.js API in 2026?

Express 5 is the safe default — massive ecosystem, familiar to every Node.js developer, excellent documentation. Fastify is 2–3× faster due to its schema-based serialisation, better TypeScript support, and built-in schema validation. Choose Fastify if raw throughput matters (high-traffic APIs, latency-sensitive services). Choose Express if team familiarity, ecosystem breadth, and a lower learning curve are priorities. For most CRUD APIs serving under 1,000 req/s, the performance difference is irrelevant — Express 5 is the right choice.

Should I use an ORM (Sequelize, Prisma) or raw SQL?

Prisma is the recommended ORM in 2026 for TypeScript projects — its generated client provides type-safe queries, migrations via prisma migrate, and a schema-first workflow. Raw pg queries (as used in this guide) are faster and give full SQL control — better for performance-critical paths or complex queries that ORMs generate poorly. A pragmatic approach: use Prisma for standard CRUD, drop to raw SQL for reporting queries and anything involving CTEs, window functions, or query optimisation.


Further Reading


Tested on: Ubuntu 24.04 LTS (Hetzner CX22). Node.js 22.11.0, Express 5.0.1, pg 8.13.0. Last verified: April 22, 2026.

Further Reading

All Dev Corner

Comments