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 frameworkmongoose- MongoDB ODMdotenv- Environment variablescors- Cross-origin resource sharinghelmet- Security headersmorgan- 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
- Add Input Validation - Use
express-validatororJoi - Add Rate Limiting - Prevent abuse with
express-rate-limit - Add API Documentation - Swagger/OpenAPI
- Add Logging - Winston for production logging
- Add Testing - Jest for unit and integration tests
- 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.