How to Build a REST API with Node.js and Express

Building a RESTful API is a fundamental skill for modern backend development. Node.js with Express provides a fast, minimalist framework perfect for creating scalable APIs. This comprehensive guide will take you from zero to a production-ready API with authentication, database integration, and best practices.

What is a REST API?

REST (Representational State Transfer) is an architectural style for designing networked applications. A REST API uses HTTP methods to perform CRUD operations:

  • GET: Retrieve data
  • POST: Create new data
  • PUT/PATCH: Update existing data
  • DELETE: Remove data

REST Principles

REST APIs follow these core principles[1]:

  1. Stateless: Each request contains all necessary information
  2. Client-Server Architecture: Separation of concerns
  3. Cacheable: Responses indicate if they can be cached
  4. Uniform Interface: Consistent naming and structure
  5. Layered System: Intermediate servers (proxies, load balancers) are transparent

Project Setup

Step 1: Initialize Project

mkdir my-rest-api
cd my-rest-api
npm init -y

Step 2: Install Dependencies

# Core dependencies
npm install express dotenv

## Database (we'll use MongoDB)
npm install mongoose

## Middleware
npm install cors helmet express-rate-limit

## Validation
npm install express-validator

## Development dependencies
npm install --save-dev nodemon

Step 3: Project Structure

Create this organized structure:

my-rest-api/
├── src/
│   ├── config/
│   │   └── database.js
│   ├── controllers/
│   │   └── userController.js
│   ├── models/
│   │   └── User.js
│   ├── routes/
│   │   └── userRoutes.js
│   ├── middleware/
│   │   ├── auth.js
│   │   └── errorHandler.js
│   └── app.js
├── .env
├── .gitignore
├── package.json
└── server.js

Building the API

Step 1: Configure Environment Variables

Create .env file:

## Server Configuration
PORT=3000
NODE_ENV=development

## Database
MONGODB_URI=mongodb://localhost:27017/myapi
MONGODB_TEST_URI=mongodb://localhost:27017/myapi-test

## JWT (for authentication)
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=24h

Create .gitignore:

node_modules/
.env
*.log
dist/
coverage/

Step 2: Database Connection

// src/config/database.js
const mongoose = require('mongoose');

const connectDatabase = async () => {
  try {
    const options = {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    };

    const conn = await mongoose.connect(
      process.env.MONGODB_URI,
      options
    );

    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(`Error: ${error.message}`);
    process.exit(1);
  }
};

module.exports = connectDatabase;

Step 3: Create Data Model

// src/models/User.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required'],
    trim: true,
    maxlength: [50, 'Name cannot exceed 50 characters']
  },
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    match: [
      /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/,
      'Please provide a valid email'
    ]
  },
  age: {
    type: Number,
    min: [0, 'Age must be positive'],
    max: [120, 'Age must be realistic']
  },
  role: {
    type: String,
    enum: ['user', 'admin', 'moderator'],
    default: 'user'
  },
  isActive: {
    type: Boolean,
    default: true
  }
}, {
  timestamps: true // Adds createdAt and updatedAt
});

// Index for faster queries
userSchema.index({ email: 1 });

// Virtual property (computed, not stored in DB)
userSchema.virtual('profile').get(function() {
  return {
    name: this.name,
    email: this.email,
    role: this.role
  };
});

// Instance method
userSchema.methods.toJSON = function() {
  const user = this.toObject();
  delete user.__v;
  return user;
};

module.exports = mongoose.model('User', userSchema);

Step 4: Create Controller

// src/controllers/userController.js
const User = require('../models/User');
const { validationResult } = require('express-validator');

// @desc    Get all users
// @route   GET /api/users
// @access  Public
exports.getUsers = async (req, res, next) => {
  try {
    // Pagination
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const skip = (page - 1) * limit;

    // Filtering
    const filter = {};
    if (req.query.role) filter.role = req.query.role;
    if (req.query.isActive) filter.isActive = req.query.isActive === 'true';

    // Sorting
    const sort = {};
    if (req.query.sortBy) {
      const parts = req.query.sortBy.split(':');
      sort[parts[0]] = parts[1] === 'desc' ? -1 : 1;
    }

    const users = await User.find(filter)
      .sort(sort)
      .limit(limit)
      .skip(skip)
      .select('-__v');

    const total = await User.countDocuments(filter);

    res.status(200).json({
      success: true,
      count: users.length,
      total,
      page,
      pages: Math.ceil(total / limit),
      data: users
    });
  } catch (error) {
    next(error);
  }
};

