REST API Design Made Simple with Express.js

Jatin VermaJatin Verma
7 min read

Introduction

Building maintainable REST APIs is crucial for any backend application's long-term success. A well-structured Express.js API not only makes your code easier to understand and modify but also ensures consistency, scalability, and developer productivity. This guide will walk you through creating clean, professional APIs using Express.js with proper separation of concerns and industry best practices.

Why Project Structure Matters

A clean project structure is the foundation of maintainable code. When your API grows from handling a few routes to dozens or hundreds, proper organization becomes essential for:

  • Developer onboarding: New team members can quickly understand the codebase

  • Debugging: Issues are easier to locate and fix

  • Testing: Well-separated concerns make unit testing straightforward

  • Scalability: Adding new features doesn't require restructuring existing code

Project Structure

Here's a recommended folder structure for a scalable Express.js API:

project-root/
├── src/
│   ├── controllers/
│   │   └── userController.js
│   ├── routes/
│   │   └── userRoutes.js
│   ├── middleware/
│   │   ├── errorHandler.js
│   │   └── validation.js
│   ├── models/
│   │   └── User.js
│   └── app.js
├── package.json
└── server.js

This structure follows the separation of concerns principle:

  • Controllers: Handle business logic and data processing

  • Routes: Define API endpoints and link them to controllers

  • Middleware: Handle cross-cutting concerns like authentication, logging, and error handling

  • Models: Define data structures and database interactions

CRUD Operations with Users Resource

Let's implement a complete CRUD API for a users resource. Here's how HTTP methods map to operations:

HTTP MethodRouteOperationDescription
GET/usersRead AllRetrieve list of all users
POST/usersCreateAdd a new user
GET/users/:idRead OneRetrieve a specific user
PUT/users/:idUpdateModify an existing user
DELETE/users/:idDeleteRemove a user

API Request Flow

Client Request → Route Handler → Controller → Business Logic → Response

Setting Up the Main Application

server.js

const app = require("./src/app");

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

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

src/app.js

const express = require("express");
const userRoutes = require("./routes/userRoutes");
const errorHandler = require("./middleware/errorHandler");

const app = express();

// Built-in middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// API routes
app.use("/api/v1/users", userRoutes);

// Error handling middleware (must be last)
app.use(errorHandler);

module.exports = app;

User Controller

src/controllers/userController.js

// In a real application, this would connect to a database
let users = [
  { id: 1, name: "John Doe", email: "john@example.com", age: 30 },
  { id: 2, name: "Jane Smith", email: "jane@example.com", age: 25 },
];

let nextId = 3;

const userController = {
  // GET /users - Get all users
  getAllUsers: (req, res, next) => {
    try {
      res.status(200).json({
        message: "Users retrieved successfully",
        data: users,
        count: users.length,
      });
    } catch (error) {
      next(error);
    }
  },

  // GET /users/:id - Get user by ID
  getUserById: (req, res, next) => {
    try {
      const { id } = req.params;
      const user = users.find((u) => u.id === parseInt(id));

      if (!user) {
        return res.status(404).json({
          message: "User not found",
        });
      }

      res.status(200).json({
        message: "User retrieved successfully",
        data: user,
      });
    } catch (error) {
      next(error);
    }
  },

  // POST /users - Create new user
  createUser: (req, res, next) => {
    try {
      const { name, email, age } = req.body;

      // Basic validation
      if (!name || !email) {
        return res.status(400).json({
          message: "Name and email are required",
        });
      }

      // Check if email already exists
      const existingUser = users.find((u) => u.email === email);
      if (existingUser) {
        return res.status(409).json({
          message: "User with this email already exists",
        });
      }

      const newUser = {
        id: nextId++,
        name,
        email,
        age: age || null,
      };

      users.push(newUser);

      res.status(201).json({
        message: "User created successfully",
        data: newUser,
      });
    } catch (error) {
      next(error);
    }
  },

  // PUT /users/:id - Update user
  updateUser: (req, res, next) => {
    try {
      const { id } = req.params;
      const { name, email, age } = req.body;

      const userIndex = users.findIndex((u) => u.id === parseInt(id));

      if (userIndex === -1) {
        return res.status(404).json({
          message: "User not found",
        });
      }

      // Update user data
      if (name) users[userIndex].name = name;
      if (email) users[userIndex].email = email;
      if (age !== undefined) users[userIndex].age = age;

      res.status(200).json({
        message: "User updated successfully",
        data: users[userIndex],
      });
    } catch (error) {
      next(error);
    }
  },

  // DELETE /users/:id - Delete user
  deleteUser: (req, res, next) => {
    try {
      const { id } = req.params;
      const userIndex = users.findIndex((u) => u.id === parseInt(id));

      if (userIndex === -1) {
        return res.status(404).json({
          message: "User not found",
        });
      }

      const deletedUser = users.splice(userIndex, 1)[0];

      res.status(200).json({
        message: "User deleted successfully",
        data: deletedUser,
      });
    } catch (error) {
      next(error);
    }
  },
};

module.exports = userController;

User Routes

