Designing a REST API with Node.js

· 10 min read · Updated March 18, 2026 · intermediate
javascript node express api rest

Building a REST API is a fundamental skill for any backend developer. In this tutorial, you’ll learn how to design and build a production-ready REST API using Node.js and Express. By the end, you’ll understand routing, middleware patterns, error handling, and how to structure your API for scalability.

What You’ll Build

We’ll build a RESTful API for managing users — a classic example that demonstrates all the core concepts:

  • GET /api/users — Retrieve all users
  • GET /api/users/:id — Retrieve a single user by ID
  • POST /api/users — Create a new user
  • PUT /api/users/:id — Update an existing user
  • DELETE /api/users/:id — Delete a user

This API will include proper error handling, logging middleware, request validation, and follow REST conventions throughout.

Prerequisites

Before starting this tutorial, make sure you have:

  • Node.js 18 or higher installed (Express 5 requires Node.js 18+)
  • Basic understanding of JavaScript and asynchronous programming
  • Familiarity with HTTP methods and status codes
  • A terminal or command prompt

Project Setup

Let’s initialize the project and install Express.

Creating the Project

Open your terminal and create a new directory:

mkdir user-api && cd user-api
npm init -y

This creates a package.json file with default settings. Now install Express:

npm install express

Creating the Server

Create a file named server.js in your project root:

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

app.use(express.json());

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

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Start the server:

node server.js

You should see Server running on port 3000. Test the health endpoint:

curl http://3000/api/health

Now let’s build out the full API.

RESTful Routes

REST (Representational State Transfer) uses HTTP methods semantically. Each method has a specific purpose:

MethodPurposeIdempotent
GETRetrieve resourcesYes
POSTCreate new resourcesNo
PUTReplace existing resourcesYes
PATCHPartially update resourcesNo
DELETERemove resourcesYes

Basic Route Definition

In Express, routes follow this pattern:

app.METHOD(path, handler);

The handler is a function that receives req (request), res (response), and optionally next. Let’s build our user routes.

Route Parameters

Route parameters capture dynamic segments of the URL:

// GET /api/users/42
app.get('/api/users/:id', (req, res) => {
  const { id } = req.params;
  res.json({ id, name: 'Example User' });
});

Query strings work similarly but are optional:

// GET /api/search?name=john&limit=10
app.get('/api/search', (req, res) => {
  const { name, limit = 10 } = req.query;
  res.json({ name, limit: parseInt(limit) });
});

Modular Route Organization

For larger applications, organizing routes into separate files keeps your code maintainable. Create a routes folder:

mkdir routes

Create routes/users.js:

const express = require('express');
const router = express.Router();

// In-memory database for demo
let users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' }
];
let nextId = 2;

// GET /api/users
router.get('/', (req, res) => {
  res.json({ data: users });
});

// GET /api/users/:id
router.get('/:id', (req, res, next) => {
  const id = parseInt(req.params.id);
  const user = users.find(u => u.id === id);
  
  if (!user) {
    return next(new Error('User not found'));
  }
  
  res.json({ data: user });
});

// POST /api/users
router.post('/', (req, res, next) => {
  const { name, email } = req.body;
  
  if (!name || !email) {
    return next(new Error('Name and email are required'));
  }
  
  const newUser = { id: nextId++, name, email };
  users.push(newUser);
  
  res.status(201).json({ data: newUser });
});

// PUT /api/users/:id
router.put('/:id', (req, res, next) => {
  const id = parseInt(req.params.id);
  const index = users.findIndex(u => u.id === id);
  
  if (index === -1) {
    return next(new Error('User not found'));
  }
  
  users[index] = { ...users[index], ...req.body };
  res.json({ data: users[index] });
});

// DELETE /api/users/:id
router.delete('/:id', (req, res, next) => {
  const id = parseInt(req.params.id);
  const index = users.findIndex(u => u.id === id);
  
  if (index === -1) {
    return next(new Error('User not found'));
  }
  
  users.splice(index, 1);
  res.status(204).send();
});

module.exports = router;

Update server.js to use the router:

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

app.use(express.json());

// Mount the users router
app.use('/api/users', require('./routes/users'));

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Test your endpoints:

# Get all users
curl http://localhost:3000/api/users

# Create a user
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Bob", "email": "bob@example.com"}'

# Get a single user
curl http://localhost:3000/api/users/1

Middleware

Middleware functions are the backbone of Express applications. They have access to the request (req), response (res), and can either handle the request or pass it to the next middleware via next().

Understanding the Middleware Flow

When a request arrives, Express executes middleware in the order they were defined:

Request → JSON Parser → Logger → Auth → Routes → Error Handler → Response

Creating Custom Middleware

Let’s create a logging middleware. Create middleware/logger.js:

const logger = (req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`${timestamp} - ${req.method} ${req.path}`);
  next();
};

module.exports = logger;

Create an authentication middleware. Create middleware/auth.js:

const authenticate = (req, res, next) => {
  const token = req.headers.authorization;
  
  // Simple check for demonstration
  if (!token || token !== 'Bearer secret-token') {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  next();
};

module.exports = authenticate;

Create a validation middleware. Create middleware/validate.js:

const validateUser = (req, res, next) => {
  const { name, email } = req.body;
  
  if (!name || typeof name !== 'string' || name.trim() === '') {
    return res.status(400).json({ error: 'Valid name is required' });
  }
  
  if (!email || typeof email !== 'string' || !email.includes('@')) {
    return res.status(400).json({ error: 'Valid email is required' });
  }
  
  next();
};

module.exports = { validateUser };

Applying Middleware

Update server.js to use our middleware:

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

const logger = require('./middleware/logger');
const authenticate = require('./middleware/auth');
const { validateUser } = require('./middleware/validate');

// Apply middleware in order
app.use(logger);
app.use(express.json());

// Public route
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Protected routes - authenticate first
app.use('/api/users', authenticate);
app.use('/api/users', require('./routes/users'));

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Now try accessing users without a token:

curl http://localhost:3000/api/users
# Returns: {"error":"Unauthorized"}

curl -H "Authorization: Bearer secret-token" http://localhost:3000/api/users
# Returns: {"data":[...]}

Configurable Middleware (Factory Pattern)

Sometimes you need middleware with custom options. Create a factory function:

// middleware/timedLogger.js
module.exports = function (options = {}) {
  const prefix = options.prefix || '[LOG]';
  const includeBody = options.includeBody || false;
  
  return function (req, res, next) {
    const start = Date.now();
    
    // Log after response is sent
    res.on('finish', () => {
      const duration = Date.now() - start;
      console.log(`${prefix} ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
    });
    
    next();
  };
};

Usage:

app.use(timedLogger({ prefix: '[API]', includeBody: true }));

Error Handling

Proper error handling is crucial for a reliable API. Express distinguishes between synchronous and asynchronous errors.

Synchronous Errors

Express automatically catches errors thrown in synchronous route handlers:

app.get('/api/users/:id', (req, res) => {
  throw new Error('User not found'); // Express catches this automatically
});

Asynchronous Errors

Async route handlers require explicit error handling. In Express 4, you must pass errors to next(). In Express 5, rejected promises are handled automatically.

Express 4: Async Handler Wrapper

// Wraps async handlers to catch errors and pass to next()
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Usage
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await database.findUser(req.params.id);
  res.json(user);
}));

Express 5: Native Async Support

Express 5 handles rejected promises automatically:

app.get('/api/users/:id', async (req, res, next) => {
  const user = await database.findUser(req.params.id);
  // If this throws, Express catches it automatically
  res.json(user);
});

Custom Error Class

Create a reusable error class for consistent error handling:

// utils/AppError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

Error-Handling Middleware

Error-handling middleware must have four parameters: (err, req, res, next). It must be defined last, after all routes and other middleware.

// Error-handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  
  const statusCode = err.statusCode || 500;
  const response = {
    status: 'error',
    message: err.message || 'Internal Server Error'
  };
  
  // Include stack trace only in development
  if (process.env.NODE_ENV !== 'production') {
    response.stack = err.stack;
  }
  
  res.status(statusCode).json(response);
});

404 Handler

Add a catch-all for undefined routes:

// Place AFTER all routes
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

Complete Error Handling Example

Here’s a fully updated server.js with proper error handling:

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

const logger = require('./middleware/logger');
const authenticate = require('./middleware/auth');
const { validateUser } = require('./middleware/validate');
const AppError = require('./utils/AppError');

// Apply middleware
app.use(logger);
app.use(express.json());

// Health check (public)
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Protected routes
app.use('/api/users', authenticate);

// User routes with validation on POST
const userRouter = require('./routes/users');
app.post('/api/users', validateUser, userRouter);
app.use('/api/users', userRouter);

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

// Global error handler
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const response = {
    status: 'error',
    message: err.message || 'Internal Server Error'
  };
  
  if (process.env.NODE_ENV !== 'production') {
    response.stack = err.stack;
  }
  
  res.status(statusCode).json(response);
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Testing Your API

Testing is essential for maintaining reliable APIs. Here’s a brief introduction to endpoint testing.

Using cURL (Manual Testing)

# Test health endpoint
curl http://localhost:3000/api/health

# Create user (with auth)
curl -X POST http://localhost:3000/api/users \
  -H "Authorization: Bearer secret-token" \
  -H "Content-Type: application/json" \
  -d '{"name": "Charlie", "email": "charlie@example.com"}'

# Get all users
curl -H "Authorization: Bearer secret-token" http://localhost:3000/api/users

# Update user
curl -X PUT http://localhost:3000/api/users/1 \
  -H "Authorization: Bearer secret-token" \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Smith"}'

# Delete user
curl -X DELETE http://localhost:3000/api/users/1 \
  -H "Authorization: Bearer secret-token"

Using Jest and Supertest

For automated testing, install testing dependencies:

npm install --save-dev jest supertest

Add a test script to package.json:

{
  "scripts": {
    "test": "jest",
    "start": "node server.js"
  }
}

Create server.test.js:

const request = require('supertest');
const express = require('express');

// Create test app (import your actual routes)
const app = express();
app.use(express.json());

let users = [{ id: 1, name: 'Test', email: 'test@example.com' }];

app.get('/api/users', (req, res) => {
  res.json({ data: users });
});

app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  const newUser = { id: users.length + 1, name, email };
  users.push(newUser);
  res.status(201).json({ data: newUser });
});

describe('User API', () => {
  it('GET /api/users should return all users', async () => {
    const response = await request(app).get('/api/users');
    expect(response.status).toBe(200);
    expect(response.body.data).toHaveLength(1);
  });

  it('POST /api/users should create a new user', async () => {
    const newUser = { name: 'Jane', email: 'jane@example.com' };
    const response = await request(app)
      .post('/api/users')
      .send(newUser);
    
    expect(response.status).toBe(201);
    expect(response.body.data.name).toBe('Jane');
  });

  it('POST /api/users should return 400 for invalid data', async () => {
    const invalidUser = { name: '' };
    const response = await request(app)
      .post('/api/users')
      .send(invalidUser);
    
    expect(response.status).toBe(400);
  });
});

Run tests:

npm test

Summary

You’ve built a complete REST API with Node.js and Express. Here’s what we covered:

  • Project Setup: Initialized an Express application with proper structure
  • RESTful Routes: Created CRUD endpoints using proper HTTP methods and status codes
  • Middleware: Implemented logging, authentication, and validation middleware
  • Error Handling: Added async error handling with custom error classes and global error handlers
  • Testing: Learned manual testing with cURL and automated testing with Jest

Key Takeaways

ConceptPattern
Route definitionapp.METHOD(path, handler)
Async errorsUse try/catch + next(err) (Express 4) or let Express 5 handle it
Error-handling middlewareMust have 4 params: (err, req, res, next), defined LAST
Middleware orderRuns in definition order; place routes after middleware
Status codes200 (OK), 201 (Created), 400 (Bad Request), 404 (Not Found), 500 (Server Error)

Next Steps

Now that you’ve built this API, try these enhancements:

  • Add input validation with a library like Joi or Zod
  • Connect to a real database (MongoDB, PostgreSQL)
  • Add pagination for the GET /users endpoint
  • Implement rate limiting to prevent abuse
  • Add request logging to a file or logging service

Continue to the next tutorial in this series to learn about connecting your API to a database and adding authentication with JWT tokens.