// @desc    Get single user
// @route   GET /api/users/:id
// @access  Public
exports.getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);

    if (!user) {
      return res.status(404).json({
        success: false,
        error: 'User not found'
      });
    }

    res.status(200).json({
      success: true,
      data: user
    });
  } catch (error) {
    next(error);
  }
};

// @desc    Create user
// @route   POST /api/users
// @access  Public
exports.createUser = async (req, res, next) => {
  try {
    // Validate input
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({
        success: false,
        errors: errors.array()
      });
    }

    const user = await User.create(req.body);

    res.status(201).json({
      success: true,
      data: user
    });
  } catch (error) {
    // Handle duplicate key error
    if (error.code === 11000) {
      return res.status(400).json({
        success: false,
        error: 'Email already exists'
      });
    }
    next(error);
  }
};

// @desc    Update user
// @route   PUT /api/users/:id
// @access  Public
exports.updateUser = async (req, res, next) => {
  try {
    const user = await User.findByIdAndUpdate(
      req.params.id,
      req.body,
      {
        new: true, // Return updated document
        runValidators: true // Run model validators
      }
    );

    if (!user) {
      return res.status(404).json({
        success: false,
        error: 'User not found'
      });
    }

    res.status(200).json({
      success: true,
      data: user
    });
  } catch (error) {
    next(error);
  }
};

// @desc    Delete user
// @route   DELETE /api/users/:id
// @access  Public
exports.deleteUser = async (req, res, next) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);

    if (!user) {
      return res.status(404).json({
        success: false,
        error: 'User not found'
      });
    }

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

Step 5: Define Routes

// src/routes/userRoutes.js
const express = require('express');
const { body } = require('express-validator');
const {
  getUsers,
  getUser,
  createUser,
  updateUser,
  deleteUser
} = require('../controllers/userController');

const router = express.Router();

// Validation middleware
const userValidation = [
  body('name')
    .trim()
    .notEmpty().withMessage('Name is required')
    .isLength({ max: 50 }).withMessage('Name too long'),
  body('email')
    .isEmail().withMessage('Valid email is required')
    .normalizeEmail(),
  body('age')
    .optional()
    .isInt({ min: 0, max: 120 }).withMessage('Age must be between 0 and 120')
];

// Routes
router.route('/')
  .get(getUsers)
  .post(userValidation, createUser);

router.route('/:id')
  .get(getUser)
  .put(updateUser)
  .delete(deleteUser);

module.exports = router;

Step 6: Create Middleware

// src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  let error = { ...err };
  error.message = err.message;

  // Log error for debugging
  console.error(err);

  // Mongoose bad ObjectId
  if (err.name === 'CastError') {
    const message = 'Resource not found';
    error = { message, statusCode: 404 };
  }

  // Mongoose duplicate key
  if (err.code === 11000) {
    const message = 'Duplicate field value entered';
    error = { message, statusCode: 400 };
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const message = Object.values(err.errors)
      .map(val => val.message)
      .join(', ');
    error = { message, statusCode: 400 };
  }

  res.status(error.statusCode || 500).json({
    success: false,
    error: error.message || 'Server Error'
  });
};

module.exports = errorHandler;

Step 7: Configure Express App

// src/app.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const errorHandler = require('./middleware/errorHandler');
const connectDatabase = require('./config/database');

const app = express();

// Connect to database
connectDatabase();

// Security middleware
app.use(helmet()); // Set security headers
app.use(cors()); // Enable CORS

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: 'Too many requests, please try again later.'
});
app.use('/api/', limiter);

// Body parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Request logging (simple)
app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  next();
});

// Routes
app.use('/api/users', require('./routes/userRoutes'));

// Health check endpoint
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

// 404 handler
app.use((req, res) => {
  res.status(404).json({
    success: false,
    error: 'Route not found'
  });
});

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

module.exports = app;

Step 8: Create Server Entry Point

// server.js
const app = require('./src/app');

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

const server = app.listen(PORT, () => {
  console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});

// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
  console.log(`Error: ${err.message}`);
  server.close(() => process.exit(1));
});

Step 9: Update package.json Scripts

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "NODE_ENV=test jest --coverage",
    "lint": "eslint src/**/*.js"
  }
}

Testing Your API

Start the Server

npm run dev

Test with cURL

Create a user:

curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "[email protected]",
    "age": 30
  }'

Get all users:

curl http://localhost:3000/api/users

Get single user:

curl http://localhost:3000/api/users/507f1f77bcf86cd799439011

Update user:

