Building a REST API with Express.js

Server room with glowing network connections

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:

Rendering diagram...

Project Setup#

Let's start by creating our project structure:

Bash
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:

Bash
npx tsc --init

Update your tsconfig.json:

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:

Text
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#

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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#

TypeScript
// 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#

TypeScript
// 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#

TypeScript
// 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:

Bash
npm install zod

Create validation schemas:

TypeScript
// 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:

TypeScript
// 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#

TypeScript
// 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:

Rendering diagram...

Testing the API#

Here's a quick way to test your endpoints:

Bash
# 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.

Share:

Related Articles