Building a REST API with Express.js

Express.js remains one of the most popular frameworks for building REST APIs in Node.js. In this guide, we'll build a complete API from scratch, following industry best practices.
What We'll Build#
We're creating a Task Management API with the following features:
Project Setup#
Let's start by creating our project structure:
mkdir task-api
cd task-api
npm init -y
npm install express cors helmet morgan dotenv
npm install -D typescript @types/node @types/express ts-node nodemon
Initialize TypeScript:
npx tsc --init
Update your tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Project Structure#
Organize your code for maintainability:
src/
โโโ index.ts # Entry point
โโโ app.ts # Express app setup
โโโ config/
โ โโโ index.ts # Configuration
โโโ routes/
โ โโโ index.ts # Route aggregator
โ โโโ tasks.ts # Task routes
โโโ controllers/
โ โโโ tasks.ts # Task controller
โโโ middleware/
โ โโโ auth.ts # Authentication
โ โโโ error.ts # Error handling
โ โโโ validate.ts # Validation
โโโ models/
โ โโโ Task.ts # Task model
โโโ utils/
โโโ ApiError.ts # Custom error class
Creating the Express App#
// src/app.ts
import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import routes from './routes';
import { errorHandler, notFoundHandler } from './middleware/error';
const app: Application = express();
// Security middleware
app.use(helmet());
app.use(cors());
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Logging
app.use(morgan('combined'));
// Routes
app.use('/api/v1', routes);
// Error handling (must be last)
app.use(notFoundHandler);
app.use(errorHandler);
export default app;
Custom Error Handling#
Create a custom error class for consistent error responses:
// src/utils/ApiError.ts
export class ApiError extends Error {
statusCode: number;
isOperational: boolean;
constructor(
statusCode: number,
message: string,
isOperational = true,
stack = ''
) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
static badRequest(message: string): ApiError {
return new ApiError(400, message);
}
static unauthorized(message = 'Unauthorized'): ApiError {
return new ApiError(401, message);
}
static forbidden(message = 'Forbidden'): ApiError {
return new ApiError(403, message);
}
static notFound(message = 'Resource not found'): ApiError {
return new ApiError(404, message);
}
static internal(message = 'Internal server error'): ApiError {
return new ApiError(500, message, false);
}
}
Error handling middleware:
// src/middleware/error.ts
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../utils/ApiError';
export function notFoundHandler(
req: Request,
res: Response,
next: NextFunction
): void {
next(ApiError.notFound(`Route ${req.originalUrl} not found`));
}
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction
): void {
if (err instanceof ApiError) {
res.status(err.statusCode).json({
success: false,
error: {
message: err.message,
...(process.env.NODE_ENV === 'development' && {
stack: err.stack
})
}
});
return;
}
// Unexpected errors
console.error('Unexpected error:', err);
res.status(500).json({
success: false,
error: {
message: 'An unexpected error occurred'
}
});
}
Building the Task Resource#
Task Model#
// src/models/Task.ts
export interface Task {
id: string;
title: string;
description?: string;
status: 'pending' | 'in-progress' | 'completed';
priority: 'low' | 'medium' | 'high';
dueDate?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTaskDto {
title: string;
description?: string;
status?: Task['status'];
priority?: Task['priority'];
dueDate?: string;
}
export interface UpdateTaskDto extends Partial<CreateTaskDto> {}
Task Controller#
// src/controllers/tasks.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Task, CreateTaskDto, UpdateTaskDto } from '../models/Task';
import { ApiError } from '../utils/ApiError';
// In-memory storage (use a database in production)
const tasks: Map<string, Task> = new Map();
export const taskController = {
// GET /tasks
async getAll(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { status, priority, sort } = req.query;
let result = Array.from(tasks.values());
// Filter by status
if (status) {
result = result.filter(t => t.status === status);
}
// Filter by priority
if (priority) {
result = result.filter(t => t.priority === priority);
}
// Sort
if (sort === 'dueDate') {
result.sort((a, b) =>
(a.dueDate?.getTime() ?? 0) - (b.dueDate?.getTime() ?? 0)
);
}
res.json({
success: true,
data: result,
count: result.length
});
} catch (error) {
next(error);
}
},
// GET /tasks/:id
async getById(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const task = tasks.get(req.params.id);
if (!task) {
throw ApiError.notFound('Task not found');
}
res.json({
success: true,
data: task
});
} catch (error) {
next(error);
}
},
// POST /tasks
async create(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const dto: CreateTaskDto = req.body;
const task: Task = {
id: uuidv4(),
title: dto.title,
description: dto.description,
status: dto.status ?? 'pending',
priority: dto.priority ?? 'medium',
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
createdAt: new Date(),
updatedAt: new Date()
};
tasks.set(task.id, task);
res.status(201).json({
success: true,
data: task
});
} catch (error) {
next(error);
}
},
// PUT /tasks/:id
async update(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const task = tasks.get(req.params.id);
if (!task) {
throw ApiError.notFound('Task not found');
}
const dto: UpdateTaskDto = req.body;
const updated: Task = {
...task,
...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : task.dueDate,
updatedAt: new Date()
};
tasks.set(task.id, updated);
res.json({
success: true,
data: updated
});
} catch (error) {
next(error);
}
},
// DELETE /tasks/:id
async delete(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const task = tasks.get(req.params.id);
if (!task) {
throw ApiError.notFound('Task not found');
}
tasks.delete(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
};
Task Routes#
// src/routes/tasks.ts
import { Router } from 'express';
import { taskController } from '../controllers/tasks';
import { validate } from '../middleware/validate';
import { createTaskSchema, updateTaskSchema } from '../schemas/task';
const router = Router();
router.get('/', taskController.getAll);
router.get('/:id', taskController.getById);
router.post('/', validate(createTaskSchema), taskController.create);
router.put('/:id', validate(updateTaskSchema), taskController.update);
router.delete('/:id', taskController.delete);
export default router;
Request Validation#
Install Zod for schema validation:
npm install zod
Create validation schemas:
// src/schemas/task.ts
import { z } from 'zod';
export const createTaskSchema = z.object({
body: z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(1000).optional(),
status: z.enum(['pending', 'in-progress', 'completed']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
dueDate: z.string().datetime().optional()
})
});
export const updateTaskSchema = z.object({
body: z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(1000).optional(),
status: z.enum(['pending', 'in-progress', 'completed']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
dueDate: z.string().datetime().optional()
})
});
Validation middleware:
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
import { ApiError } from '../utils/ApiError';
export function validate(schema: AnyZodObject) {
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params
});
next();
} catch (error) {
if (error instanceof ZodError) {
const message = error.errors
.map(e => `${e.path.join('.')}: ${e.message}`)
.join(', ');
next(ApiError.badRequest(message));
return;
}
next(error);
}
};
}
Authentication Middleware#
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../utils/ApiError';
// Simple token-based auth (use JWT in production)
export function authenticate(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw ApiError.unauthorized('Missing or invalid authorization header');
}
const token = authHeader.split(' ')[1];
// In production, verify JWT token here
if (!token || token === 'invalid') {
throw ApiError.unauthorized('Invalid token');
}
// Attach user to request
(req as any).user = { id: '1', email: 'user@example.com' };
next();
}
export function authorize(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
const user = (req as any).user;
if (!user) {
throw ApiError.unauthorized();
}
if (roles.length && !roles.includes(user.role)) {
throw ApiError.forbidden('Insufficient permissions');
}
next();
};
}
API Response Format#
Consistent response format is crucial for API consumers:
Testing the API#
Here's a quick way to test your endpoints:
# Create a task
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Express", "priority": "high"}'
# Get all tasks
curl http://localhost:3000/api/v1/tasks
# Get filtered tasks
curl "http://localhost:3000/api/v1/tasks?status=pending&priority=high"
# Update a task
curl -X PUT http://localhost:3000/api/v1/tasks/{id} \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'
# Delete a task
curl -X DELETE http://localhost:3000/api/v1/tasks/{id}
Video Tutorial#
For a visual walkthrough of building REST APIs, check out this excellent tutorial:
Next Steps#
Building REST APIs with Express is straightforward when you follow established patterns. Start with solid error handling and validation, then layer in features as needed.




