Follow Us

CodeWithSabir

  • Contact Us
  • Privacy Policy
  • About
  • Terms & Conditions

All Rights Reserved © 2026

  • Light
  • Dark
Web Development

Building REST APIs with Node.js and Express: The Complete Guide

Sabir Soft
Sabir Lkhaloufi
  • December 25, 2025
  • 4 min read

Building REST APIs with Node.js and Express: The Complete Guide

Express.js is still the most widely used Node.js web framework in 2026 — not because it's flashy, but because it's minimal, flexible, and gets out of your way. When you need an API that's maintainable, testable, and can be understood by any developer who joins your team, Express remains a solid choice.

This guide covers building a real API from scratch with all the pieces that get skipped in most tutorials: proper validation, authentication, error handling, and tests.

Project Setup

mkdir node-api && cd node-api
npm init -y
npm install express zod bcryptjs jsonwebtoken
npm install -D typescript @types/express @types/node @types/bcryptjs @types/jsonwebtoken tsx nodemon
npx tsc --init
// tsconfig.json — key settings
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  }
}
// package.json scripts
{
  "scripts": {
    "dev": "nodemon --exec tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Project Structure

src/
├── index.ts
├── app.ts
├── config/
│   └── env.ts
├── middleware/
│   ├── auth.ts
│   ├── errorHandler.ts
│   └── validateRequest.ts
├── routes/
│   ├── auth.routes.ts
│   └── users.routes.ts
├── controllers/
│   ├── auth.controller.ts
│   └── users.controller.ts
├── services/
│   ├── auth.service.ts
│   └── users.service.ts
└── types/
    └── index.ts

App Entry Point

// src/app.ts
import express from 'express'
import { authRouter } from './routes/auth.routes'
import { usersRouter } from './routes/users.routes'
import { errorHandler } from './middleware/errorHandler'
 
const app = express()
 
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
 
// Health check
app.get('/health', (_, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
 
// Routes
app.use('/api/v1/auth', authRouter)
app.use('/api/v1/users', usersRouter)
 
// Error handler — must be last
app.use(errorHandler)
 
export default app
// src/index.ts
import app from './app'
 
const PORT = process.env.PORT || 3000
 
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Request Validation Middleware

This is the piece most tutorials skip. Every incoming request should be validated before it touches your business logic:

// src/middleware/validateRequest.ts
import { NextFunction, Request, Response } from 'express'
import { ZodSchema } from 'zod'
 
export function validateRequest(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse({
      body: req.body,
      params: req.params,
      query: req.query,
    })
 
    if (!result.success) {
      res.status(400).json({
        success: false,
        error: 'Validation failed',
        details: result.error.flatten(),
      })
      return
    }
 
    // Replace req data with parsed/coerced data
    req.body = result.data.body
    req.params = result.data.params || req.params
    req.query = result.data.query || req.query
 
    next()
  }
}

Authentication: JWT Flow

// src/services/auth.service.ts
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
 
const JWT_SECRET = process.env.JWT_SECRET!
const JWT_EXPIRES_IN = '7d'
 
export interface TokenPayload {
  userId: string
  email: string
}
 
export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12)
}
 
export async function comparePassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash)
}
 
export function generateToken(payload: TokenPayload): string {
  return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN })
}
 
export function verifyToken(token: string): TokenPayload {
  return jwt.verify(token, JWT_SECRET) as TokenPayload
}
// src/middleware/auth.ts
import { NextFunction, Request, Response } from 'express'
import { verifyToken } from '../services/auth.service'
 
declare global {
  namespace Express {
    interface Request {
      user?: { userId: string; email: string }
    }
  }
}
 
export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization
 
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ success: false, error: 'Authentication required' })
    return
  }
 
  const token = authHeader.split(' ')[1]
 
  try {
    const payload = verifyToken(token)
    req.user = payload
    next()
  } catch {
    res.status(401).json({ success: false, error: 'Invalid or expired token' })
  }
}

Routes and Controllers

// src/routes/auth.routes.ts
import { Router } from 'express'
import { z } from 'zod'
import { validateRequest } from '../middleware/validateRequest'
import { register, login } from '../controllers/auth.controller'
 
export const authRouter = Router()
 
const registerSchema = z.object({
  body: z.object({
    email: z.string().email(),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    name: z.string().min(2).optional(),
  }),
})
 
