Express.js

Express gives you routing, middleware, and HTTP utilities on top of Node.js and nothing else. No folder conventions, no DI system, no opinions on validation or auth β€” you design the architecture yourself. That freedom is exactly why Express became the default Node.js framework, and also why so many Express codebases are a mess six months in. Understanding how to structure it well matters as much as the framework itself.


🟒 Junior

Setting Up from Scratch

mkdir my-api && cd my-api
npm init -y
npm install express
npm install -D nodemon   # auto-restart on file changes

Add to package.json:

{
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js"
  }
}

Basic server:

const express = require('express');
const app = express();

app.use(express.json());                     // parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // parse form data

app.get('/', (req, res) => {
  res.json({ message: 'Hello, world!' });
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

The Request Object (req)

Everything the client sent you:

app.post('/users/:id/orders', (req, res) => {
  console.log(req.params.id);        // URL parameters:  /users/42/orders β†’ "42"
  console.log(req.query.page);       // Query string:    ?page=2 β†’ "2"
  console.log(req.body.name);        // JSON body:       { "name": "Alice" }
  console.log(req.headers['authorization']); // Request headers
  console.log(req.method);           // "POST"
  console.log(req.path);             // "/users/42/orders"
  console.log(req.ip);               // Client IP (behind proxy: req.ips)
});

The Response Object (res)

Sending responses back:

// JSON response (sets Content-Type: application/json automatically)
res.json({ user: { id: 1, name: 'Alice' } });

// With status code
res.status(201).json({ id: newUser.id });
res.status(404).json({ error: 'User not found' });
res.status(204).send();  // No content (used after DELETE)

// Redirect
res.redirect(301, 'https://new-location.com');

// Send a file
res.sendFile(path.join(__dirname, 'report.pdf'));

// Set headers manually
res.set('X-Custom-Header', 'value');
res.set('Cache-Control', 'no-store');

Routing

// Method + path
app.get('/users', getAllUsers);
app.post('/users', createUser);
app.get('/users/:id', getUserById);
app.put('/users/:id', replaceUser);
app.patch('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);

// Route parameters
app.get('/posts/:year/:month', (req, res) => {
  const { year, month } = req.params; // { year: '2026', month: '05' }
  res.json({ year, month });
});

// Query parameters (?search=alice&page=2)
app.get('/search', (req, res) => {
  const { search = '', page = 1, limit = 20 } = req.query;
  res.json({ search, page: +page, limit: +limit });
});

Express Router β€” group related routes into modules:

// routes/users.js
const router = require('express').Router();

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

module.exports = router;

// app.js
app.use('/users', require('./routes/users'));
app.use('/posts', require('./routes/posts'));

Middleware

A middleware is any function with signature (req, res, next). It either modifies req/res and calls next(), or sends a response and stops the chain.

// Logging middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.path} ${res.statusCode} β€” ${Date.now() - start}ms`);
  });
  next(); // MUST call next() or send a response β€” otherwise request hangs forever
});

// Attach data to the request for downstream handlers
app.use((req, res, next) => {
  req.requestId = crypto.randomUUID();
  next();
});

// Route-level middleware β€” only runs for this route
app.get('/admin', requireAdmin, adminHandler);

// Chained middleware on a route
app.post('/users', validateBody(schema), checkPermission('users:create'), createUser);

Order is everything β€” middleware runs in the exact order it is registered.

Serving Static Files

// Serve everything in /public at the URL root
app.use(express.static('public'));
// public/logo.png is now accessible at /logo.png

// Serve at a specific URL prefix
app.use('/assets', express.static(path.join(__dirname, 'public')));
// public/logo.png β†’ /assets/logo.png

🟑 Medior

Input Validation with Zod

Never trust user input. Validate and sanitize before touching your database.

npm install zod
const { z } = require('zod');

const createUserSchema = z.object({
  name:     z.string().min(2).max(100),
  email:    z.string().email(),
  age:      z.number().int().min(0).max(150).optional(),
  role:     z.enum(['user', 'admin']).default('user'),
});

// Reusable validation middleware factory
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.flatten().fieldErrors,
      });
    }
    req.validated = result.data; // use req.validated downstream β€” not req.body
    next();
  };
}

