Published on

Build a REST API with Node.js and Express - Complete Guide

Authors

Introduction

Express remains the most popular Node.js framework for building REST APIs — and for good reason. It's minimal, flexible, and has a massive ecosystem. In this guide, we'll build a complete, production-ready REST API from scratch.

Project Setup

mkdir my-api && cd my-api
npm init -y
npm install express dotenv
npm install --save-dev typescript @types/express @types/node ts-node nodemon
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  }
}

Basic Express App

src/index.ts
import express from 'express'
import dotenv from 'dotenv'

dotenv.config()

const app = express()
const PORT = process.env.PORT || 3000

// Middleware
app.use(express.json())
app.use(express.urlencoded({ extended: true }))

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

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`)
})

MVC Structure

src/
├── controllers/
│   └── userController.ts
├── routes/
│   └── userRoutes.ts
├── middleware/
│   ├── auth.ts
│   └── errorHandler.ts
├── models/
│   └── User.ts
├── services/
│   └── userService.ts
└── index.ts

Routes and Controllers

src/routes/userRoutes.ts
import { Router } from 'express'
import {
  getAllUsers,
  getUserById,
  createUser,
  updateUser,
  deleteUser,
} from '../controllers/userController'
import { authenticate } from '../middleware/auth'

const router = Router()

router.get('/', getAllUsers)
router.get('/:id', getUserById)
router.post('/', createUser)
router.put('/:id', authenticate, updateUser)
router.delete('/:id', authenticate, deleteUser)

export default router
src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express'
import { UserService } from '../services/userService'

const userService = new UserService()

export async function getAllUsers(req: Request, res: Response, next: NextFunction) {
  try {
    const page = Number(req.query.page) || 1
    const limit = Number(req.query.limit) || 10
    const users = await userService.findAll({ page, limit })
    res.json({ data: users, page, limit })
  } catch (error) {
    next(error)
  }
}

export async function getUserById(req: Request, res: Response, next: NextFunction) {
  try {
    const user = await userService.findById(req.params.id)
    if (!user) {
      return res.status(404).json({ message: 'User not found' })
    }
    res.json(user)
  } catch (error) {
    next(error)
  }
}

export async function createUser(req: Request, res: Response, next: NextFunction) {
  try {
    const user = await userService.create(req.body)
    res.status(201).json(user)
  } catch (error) {
    next(error)
  }
}

export async function updateUser(req: Request, res: Response, next: NextFunction) {
  try {
    const user = await userService.update(req.params.id, req.body)
    if (!user) return res.status(404).json({ message: 'User not found' })
    res.json(user)
  } catch (error) {
    next(error)
  }
}

export async function deleteUser(req: Request, res: Response, next: NextFunction) {
  try {
    await userService.delete(req.params.id)
    res.status(204).send()
  } catch (error) {
    next(error)
  }
}

Input Validation with Zod

npm install zod
src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express'
import { AnyZodObject, ZodError } from 'zod'

export const validate = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await schema.parseAsync({
        body: req.body,
        query: req.query,
        params: req.params,
      })
      next()
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          message: 'Validation error',
          errors: error.errors,
        })
      }
      next(error)
    }
  }
src/schemas/userSchema.ts
import { z } from 'zod'

export const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(2).max(50),
    email: z.string().email(),
    password: z.string().min(8),
  }),
})

JWT Authentication

npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs
src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'

interface JwtPayload {
  userId: string
  email: string
}

declare global {
  namespace Express {
    interface Request {
      user?: JwtPayload
    }
  }
}

export function authenticate(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(' ')[1]

  if (!token) {
    return res.status(401).json({ message: 'No token provided' })
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload
    req.user = decoded
    next()
  } catch {
    res.status(401).json({ message: 'Invalid token' })
  }
}

Global Error Handler

src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'

export class AppError extends Error {
  statusCode: number
  constructor(message: string, statusCode: number) {
    super(message)
    this.statusCode = statusCode
  }
}

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({ message: err.message })
  }

  console.error(err.stack)
  res.status(500).json({ message: 'Internal server error' })
}

Putting It All Together

src/index.ts
import express from 'express'
import userRoutes from './routes/userRoutes'
import { errorHandler } from './middleware/errorHandler'

const app = express()

app.use(express.json())
app.use('/api/users', userRoutes)
app.use(errorHandler)  // Must be last!

app.listen(3000)

Conclusion

Building a REST API with Express and TypeScript gives you a flexible, well-understood foundation that scales from prototypes to production. Add validation with Zod, secure routes with JWT, and centralize your errors — and you've got a rock-solid API that's easy to maintain and extend.