Tutorial

Building a Production-Ready REST API with Node.js and Express: From Design to Deployment

January 15, 2026
17 views
Building a Production-Ready REST API with Node.js and Express: From Design to Deployment

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”...

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

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

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:

  1. Build once (immutable artifact)
  2. Run the same artifact in each environment
  3. 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.

Share: Twitter Facebook
Category: Tutorial
Published: January 15, 2026

Related Posts

Back to Blog