curl -X PUT http://localhost:3000/api/users/507f1f77bcf86cd799439011 \
  -H "Content-Type: application/json" \
  -d '{"age": 31}'

Delete user:

curl -X DELETE http://localhost:3000/api/users/507f1f77bcf86cd799439011

Query Parameters

## Pagination
curl "http://localhost:3000/api/users?page=2&limit=5"

## Filtering
curl "http://localhost:3000/api/users?role=admin&isActive=true"

## Sorting
curl "http://localhost:3000/api/users?sortBy=createdAt:desc"

Advanced Features

1. Add Authentication

// src/middleware/auth.js
const jwt = require('jsonwebtoken');

exports.protect = async (req, res, next) => {
  let token;

  if (req.headers.authorization?.startsWith('Bearer')) {
    token = req.headers.authorization.split(' ')[1];
  }

  if (!token) {
    return res.status(401).json({
      success: false,
      error: 'Not authorized to access this route'
    });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({
      success: false,
      error: 'Not authorized'
    });
  }
};

Use in routes:

const { protect } = require('../middleware/auth');

router.delete('/:id', protect, deleteUser);

2. Add Request Logging

npm install morgan
const morgan = require('morgan');

if (process.env.NODE_ENV === 'development') {
  app.use(morgan('dev'));
}

3. Add API Documentation

npm install swagger-ui-express swagger-jsdoc
// Add to app.js
const swaggerUi = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');

const swaggerOptions = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'My REST API',
      version: '1.0.0',
      description: 'A simple REST API built with Express'
    },
    servers: [
      { url: 'http://localhost:3000' }
    ]
  },
  apis: ['./src/routes/*.js']
};

const swaggerDocs = swaggerJsDoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocs));

Best Practices Checklist

  • Use environment variables for configuration
  • Implement proper error handling
  • Add input validation
  • Use appropriate HTTP status codes
  • Implement pagination for list endpoints
  • Add rate limiting
  • Use security middleware (helmet, cors)
  • Log requests and errors
  • Version your API (/api/v1/users)
  • Document your API
  • Write tests
  • Use HTTPS in production

HTTP Status Codes Reference

CodeMeaningUse Case
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST
204No ContentSuccessful DELETE
400Bad RequestInvalid input
401UnauthorizedAuthentication required
403ForbiddenInsufficient permissions
404Not FoundResource doesn’t exist
429Too Many RequestsRate limit exceeded
500Internal Server ErrorServer error

Performance Optimization

1. Database Indexing

// Add indexes to frequently queried fields
userSchema.index({ email: 1 });
userSchema.index({ role: 1, isActive: 1 });

2. Response Compression

npm install compression
const compression = require('compression');
app.use(compression());

3. Caching

npm install node-cache
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 600 }); // 10 minutes

exports.getUsers = async (req, res, next) => {
  const cacheKey = `users_${JSON.stringify(req.query)}`;
  
  const cached = cache.get(cacheKey);
  if (cached) {
    return res.status(200).json(cached);
  }
  
  // ... fetch from database
  cache.set(cacheKey, result);
  res.status(200).json(result);
};

Deployment

Environment Configuration

## Production .env
NODE_ENV=production
PORT=8080
MONGODB_URI=mongodb+srv://user:[email protected]/myapi
JWT_SECRET=production-secret-key-very-long-and-secure

Process Manager (PM2)

npm install -g pm2
pm2 start server.js --name "my-api"
pm2 startup
pm2 save

Conclusion

You’ve built a production-ready REST API with Node.js and Express, complete with database integration, validation, error handling, and security features. This foundation can scale from small projects to enterprise applications.

Key takeaways:

  • Follow REST principles for consistent API design
  • Validate input to prevent security vulnerabilities
  • Handle errors gracefully with proper status codes
  • Use middleware to keep code DRY and maintainable
  • Document your API for developers (including future you)

Start with this solid foundation and extend it with features like authentication, file uploads, real-time capabilities with WebSockets, or microservices architecture as your needs grow.

References

[1] Fielding, R. T. (2000). Architectural Styles and the Design of Network-based Software Architectures. Doctoral dissertation, University of California, Irvine. Available at: https://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm (Accessed: November 2025)

[2] Express.js Documentation. (2024). Express.js Guide. Available at: https://expressjs.com/en/guide/routing.html (Accessed: November 2025)

[3] Mozilla Developer Network. (2024). HTTP Status Codes. Available at: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status (Accessed: November 2025)

Thank you for reading! If you have any feedback or comments, please send them to [email protected].