const loginSchema = z.object({
  body: z.object({
    email: z.string().email(),
    password: z.string().min(1),
  }),
})
 
authRouter.post('/register', validateRequest(registerSchema), register)
authRouter.post('/login', validateRequest(loginSchema), login)
// src/controllers/auth.controller.ts
import { Request, Response, NextFunction } from 'express'
import { db } from '../lib/db'
import { hashPassword, comparePassword, generateToken } from '../services/auth.service'
 
export async function register(req: Request, res: Response, next: NextFunction) {
  try {
    const { email, password, name } = req.body
 
    const existing = await db.user.findUnique({ where: { email } })
    if (existing) {
      res.status(409).json({ success: false, error: 'Email already in use' })
      return
    }
 
    const hashedPassword = await hashPassword(password)
    const user = await db.user.create({
      data: { email, password: hashedPassword, name },
      select: { id: true, email: true, name: true, createdAt: true },
    })
 
    const token = generateToken({ userId: user.id, email: user.email })
 
    res.status(201).json({ success: true, data: { user, token } })
  } catch (error) {
    next(error)
  }
}
 
export async function login(req: Request, res: Response, next: NextFunction) {
  try {
    const { email, password } = req.body
 
    const user = await db.user.findUnique({ where: { email } })
    if (!user) {
      res.status(401).json({ success: false, error: 'Invalid credentials' })
      return
    }
 
    const valid = await comparePassword(password, user.password)
    if (!valid) {
      res.status(401).json({ success: false, error: 'Invalid credentials' })
      return
    }
 
    const token = generateToken({ userId: user.id, email: user.email })
 
    res.json({
      success: true,
      data: {
        user: { id: user.id, email: user.email, name: user.name },
        token,
      },
    })
  } catch (error) {
    next(error)
  }
}

Global Error Handler

Don't scatter res.status(500) calls across your controllers. Use a central error handler:

// src/middleware/errorHandler.ts
import { NextFunction, Request, Response } from 'express'
 
export class AppError extends Error {
  constructor(
    public message: string,
    public statusCode: number = 500,
    public isOperational: boolean = true
  ) {
    super(message)
    this.name = this.constructor.name
    Error.captureStackTrace(this, this.constructor)
  }
}
 
export function errorHandler(
  err: Error | AppError,
  req: Request,
  res: Response,
  next: NextFunction
) {
  if (err instanceof AppError && err.isOperational) {
    res.status(err.statusCode).json({
      success: false,
      error: err.message,
    })
    return
  }
 
  // Unexpected errors
  console.error('Unhandled error:', err)
  res.status(500).json({
    success: false,
    error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
  })
}

Common Mistakes

1. Not handling async errors. Express 4 doesn't automatically catch promise rejections. Either use a wrapper or upgrade to Express 5:

// Wrapper to avoid try/catch in every controller
function asyncHandler(fn: Function) {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next)
  }
}

2. Returning sensitive data. Never return the full database object — always explicitly select what to expose.

3. Using req.body without validation. Everything from the client is untrusted.

4. No rate limiting. Add express-rate-limit to your auth endpoints before deploying.

5. Synchronous operations in routes. Reading files, heavy computation — move them off the event loop.

Key Takeaways

  • Always validate request data with Zod before touching business logic
  • Use a layered architecture: routes → controllers → services → database
  • Centralize error handling instead of scattering try/catch everywhere
  • Never return full database objects — explicitly select what to expose
  • Use the AppError pattern to distinguish expected errors from unexpected ones
  • Add rate limiting and helmet.js before deploying any auth endpoints
Popular Blogs
Claude AI vs ChatGPT: An Honest Comparison for Developers
  • April 28, 2026
AI Tools Every Developer Should Be Using in 2026
  • April 20, 2026
Using the Claude API in Real Projects: A Practical Developer Guide
  • April 15, 2026
Prompt Engineering for Developers: Write Prompts That Actually Work
  • April 10, 2026
Categories
AIDevOpsNext.jsMobile DevelopmentWeb Development

Related Posts

Web Development
React Performance Optimization Techniques That Actually Work
Sabir Khaloufi·Dec 20, 2025
Web Development
TypeScript Best Practices for React Developers
Sabir Khaloufi·Dec 15, 2025
Web Development
Understanding JWT Authentication in Modern Web Apps
Sabir Khaloufi·Dec 10, 2025