Back to Blog
Development Tips10 min read

How to Build a REST API with Node.js and Express: Complete Guide 2026

Learn how to build a production-ready REST API with Node.js and Express. Step-by-step tutorial covering authentication, database integration, error handling, and best practices.

Hevcode Team
January 15, 2026

Building a REST API is one of the most valuable skills in modern web development. Whether you're creating a backend for a mobile app, web application, or microservice, Node.js and Express provide the perfect foundation for fast, scalable APIs.

In this comprehensive guide, we'll build a production-ready REST API from scratch, covering everything from project setup to deployment best practices.

What You'll Learn

  • Setting up a Node.js project with Express
  • Creating RESTful endpoints (CRUD operations)
  • Database integration with MongoDB
  • Authentication with JWT
  • Input validation and error handling
  • API documentation with Swagger
  • Security best practices
  • Testing your API

Prerequisites

Before starting, ensure you have:

  • Node.js 18+ installed
  • Basic JavaScript/TypeScript knowledge
  • MongoDB installed (or MongoDB Atlas account)
  • Postman or similar API testing tool

Project Setup

1. Initialize Your Project

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

2. Install Dependencies

npm install express mongoose dotenv cors helmet morgan
npm install -D typescript @types/node @types/express ts-node nodemon

Package explanations:

  • express - Web framework
  • mongoose - MongoDB ODM
  • dotenv - Environment variables
  • cors - Cross-origin resource sharing
  • helmet - Security headers
  • morgan - HTTP request logging

3. TypeScript Configuration

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

4. Project Structure

my-rest-api/
├── src/
│   ├── config/
│   │   └── database.ts
│   ├── controllers/
│   │   └── userController.ts
│   ├── middleware/
│   │   ├── auth.ts
│   │   ├── errorHandler.ts
│   │   └── validate.ts
│   ├── models/
│   │   └── User.ts
│   ├── routes/
│   │   ├── index.ts
│   │   └── userRoutes.ts
│   ├── utils/
│   │   └── apiResponse.ts
│   └── app.ts
├── .env
├── package.json
└── tsconfig.json

Building the API

1. Main Application File

Create src/app.ts:

import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import dotenv from 'dotenv';
import { connectDB } from './config/database';
import routes from './routes';
import { errorHandler } from './middleware/errorHandler';

dotenv.config();

const app: Application = express();

// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.use('/api/v1', routes);

// Error handling
app.use(errorHandler);

// Database connection and server start
const PORT = process.env.PORT || 3000;

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

export default app;

2. Database Configuration

Create src/config/database.ts:

import mongoose from 'mongoose';

export const connectDB = async (): Promise<void> => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI as string);
    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error('Database connection error:', error);
    process.exit(1);
  }
};

3. User Model

Create src/models/User.ts:

import mongoose, { Document, Schema } from 'mongoose';
import bcrypt from 'bcryptjs';

export interface IUser extends Document {
  name: string;
  email: string;
  password: string;
  role: 'user' | 'admin';
  createdAt: Date;
  comparePassword(candidatePassword: string): Promise<boolean>;
}

const userSchema = new Schema<IUser>({
  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: [/^\S+@\S+\.\S+$/, 'Please enter a valid email']
  },
  password: {
    type: String,
    required: [true, 'Password is required'],
    minlength: [8, 'Password must be at least 8 characters'],
    select: false
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

// Hash password before saving
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

// Compare password method
userSchema.methods.comparePassword = async function(
  candidatePassword: string
): Promise<boolean> {
  return bcrypt.compare(candidatePassword, this.password);
};

export default mongoose.model<IUser>('User', userSchema);

4. Controllers

Create src/controllers/userController.ts:

import { Request, Response, NextFunction } from 'express';
import User from '../models/User';
import jwt from 'jsonwebtoken';
import { ApiResponse } from '../utils/apiResponse';

// Generate JWT Token
const generateToken = (id: string): string => {
  return jwt.sign({ id }, process.env.JWT_SECRET as string, {
    expiresIn: process.env.JWT_EXPIRE || '7d'
  });
};

// @desc    Register user
// @route   POST /api/v1/users/register
// @access  Public
export const register = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    const { name, email, password } = req.body;

    // Check if user exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      res.status(400).json(ApiResponse.error('Email already registered'));
      return;
    }

    // Create user
    const user = await User.create({ name, email, password });

    // Generate token
    const token = generateToken(user._id.toString());

    res.status(201).json(ApiResponse.success({
      user: {
        id: user._id,
        name: user.name,
        email: user.email,
        role: user.role
      },
      token
    }, 'User registered successfully'));
  } catch (error) {
    next(error);
  }
};

// @desc    Login user
// @route   POST /api/v1/users/login
// @access  Public
export const login = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    const { email, password } = req.body;

    // Check if email and password provided
    if (!email || !password) {
      res.status(400).json(ApiResponse.error('Please provide email and password'));
      return;
    }

    // Find user and include password
    const user = await User.findOne({ email }).select('+password');
    if (!user) {
      res.status(401).json(ApiResponse.error('Invalid credentials'));
      return;
    }

    // Check password
    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      res.status(401).json(ApiResponse.error('Invalid credentials'));
      return;
    }

    // Generate token
    const token = generateToken(user._id.toString());

    res.status(200).json(ApiResponse.success({
      user: {
        id: user._id,
        name: user.name,
        email: user.email,
        role: user.role
      },
      token
    }, 'Login successful'));
  } catch (error) {
    next(error);
  }
};