app.post('/users', validate(createUserSchema), async (req, res, next) => {
  try {
    const user = await userService.create(req.validated);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

Error Handling

Express has a special 4-argument error middleware. It must be registered last.

// Sync errors β€” throw directly
app.get('/sync-error', (req, res) => {
  throw new Error('Something broke');  // Express catches this automatically
});

// Async errors β€” MUST pass to next()
app.get('/async-error', async (req, res, next) => {
  try {
    const data = await db.query('SELECT ...');
    res.json(data);
  } catch (err) {
    next(err);  // Skips all route handlers, lands in the error middleware below
  }
});

// Custom error class with HTTP status
class AppError extends Error {
  constructor(message, status = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.status = status;
    this.code   = code;
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

// Centralized error handler β€” LAST middleware, 4 arguments
app.use((err, req, res, next) => {
  const status  = err.status  || 500;
  const message = err.message || 'Internal server error';

  // Log with request context
  console.error({
    requestId: req.requestId,
    method: req.method,
    path: req.path,
    status,
    error: err.stack,
  });

  res.status(status).json({
    error: {
      code:    err.code    || 'INTERNAL_ERROR',
      message: status < 500 ? message : 'Something went wrong',
    },
  });
});

Express 5 (currently in beta) catches async errors automatically β€” no try/catch needed in handlers.

Authentication β€” JWT (Stateless)

npm install jsonwebtoken bcryptjs
const jwt  = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

// Sign a token on login
app.post('/auth/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = await userRepo.findByEmail(email);
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      throw new AppError('Invalid credentials', 401, 'UNAUTHORIZED');
    }
    const accessToken = jwt.sign(
      { sub: user.id, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }          // short-lived access token
    );
    const refreshToken = jwt.sign(
      { sub: user.id },
      process.env.JWT_REFRESH_SECRET,
      { expiresIn: '7d' }           // long-lived refresh token
    );
    // Store refresh token in httpOnly cookie β€” not in localStorage (XSS risk)
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure:   process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge:   7 * 24 * 60 * 60 * 1000,
    });
    res.json({ accessToken });
  } catch (err) {
    next(err);
  }
});

// Protect routes with auth middleware
const requireAuth = (req, res, next) => {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return next(new AppError('Missing token', 401, 'UNAUTHORIZED'));
  }
  try {
    req.user = jwt.verify(header.split(' ')[1], process.env.JWT_SECRET);
    next();
  } catch (err) {
    next(new AppError('Invalid or expired token', 401, 'UNAUTHORIZED'));
  }
};

// Role-based authorization
const requireRole = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user?.role)) {
    return next(new AppError('Insufficient permissions', 403, 'FORBIDDEN'));
  }
  next();
};

app.get('/admin/users', requireAuth, requireRole('admin'), listUsers);

Authentication β€” Sessions (Stateful)

npm install express-session connect-redis
const session   = require('express-session');
const RedisStore = require('connect-redis').default;

app.use(session({
  store:             new RedisStore({ client: redisClient }),
  secret:            process.env.SESSION_SECRET,
  resave:            false,     // don't save unchanged sessions
  saveUninitialized: false,     // don't save empty sessions (GDPR friendly)
  cookie: {
    httpOnly: true,             // not accessible via document.cookie (XSS protection)
    secure:   process.env.NODE_ENV === 'production', // HTTPS only in prod
    sameSite: 'strict',         // CSRF protection
    maxAge:   30 * 60 * 1000,  // 30 minutes
  },
}));

// Login endpoint
app.post('/auth/login', async (req, res, next) => {
  try {
    const user = await verifyCredentials(req.body);
    req.session.userId   = user.id;
    req.session.userRole = user.role;
    res.json({ message: 'Logged in' });
  } catch (err) { next(err); }
});

// Middleware to protect routes
const requireSession = (req, res, next) => {
  if (!req.session.userId) {
    return next(new AppError('Not authenticated', 401, 'UNAUTHORIZED'));
  }
  next();
};

JWT vs Sessions:

  • JWT β€” stateless, scales horizontally without shared storage, but cannot be revoked until expiry. Best for APIs consumed by mobile/SPA clients.
  • Sessions β€” stateful, can be revoked instantly (just delete from Redis), simpler for traditional web apps.

Database Integration Pattern

Express has no opinion on databases. Here’s a clean pattern with a service layer:

// repositories/userRepository.js
class UserRepository {
  constructor(db) { this.db = db; }

  async findById(id) {
    const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
    return result.rows[0] || null;
  }

  async create({ name, email, passwordHash }) {
    const result = await this.db.query(
      'INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING *',
      [name, email, passwordHash]
    );
    return result.rows[0];
  }
}

// services/userService.js
class UserService {
  constructor(userRepo) { this.userRepo = userRepo; }

  async getUser(id) {
    const user = await this.userRepo.findById(id);
    if (!user) throw new NotFoundError('User');
    return { id: user.id, name: user.name, email: user.email }; // never return password hash
  }
}