src/routes/userRoutes.js

const express = require("express");
const userController = require("../controllers/userController");

const router = express.Router();

// Define routes and link to controller methods
router.get("/", userController.getAllUsers);
router.get("/:id", userController.getUserById);
router.post("/", userController.createUser);
router.put("/:id", userController.updateUser);
router.delete("/:id", userController.deleteUser);

module.exports = router;

Error Handling Middleware

Proper error handling is crucial for a professional API. Express.js error handling middleware catches and processes errors consistently.

src/middleware/errorHandler.js

const errorHandler = (err, req, res, next) => {
  console.error("Error:", err);

  // Default error response
  let statusCode = 500;
  let message = "Internal Server Error";

  // Handle specific error types
  if (err.name === "ValidationError") {
    statusCode = 400;
    message = "Validation Error";
  } else if (err.name === "CastError") {
    statusCode = 400;
    message = "Invalid ID format";
  } else if (err.code === 11000) {
    statusCode = 409;
    message = "Duplicate resource";
  }

  // Send error response
  res.status(statusCode).json({
    message,
    ...(process.env.NODE_ENV === "development" && { stack: err.stack }),
  });
};

module.exports = errorHandler;

Response Format Standards

Consistency in API responses makes your API easier to consume. Here are the standard response formats used in this guide:

Success Responses

// Single resource
{
  "message": "User retrieved successfully",
  "data": { "id": 1, "name": "John Doe", "email": "john@example.com" }
}

// Multiple resources
{
  "message": "Users retrieved successfully",
  "data": [...],
  "count": 10
}

Error Responses

// Client error (4xx)
{
  "message": "User not found"
}

// Server error (5xx)
{
  "message": "Internal Server Error"
}

HTTP Status Codes

Using proper HTTP status codes makes your API intuitive:

  • 200 OK: Successful GET, PUT, DELETE

  • 201 Created: Successful POST

  • 400 Bad Request: Invalid request data

  • 404 Not Found: Resource doesn't exist

  • 409 Conflict: Resource already exists

  • 500 Internal Server Error: Server-side errors

Best Practices for Scalable APIs

1. API Versioning

Always version your API to maintain backward compatibility:

// Version in URL
app.use("/api/v1/users", userRoutes);
app.use("/api/v2/users", userRoutesV2);

// Version in headers (alternative)
app.use("/api/users", versionMiddleware, userRoutes);

2. Input Validation Middleware

src/middleware/validation.js

const validateUser = (req, res, next) => {
  const { name, email } = req.body;

  if (!name || typeof name !== "string") {
    return res.status(400).json({
      message: "Name is required and must be a string",
    });
  }

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!email || !emailRegex.test(email)) {
    return res.status(400).json({
      message: "Valid email is required",
    });
  }

  next();
};

module.exports = { validateUser };

3. Environment Configuration

Use environment variables for configuration:

// .env file
PORT=3000
NODE_ENV=development
DB_CONNECTION_STRING=mongodb://localhost:27017/myapp

// In your app
const port = process.env.PORT || 3000;
const dbUrl = process.env.DB_CONNECTION_STRING;

4. Logging

Implement proper logging for debugging and monitoring:

const winston = require("winston");

const logger = winston.createLogger({
  level: "info",
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: "error.log", level: "error" }),
    new winston.transports.File({ filename: "combined.log" }),
  ],
});

5. Security Middleware

Add security middleware for production:

const helmet = require("helmet");
const cors = require("cors");
const rateLimit = require("express-rate-limit");

app.use(helmet()); // Security headers
app.use(cors()); // CORS configuration
app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // limit each IP to 100 requests per windowMs
  })
);

Testing Your API

Here are some example requests you can test with tools like Postman or curl:

# Get all users
GET http://localhost:3000/api/v1/users

# Create a new user
POST http://localhost:3000/api/v1/users
Content-Type: application/json
{
  "name": "Alice Johnson",
  "email": "alice@example.com",
  "age": 28
}

# Get user by ID
GET http://localhost:3000/api/v1/users/1

# Update user
PUT http://localhost:3000/api/v1/users/1
Content-Type: application/json
{
  "name": "John Updated",
  "age": 31
}

# Delete user
DELETE http://localhost:3000/api/v1/users/1

Conclusion

Building clean, maintainable REST APIs with Express.js requires attention to structure, consistency, and best practices. The key principles covered in this guide include:

  • Separation of concerns through proper project structure

  • Consistent response formats for predictable API behavior

  • Proper error handling for robust applications

  • HTTP status codes for clear communication

  • Modular design for scalability and maintainability

By following these patterns, you'll create APIs that are not only functional but also professional, scalable, and enjoyable to work with. Remember that good API design is an investment in your application's future—it pays dividends in reduced bugs, easier maintenance, and faster feature development.

Start with these fundamentals, and gradually add more sophisticated features like authentication, rate limiting, caching, and database integration as your application grows. The solid foundation you've built will make these additions much simpler to implement.

0
Subscribe to my newsletter

Read articles from Jatin Verma directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jatin Verma
Jatin Verma