// @desc    Get all users
// @route   GET /api/v1/users
// @access  Private/Admin
export const getUsers = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    const users = await User.find().select('-password');
    res.status(200).json(ApiResponse.success(users));
  } catch (error) {
    next(error);
  }
};

// @desc    Get single user
// @route   GET /api/v1/users/:id
// @access  Private
export const getUser = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      res.status(404).json(ApiResponse.error('User not found'));
      return;
    }
    res.status(200).json(ApiResponse.success(user));
  } catch (error) {
    next(error);
  }
};

// @desc    Update user
// @route   PUT /api/v1/users/:id
// @access  Private
export const updateUser = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    const user = await User.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );
    if (!user) {
      res.status(404).json(ApiResponse.error('User not found'));
      return;
    }
    res.status(200).json(ApiResponse.success(user, 'User updated successfully'));
  } catch (error) {
    next(error);
  }
};

// @desc    Delete user
// @route   DELETE /api/v1/users/:id
// @access  Private/Admin
export const deleteUser = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) {
      res.status(404).json(ApiResponse.error('User not found'));
      return;
    }
    res.status(200).json(ApiResponse.success(null, 'User deleted successfully'));
  } catch (error) {
    next(error);
  }
};

5. Authentication Middleware

Create src/middleware/auth.ts:

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import User, { IUser } from '../models/User';
import { ApiResponse } from '../utils/apiResponse';

interface JwtPayload {
  id: string;
}

declare global {
  namespace Express {
    interface Request {
      user?: IUser;
    }
  }
}

export const protect = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  try {
    let token: string | undefined;

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

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

    // Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;

    // Get user from token
    const user = await User.findById(decoded.id);
    if (!user) {
      res.status(401).json(ApiResponse.error('User no longer exists'));
      return;
    }

    req.user = user;
    next();
  } catch (error) {
    res.status(401).json(ApiResponse.error('Not authorized to access this route'));
  }
};

export const authorize = (...roles: string[]) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user || !roles.includes(req.user.role)) {
      res.status(403).json(ApiResponse.error('Not authorized to perform this action'));
      return;
    }
    next();
  };
};

6. Error Handler

Create src/middleware/errorHandler.ts:

import { Request, Response, NextFunction } from 'express';

interface CustomError extends Error {
  statusCode?: number;
  code?: number;
  keyValue?: Record<string, string>;
}

export const errorHandler = (
  err: CustomError,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  let error = { ...err };
  error.message = err.message;

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

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

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

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

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

7. API Response Utility

Create src/utils/apiResponse.ts:

export class ApiResponse {
  static success<T>(data: T, message?: string) {
    return {
      success: true,
      message: message || 'Success',
      data
    };
  }

  static error(message: string, errors?: any) {
    return {
      success: false,
      message,
      errors
    };
  }
}

8. Routes

Create src/routes/userRoutes.ts:

import { Router } from 'express';
import {
  register,
  login,
  getUsers,
  getUser,
  updateUser,
  deleteUser
} from '../controllers/userController';
import { protect, authorize } from '../middleware/auth';

const router = Router();

// Public routes
router.post('/register', register);
router.post('/login', login);

// Protected routes
router.use(protect);

router.route('/')
  .get(authorize('admin'), getUsers);

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

export default router;

Create src/routes/index.ts:

import { Router } from 'express';
import userRoutes from './userRoutes';

const router = Router();

router.use('/users', userRoutes);

// Health check
router.get('/health', (req, res) => {
  res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() });
});

export default router;

Environment Variables

Create .env:

PORT=3000
MONGODB_URI=mongodb://localhost:27017/my-api
JWT_SECRET=your-super-secret-key-change-this
JWT_EXPIRE=7d
NODE_ENV=development

Running the API

Add scripts to package.json:

{
  "scripts": {
    "dev": "nodemon src/app.ts",
    "build": "tsc",
    "start": "node dist/app.js"
  }
}

Run in development:

npm run dev

Testing Your API

Register a User

curl -X POST http://localhost:3000/api/v1/users/register \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "john@example.com", "password": "password123"}'

Login

curl -X POST http://localhost:3000/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{"email": "john@example.com", "password": "password123"}'

Get Users (Protected)

curl http://localhost:3000/api/v1/users \
  -H "Authorization: Bearer YOUR_JWT_TOKEN"

Best Practices Implemented

1. Security

  • Helmet for security headers
  • CORS configuration
  • Password hashing with bcrypt
  • JWT authentication
  • Input validation

2. Error Handling

  • Centralized error handler
  • Consistent error responses
  • Mongoose error handling

3. Code Organization

  • MVC architecture
  • Separation of concerns
  • TypeScript for type safety

4. API Design

  • RESTful conventions
  • Versioned endpoints (/api/v1)
  • Consistent response format

Next Steps

  1. Add Input Validation - Use express-validator or Joi
  2. Add Rate Limiting - Prevent abuse with express-rate-limit
  3. Add API Documentation - Swagger/OpenAPI
  4. Add Logging - Winston for production logging
  5. Add Testing - Jest for unit and integration tests
  6. Add Caching - Redis for performance

Conclusion

You now have a production-ready REST API foundation with Node.js and Express. This architecture scales well and follows industry best practices.

Need help building your backend API? Contact Hevcode for professional API development services. We specialize in scalable, secure backend systems for web and mobile applications.

Related Articles

Tags:Node.jsREST APIExpressBackend DevelopmentAPI Development

Need help with your project?

We've helped 534+ clients build successful apps. Let's discuss yours.

Ready to Build Your App?

534+ projects delivered • 4.9★ rating • 6+ years experience

Let's discuss your project — no obligations, just a straightforward conversation.