// routes/users.js
router.get('/:id', requireAuth, async (req, res, next) => {
  try {
    const user = await userService.getUser(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
});

Testing with Supertest

npm install -D jest supertest
// routes/users.test.js
const request = require('supertest');
const app     = require('../app'); // export app without .listen()

describe('GET /users/:id', () => {
  it('returns a user', async () => {
    const res = await request(app)
      .get('/users/1')
      .set('Authorization', `Bearer ${validToken}`);

    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ id: 1, name: 'Alice' });
  });

  it('returns 404 for unknown user', async () => {
    const res = await request(app)
      .get('/users/9999')
      .set('Authorization', `Bearer ${validToken}`);

    expect(res.status).toBe(404);
    expect(res.body.error.code).toBe('NOT_FOUND');
  });

  it('returns 401 without a token', async () => {
    const res = await request(app).get('/users/1');
    expect(res.status).toBe(401);
  });
});

πŸ”΄ Senior

Security Hardening

npm install helmet cors express-rate-limit
const helmet    = require('helmet');
const cors      = require('cors');
const rateLimit = require('express-rate-limit');

// Helmet sets security-related HTTP headers
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc:  ["'self'"],
      styleSrc:   ["'self'", "'unsafe-inline'"],
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true },
}));

// CORS β€” explicitly whitelist allowed origins
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
}));

// Rate limiting β€” prevents brute-force and DDoS
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
});
app.use(globalLimiter);

// Stricter limit on auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many attempts, try again later' },
});
app.use('/auth', authLimiter);

Performance

npm install compression
const compression = require('compression');
app.use(compression()); // gzip all responses > 1kb

// Never use *Sync methods in request handlers β€” they block the entire event loop:
// ❌ fs.readFileSync(...)
// βœ… await fs.promises.readFile(...)

// Cluster β€” fork one worker per CPU core
import cluster from 'cluster';
import { cpus } from 'os';

if (cluster.isPrimary) {
  for (let i = 0; i < cpus().length; i++) cluster.fork();
  cluster.on('exit', (worker) => {
    console.warn(`Worker ${worker.id} died, restarting`);
    cluster.fork();
  });
} else {
  startExpressServer(); // your normal app.listen(...)
}
// In practice: pm2 start app.js -i max   does this without writing cluster code

Graceful Shutdown

const server = app.listen(port, () => console.log(`Listening on ${port}`));

const shutdown = async (signal) => {
  console.log(`Received ${signal}, shutting down gracefully…`);
  server.close(async () => {           // stop accepting new connections
    await db.pool.end();               // drain database pool
    await redisClient.quit();          // close Redis connection
    console.log('Shutdown complete');
    process.exit(0);
  });
  // Force exit if graceful shutdown takes too long
  setTimeout(() => process.exit(1), 10_000);
};

process.on('SIGTERM', () => shutdown('SIGTERM')); // Docker/Kubernetes stop
process.on('SIGINT',  () => shutdown('SIGINT'));  // Ctrl+C

Structured Project Layout

src/
  config/         ← env variables, DB config
  middleware/     ← auth, logging, validation, error-handler
  routes/         ← one file per resource (users.js, posts.js)
  controllers/    ← thin: parse req, call service, send res
  services/       ← business logic
  repositories/   ← data access (SQL/ORM queries)
  models/         ← schema definitions
  errors/         ← custom error classes
  utils/          ← shared helpers
  app.js          ← express setup, middleware registration, route mounting
  server.js       ← http.listen(), cluster, graceful shutdown

Keep controllers thin β€” they should only parse the request, call a service, and format the response. Business logic lives in services.

Express vs Alternatives

Β  Express Fastify NestJS Hono
Requests/sec ~30k ~60k ~25k ~100k+
TypeScript Manual Built-in First-class First-class
Structure DIY Plugin-based Angular-like Minimal
Ecosystem Largest Growing Enterprise Edge-focused
Best for Full control, legacy High-throughput APIs Large teams Edge/serverless

Senior Gotchas

  • Forgetting next(err) in async handlers β€” causes hanging requests that never respond. Consider wrapping all async route handlers with a try-catch wrapper utility.
  • Middleware order β€” cors() must come before route handlers; body parsers must come before anything that reads req.body; the error handler MUST be last.
  • Not setting NODE_ENV=production β€” Express keeps development-only overhead (error stack traces in responses, view cache off) running in production.
  • req.params values are always strings β€” req.params.id for a route /users/:id is "42", not 42. Parse with +req.params.id or parseInt before using in DB queries.
  • router.param() deduplication β€” use router.param('id', lookupUser) to avoid repeating the same DB lookup in every route that uses :id.
  • Unhandled promise rejections crash Node 15+ β€” always either try/catch async handlers or use a wrapper. Express 5 fixes this automatically.
  • app vs server β€” app.listen() returns the underlying http.Server. If you need WebSocket or graceful shutdown control, save the reference: const server = app.listen(...).