Phase 2 — Building REST APIs lesson-0008

Express.js Basics

Express is a thin, unopinionated layer over Node's raw http module. Understanding what it actually does — and doesn't do — separates developers who use it from developers who master it.

What Express Actually Is

In Lesson 4 you built a raw Node HTTP server. Every route was an if statement. Every response required manually setting headers. Express wraps that exact same http.createServer but gives you:

  • A routing system that maps method + path patterns to handler functions
  • A middleware pipeline — a chain of functions that process every request
  • Convenience methods on req and res that raw Node lacks
  • Nothing else — no ORM, no auth, no templating. You assemble those yourself.
Why Minimal Matters
Express's simplicity is a feature. You choose every piece. This is different from Rails or Django which make choices for you. The tradeoff: more power, more responsibility to structure things well.

Installation & Setup

# Create and initialise the project
mkdir my-api && cd my-api
npm init -y

# Core dependencies
npm install express

# Development dependencies
npm install -D nodemon          # auto-restart on file change

# Add to package.json — scripts section
# "start":  "node src/index.js"
# "dev":    "nodemon src/index.js"

Production-Grade Project Structure

How you organise files matters enormously as a project grows. This structure separates concerns cleanly and is used by real production teams:

my-api/ ├── src/ │ ├── index.js ← entry point: starts server, handles process signals │ ├── app.js ← Express app: middleware + routes (no listen() here) │ ├── routes/ │ │ ├── index.js ← mounts all routers at their paths │ │ ├── users.js ← /api/v1/users route definitions │ │ └── posts.js ← /api/v1/posts route definitions │ ├── controllers/ │ │ ├── users.js ← user business logic (what routes point to) │ │ └── posts.js │ ├── middleware/ │ │ ├── auth.js ← JWT verification middleware │ │ ├── validate.js ← request validation factory │ │ └── errorHandler.js ← centralised error handling │ ├── services/ │ │ └── users.js ← pure business logic, no req/res (testable) │ ├── db/ │ │ ├── client.js ← database connection singleton │ │ └── migrations/ ← SQL migration files │ ├── schemas/ │ │ └── users.js ← Zod validation schemas │ └── errors/ │ └── AppError.js ← custom error classes ├── tests/ │ ├── users.test.js │ └── setup.js ├── .env ← environment variables (NEVER commit) ├── .env.example ← template showing required vars (DO commit) ├── .gitignore └── package.json

app.js vs index.js — Why They Must Be Separate

This separation is non-negotiable for a testable codebase:

// src/app.js — Express application configuration ONLY
const express = require('express');
const app = express();

// ── Middleware ──────────────────────────────────
app.use(express.json({ limit: '10kb' }));   // body size limit prevents DoS
app.use(express.urlencoded({ extended: true })); // parse form bodies too

// ── Routes ─────────────────────────────────────
app.use('/api/v1', require('./routes'));

// ── 404 handler ────────────────────────────────
app.use((req, res) => {
  res.status(404).json({ error: 'Not Found', path: req.path });
});

// ── Error handler (4 args — must be last) ──────
app.use(require('./middleware/errorHandler'));

module.exports = app;  // ← export without listening
// src/index.js — server lifecycle ONLY
const app = require('./app');

const PORT = process.env.PORT ?? 3000;

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

// ── Graceful shutdown ───────────────────────────
// When the OS sends SIGTERM (e.g. on deploy/container stop),
// stop accepting new connections and finish existing ones.
const shutdown = () => {
  console.log('[server] Shutting down gracefully...');
  server.close(() => {
    console.log('[server] All connections closed. Exiting.');
    process.exit(0);
  });
  // Force exit if connections don't close in 10s
  setTimeout(() => process.exit(1), 10_000);
};

process.on('SIGTERM', shutdown);
process.on('SIGINT',  shutdown); // Ctrl+C in development

// ── Unhandled rejections ────────────────────────
process.on('unhandledRejection', (reason) => {
  console.error('[fatal] Unhandled rejection:', reason);
  shutdown();
});
Why Graceful Shutdown Matters in Production
When Kubernetes or Docker restarts your container, it sends SIGTERM first. If you ignore it, the process is killed immediately — any in-flight database transactions or HTTP requests are cut off. Graceful shutdown lets those complete. This is required for zero-downtime deployments.

The req Object — Complete Reference

Everything the client sent lives on req. These are the properties you'll use daily:

req.params
Route parameters — GET /users/:idreq.params.id. Always strings. Cast before comparing to DB ids.
req.query
Query string as object — ?page=2&sort=name{ page: '2', sort: 'name' }. Always strings. Never trust.
req.body
Parsed request body. Requires express.json() or express.urlencoded() middleware. Undefined without it.
req.headers
All request headers as lowercase keys: req.headers.authorization, req.headers['content-type'].
req.method
HTTP verb as uppercase string: 'GET', 'POST', 'DELETE'
req.path
URL path without query string: '/users/42'. Use req.url to include the query string.
req.ip
Client IP address. Set app.set('trust proxy', 1) when behind a load balancer to read from X-Forwarded-For.
req.get(header)
Case-insensitive header read: req.get('Authorization'). Equivalent to req.headers.authorization.
req.cookies
Parsed cookies — requires cookie-parser middleware. Not available by default.
req.user
Not built-in — you attach this in auth middleware: req.user = decodedJwt. Available in all downstream handlers.

The res Object — Complete Reference

