CodeWithSabir
HomeAIDevOpsNext.jsMobile DevelopmentWeb Development
CodeWithSabir
  • Home
  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development
  • About
  • Contact
CodeWithSabir

In-depth articles, tutorials, and guides on web development, React, Next.js, AI, and modern programming practices.

Topics

  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development

Company

  • About
  • Contact
  • Privacy Policy
  • Terms

© 2026 CodeWithSabir. All rights reserved.

Built with SabirSoft.com

Home/Web Development/Building REST APIs with Node.js and Express: The Complete Guide
Web Development

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

Learn to build production-ready REST APIs with Node.js and Express. Covers routing, middleware, validation, authentication, error handling, and testing best practices.

Sabir KhaloufiSabir KhaloufiDecember 25, 20254 min read

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

bash
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
json
// tsconfig.json — key settings
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  }
}
json
// package.json scripts
{
  "scripts": {
    "dev": "nodemon --exec tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Project Structure

code
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

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

typescript
// 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

typescript
// 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
}
typescript
// 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

typescript
// 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)
typescript
// 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:

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

typescript
// 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
#node.js#express#rest api#backend#javascript
Share:
Sabir Khaloufi — author photo

Written by

Sabir Khaloufi

Full-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.

On this page

  • Project Setup
  • Project Structure
  • App Entry Point
  • Request Validation Middleware
  • Authentication: JWT Flow
  • Routes and Controllers
  • Global Error Handler
  • Common Mistakes
  • Key Takeaways

Related Articles

Web Development

React Performance Optimization Techniques That Actually Work

Practical React performance techniques that make a real difference — memo, useMemo, useCallback, code splitting, virtualization, and when each one actually helps.

Sabir KhaloufiDecember 20, 20254 min read
Web Development

TypeScript Best Practices for React Developers

Practical TypeScript patterns for React — covering component typing, generic components, custom hook types, discriminated unions, and the mistakes that cause runtime errors despite type checking.

Sabir KhaloufiDecember 15, 20254 min read
Web Development

Understanding JWT Authentication in Modern Web Apps

A clear guide to JWT authentication — how tokens work, where to store them, refresh token patterns, security pitfalls, and practical implementation in Node.js and React.

Sabir KhaloufiDecember 10, 20254 min read