Building REST APIs with Express in Node.js

1. Overview

This guide teaches you how to build a REST API managing Order with Express — from initializing your project, defining routes, middleware, validating data, to centralized error handling. By the end, you’ll be able to build a complete CRUD API with automated tests using Jest and Supertest, and understand common pitfalls when starting with Express.

Throughout this article, we’ll build an example: an API managing orders (Order) for a Customer, supporting create, read, update, and delete operations.

2. Project Setup

Create a new Node.js project and install Express:

mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest

Add type: module to use import/export syntax (ES Module), and declare a test script in package.json:

{
  "name": "order-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  }
}

Note: Jest with ES Module requires the --experimental-vm-modules flag. If you prefer to avoid this complexity, you can remove "type": "module" and use require/module.exports (CommonJS) instead — both are valid, depending on your team’s preference.

3. Create a Basic Express Server

// src/app.js
import express from 'express';

const app = express();
app.use(express.json()); // parse JSON body into req.body

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

export default app;
// src/server.js
import app from './app.js';

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Order API running on port ${PORT}`);
});
  • express.json() is a built-in middleware that automatically parses Content-Type: application/json into an object in req.body. Without this line, req.body will be undefined.
  • Separating app.js from server.js keeps tests from needing to actually open a port — this is an important practice for Node.js REST APIs.

Try it out:

node src/server.js
curl http://localhost:3000/health
{"status":"UP"}

4. Define the Order Resource

We’ll use an in-memory array as a “database” to focus on the API without depending on a real database.

// src/orders/orderRepository.js
let orders = [];
let nextId = 1;

export function findAll() {
  return orders;
}

export function findById(id) {
  return orders.find((o) => o.id === id);
}

export function create(data) {
  const order = { id: nextId++, ...data, status: 'PENDING' };
  orders.push(order);
  return order;
}

export function update(id, data) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return null;
  orders[index] = { ...orders[index], ...data };
  return orders[index];
}

export function remove(id) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return false;
  orders.splice(index, 1);
  return true;
}

This repository mimics the CRUD operations you’d see in any ORM (Sequelize, Prisma…), just storing in RAM instead of a database.

5. Routing with Express Router

Instead of attaching all routes to app, use express.Router() to organize by resource:

// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';

const router = Router();

router.get('/', (req, res) => {
  res.json(repo.findAll());
});

router.get('/:id', (req, res, next) => {
  const order = repo.findById(Number(req.params.id));
  if (!order) return next({ status: 404, message: 'Order not found' });
  res.json(order);
});

router.post('/', (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});

router.put('/:id', (req, res, next) => {
  const order = repo.update(Number(req.params.id), req.body);
  if (!order) return next({ status: 404, message: 'Order not found' });
  res.json(order);
});

router.delete('/:id', (req, res, next) => {
  const deleted = repo.remove(Number(req.params.id));
  if (!deleted) return next({ status: 404, message: 'Order not found' });
  res.status(204).end();
});

export default router;

Attach the router to app with a /orders prefix:

// src/app.js (updated)
import orderRouter from './orders/orderRouter.js';

app.use('/orders', orderRouter);
  • router.get('/:id', ...) — :id is a route parameter, accessed via req.params.id (always a string, needs Number() for comparison).
  • Calling next({ status: 404, message: ... }) passes the error to the error-handling middleware (section 7), instead of repeating res.status(404)... in every route.
  • Use 204 No Content for successful DELETE — no response body.

6. Validating Input Data

Validating by hand with if/else easily misses cases. Use express-validator for cleaner, more explicit validation:

npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';

export const validateCreateOrder = [
  body('customerName').isString().notEmpty().withMessage('customerName is required'),
  body('amount').isFloat({ gt: 0 }).withMessage('amount must be greater than 0'),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
];

Attach the validator to the POST route:

// src/orders/orderRouter.js (updated)
import { validateCreateOrder } from './orderValidator.js';

router.post('/', validateCreateOrder, (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});
  • Each body(...) is a validation rule for one field; if it fails, errors accumulate in validationResult(req).
  • The last middleware in the array checks errors.isEmpty() — if there are errors, return 400 with details and don’t call next() to block the request from reaching the actual handler.

Send a request missing amount:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName": "John"}'
{"errors":[{"type":"field","msg":"amount must be greater than 0","path":"amount",...}]}

7. Centralized Error Handling

Express allows you to define error-handling middleware — a middleware with 4 parameters (err, req, res, next), placed after all routes:

// src/errorHandler.js
export function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const message = err.message || 'System error';

  if (status === 500) {
    console.error(err); // log the actual error for debugging, don't expose stack trace to client
  }

  res.status(status).json({ message });
}
// src/app.js (updated, add at the END of file)
import { errorHandler } from './errorHandler.js';

app.use(errorHandler);
  • Express recognizes this as an error handler based on the parameter count (4), not the function name.
  • Must place app.use(errorHandler) last, after all app.use(router) statements — Express processes middleware in declaration order.
  • Any next(err) or thrown error in a route (if using express-async-errors or try/catch) goes here.

Common mistake: forgetting to call next(err) in async code (e.g., await db.query(...) throws) prevents Express from catching the error, causing the server to crash or the request to hang. With Express 5 (newer versions), async handlers that throw automatically get caught — but with Express 4, you need to wrap in try/catch and manually call next(err).

8. Add a Service Layer to Separate Business Logic

When logic becomes more complex (e.g., don’t allow creating an order if the amount exceeds the customer’s limit), don’t stuff everything into the router. Extract to orderService:

// src/orders/orderService.js
import * as repo from './orderRepository.js';

const MAX_AMOUNT = 100_000_000;

export function createOrder(data) {
  if (data.amount > MAX_AMOUNT) {
    const err = new Error('Amount exceeds limit');
    err.status = 422;
    throw err;
  }
  return repo.create(data);
}
// src/orders/orderRouter.js (updated POST route)
import * as orderService from './orderService.js';

router.post('/', validateCreateOrder, (req, res, next) => {
  try {
    const order = orderService.createOrder(req.body);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

The router now only handles HTTP (status codes, parameter parsing), while orderService handles business logic. This is the same Controller–Service pattern you see in other frameworks, making it easy to test business logic independently of HTTP.

9. Write Tests with Jest and Supertest

Supertest lets you call the API directly on the app object (Express instance) without opening a real port — this is why section 3 separated app.js from server.js.

// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';

describe('Order API', () => {
  it('create order successfully returns 201 and id', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'John', amount: 500000 });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      customerName: 'John',
      amount: 500000,
      status: 'PENDING',
    });
    expect(response.body.id).toBeDefined();
  });

  it('missing amount returns 400', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'John' });

    expect(response.status).toBe(400);
  });

  it('get non-existent order returns 404', async () => {
    const response = await request(app).get('/orders/9999');

    expect(response.status).toBe(404);
    expect(response.body.message).toBe('Order not found');
  });
});

Run the tests:

npm test
PASS  tests/order.test.js
  Order API
    ✓ create order successfully returns 201 and id
    ✓ missing amount returns 400
    ✓ get non-existent order returns 404
  • request(app) creates a virtual HTTP client attached directly to the Express app — faster than testing over real network traffic.
  • toMatchObject only checks specified fields, ignoring others (like auto-generated id) — convenient for testing responses with dynamic values.

Note: since orders is an in-memory array shared between tests, tests running sequentially can affect each other (e.g., an order created in test 1 still exists when test 2 runs). With a real API connected to a database, reset data in beforeEach or use transaction rollback.

10. When Not to Use Plain Express

  • Large APIs with complex domains: Express has no enforced conventions for structure, making it easy to create “spaghetti code” if the team doesn’t self-impose standards. Consider NestJS (with DI, modules, decorators like Spring) if your team is familiar with clear layered architecture.
  • Need strong type-safety: Plain Express + JavaScript won’t catch type errors at compile time. For large projects, adopt TypeScript from the start — converting later is far more expensive.
  • High-throughput, complex streaming/WebSocket: Express works fine, but Fastify or performance-optimized frameworks (faster JSON serialization benchmarks) may suit systems with extreme traffic better.

For small-to-medium APIs with straightforward CRUD like the example above, Express remains a quick, clean choice with a large community and plenty of ready-made middleware (auth, rate-limiting, CORS…).

11. Conclusion

Through this guide, you’ve built a complete Order REST API with Express: routing via Router, input validation with express-validator, centralized error handling with 4-parameter middleware, and separated business logic into a service layer for easier testing. Testing with Jest + Supertest gives you confidence to refactor without breaking API behavior. From this foundation, you can add real database connections (Prisma, Mongoose), authentication (JWT), or graduate to TypeScript as your project grows.

nodejsexpressrest-apijavascripttesting