// ── Sending responses ───────────────────────────────────────────
res.json(data)               // JSON + Content-Type: application/json (most common)
res.send(text)               // text/html or Buffer
res.status(201).json(data)   // chain: set status then send
res.status(204).send()       // 204 No Content — empty body (use for DELETE)
res.sendFile(absolutePath)   // stream a file to the response
res.redirect(301, '/new')   // permanent redirect (302 = temporary)
res.end()                    // close without body — lower level, rarely needed

// ── Headers ────────────────────────────────────────────────────
res.set('X-Request-ID', id)   // set a single header
res.set({ 'X-A': '1', 'X-B': '2' }) // set multiple headers
res.get('Content-Type')       // read a header you've already set
res.type('json')               // shorthand for Content-Type: application/json

// ── Cookies ────────────────────────────────────────────────────
res.cookie('sessionId', token, {
  httpOnly: true,    // JS cannot read — prevents XSS theft
  secure:   true,    // HTTPS only
  sameSite: 'strict', // prevents CSRF
  maxAge:   7 * 24 * 60 * 60 * 1000, // 7 days in ms
});
res.clearCookie('sessionId'); // remove a cookie

// ── State queries ──────────────────────────────────────────────
res.headersSent  // true if headers have already been sent — calling res.json() again throws
Double-Response Bug
If you call res.json() twice in a handler, you'll get: Error: Cannot set headers after they are sent to the client. This is a common bug. Always return res.json() to exit the handler after responding, or structure your code with an early-return pattern.

Early-Return Pattern — Preventing Double Responses

❌ Bug: double response
app.get('/users/:id', (req, res) => {
  const user = findUser(id);
  if (!user) {
    res.status(404).json({
      error: 'Not found'
    });
    // ← no return! falls through
  }
  res.json(user); // ← crashes!
});
✅ Correct: early return
app.get('/users/:id', (req, res) => {
  const user = findUser(id);
  if (!user) {
    return res.status(404)
      .json({ error: 'Not found' });
    // ↑ return exits handler
  }
  res.json(user); // safe
});

Full Working CRUD — Users Resource

// src/routes/users.js
const { Router } = require('express');
const asyncHandler = require('express-async-errors'); // npm install express-async-errors
const { NotFoundError } = require('../errors/AppError');
const db = require('../db/client');

const router = Router();

// GET /api/v1/users — list with pagination
router.get('/', async (req, res) => {
  const page  = Math.max(1, +req.query.page  || 1);
  const limit = Math.min(100, +req.query.limit || 20);
  const offset = (page - 1) * limit;

  const [users, total] = await Promise.all([
    db.users.findMany({ skip: offset, take: limit }),
    db.users.count(),
  ]);

  res.json({
    data: users,
    meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
  });
});

// GET /api/v1/users/:id — single user
router.get('/:id', async (req, res) => {
  const user = await db.users.findUnique({ where: { id: +req.params.id } });
  if (!user) throw new NotFoundError('User');
  res.json({ data: user });
});

// POST /api/v1/users — create
router.post('/', async (req, res) => {
  const user = await db.users.create({ data: req.body });
  res
    .status(201)
    .set('Location', `/api/v1/users/${user.id}`)
    .json({ data: user });
});

// PATCH /api/v1/users/:id — partial update
router.patch('/:id', async (req, res) => {
  const user = await db.users.update({
    where: { id: +req.params.id },
    data: req.body,
  });
  res.json({ data: user });
});

// DELETE /api/v1/users/:id — delete
router.delete('/:id', async (req, res) => {
  await db.users.delete({ where: { id: +req.params.id } });
  res.status(204).send();
});

module.exports = router;

Environment Configuration Best Practices

// .env — never commit. Add .env to .gitignore
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=supersecretkey

// .env.example — DO commit. Shows teammates what vars are needed
NODE_ENV=
PORT=3000
DATABASE_URL=
JWT_SECRET=
// src/config.js — validate env vars at startup, not at runtime
const { z } = require('zod');

const envSchema = z.object({
  NODE_ENV:     z.enum(['development', 'test', 'production']),
  PORT:         z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET:   z.string().min(32),
});

const result = envSchema.safeParse(process.env);
if (!result.success) {
  console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
  process.exit(1); // fail loudly at startup, not silently at runtime
}

module.exports = result.data;
Production Rule: Fail Fast
If a required environment variable is missing, crash immediately on startup with a clear error. A server running with a missing DATABASE_URL that fails on the first request is much harder to debug than a server that refuses to start and prints exactly what's wrong.

app.settings — Important Knobs

// Trust the X-Forwarded-For header from proxies (Nginx, AWS ALB)
// Without this, req.ip returns the proxy's IP, not the client's
app.set('trust proxy', 1);  // 1 = trust one hop (your load balancer)

// Disable the X-Powered-By: Express header — security by obscurity
app.disable('x-powered-by'); // helmet does this too, but good to know

// When NODE_ENV=production, Express enables response caching,
// disables stack traces in error responses, and other optimisations
// This is set automatically when you deploy — ensure NODE_ENV is set

🧠 Check Your Understanding


Go Deeper

Primary source: Express 4.x API Reference — the definitive complete reference for req, res, and app.

Read next: Express Performance Best Practices — official guide covering compression, caching, process management.

Ask your teacher: "Explain graceful shutdown in more depth — what happens to in-flight DB transactions?" or "What does trust proxy do and when do I need it?"