Getting Started

Express.js Fundamentals — Getting Started

Express.js Fundamentals

Chapter 1 of 8  ·  Getting Started

Getting Started

Express is the most widely-used Node.js web framework — not because it does a lot, but precisely because it does very little. It adds routing, middleware, and a cleaner API around Node's built-in http module, then gets out of your way. Understanding what Express actually is (a thin wrapper, not a full framework) is the mental model that makes every other concept in this course click immediately.

Why Express Exists

Node's built-in http module can handle HTTP requests, but building even a simple API with it gets tedious fast. Every route is an if block, parsing request bodies requires manual buffering, and sending JSON means setting headers by hand.

TaskRaw Node httpExpress
Route GET /users if (req.method === 'GET' && req.url === '/users') app.get('/users', handler)
Parse JSON body Manually collect chunks, JSON.parse on end event app.use(express.json()) — then req.body
Send JSON response res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(data)) res.json(data)
URL parameters Parse req.url with URL constructor manually app.get('/users/:id', ...)req.params.id
Serve static files Read file, determine MIME type, stream response app.use(express.static('public'))

Express doesn't replace Node's http module — it wraps it. A request that arrives at Node's http server is passed straight through to Express, which adds its routing layer and then calls your handler with enhanced req and res objects.

Installation & First Server

# Start a new project mkdir my-api && cd my-api npm init -y npm install express # Optional but highly recommended for development npm install --save-dev nodemon
// server.js — the simplest possible Express server const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send('Hello from Express!'); }); app.listen(3000, () => { console.log('Server listening on http://localhost:3000'); });

Run it with node server.js, or npx nodemon server.js during development (nodemon auto-restarts on file changes — essential for a smooth workflow).

The app Object

express() returns an application object — the central object in every Express app. It has methods for registering routes, configuring settings, and attaching middleware. Internally it is also a valid http.RequestListener, which is why you can pass it directly to http.createServer(app) — that's exactly what app.listen() does under the hood.

// app.listen() is just shorthand for this: const http = require('node:http'); const server = http.createServer(app); server.listen(3000); // Knowing this matters: you'll use the returned server reference // for WebSockets, graceful shutdown, and tests (supertest accepts it directly). const server2 = app.listen(3000, () => console.log('ready')); // server2 is an http.Server — same object, same API

Route Methods

Express provides a method on the app object for every HTTP verb: app.get(), app.post(), app.put(), app.patch(), app.delete(), app.all(). Each takes a path pattern and one or more handler functions.

// Route registration — method, path, handler app.get('/articles', listArticles); // GET /articles app.post('/articles', createArticle); // POST /articles app.get('/articles/:id', getArticle); // GET /articles/42 app.put('/articles/:id', updateArticle); // PUT /articles/42 app.delete('/articles/:id', deleteArticle); // DELETE /articles/42 app.all('/ping', handleAll); // any method // Handler function signature — always (req, res[, next]) function listArticles(req, res) { res.json([{ id: 1, title: 'First Post' }]); }
GET POST PUT / PATCH DELETE

req and res — A First Look

Express wraps Node's raw IncomingMessage and ServerResponse objects, adding convenience properties and methods. Chapter 4 covers them in depth; here's the essential subset for Chapter 1.

The req Object

req.params — URL parameters (:idreq.params.id)

req.query — parsed query string (?sort=asc)

req.body — parsed request body (needs a body-parser middleware)

req.headers — request headers object

req.method — HTTP verb as a string ('GET')

req.path — path without query string ('/users/42')

The res Object

res.send(body) — send any response (string, Buffer, object)

res.json(data) — send JSON (sets Content-Type automatically)

res.status(code) — set status code; chainable

res.redirect(url) — send 302 redirect

res.set(field, value) — set a response header

res.sendFile(path) — stream a file to the client

// Common response patterns — all send the response and end the cycle res.send('plain text'); res.json({ ok: true, data: [] }); res.status(404).json({ error: 'Not found' }); // chainable res.status(201).json({ id: newRecord.id }); res.redirect(301, '/new-location'); res.sendStatus(204); // 204 No Content (empty body) // Only one response per request — calling res.json() after res.send() // is a "headers already sent" bug. Return after sending. app.get('/safe', (req, res) => { if (!req.query.key) { res.status(400).json({ error: 'key required' }); return; // ← crucial: stop execution after responding } res.json({ data: 'ok' }); });

The Request-Response Cycle

HTTP Request arrives │ ▼ ┌─────────────────────────────────────────────┐ │ Express Application │ │ │ │ Middleware 1 → Middleware 2 → ... → Route │ │ (logger) (json parser) handler │ └─────────────────────────────────────────────┘ │ ▼ HTTP Response sent Each middleware calls next() to pass to the next one. The route handler sends the response (no next() call). Middleware is covered in depth in Chapter 3.

Recommended Project Structure

Express imposes no structure at all — you can put everything in one file. In Chapter 8 we'll look at the full MVC pattern, but this is a sensible starting shape for any project beyond a toy:

my-api/ ├── package.json ├── package-lock.json ├── .env # PORT, DATABASE_URL, secrets — never commit ├── .gitignore # node_modules, .env ├── server.js # entry point — creates http server, calls listen() ├── src/ │ ├── app.js # creates and exports the Express app (no listen here) │ ├── routes/ │ │ ├── index.js # mounts all sub-routers │ │ ├── users.js # express.Router() for /users │ │ └── articles.js # express.Router() for /articles │ ├── middleware/ │ │ └── logger.js │ └── controllers/ # handler logic, separated from routes └── tests/ └── app.test.js

The key split: app.js creates and configures the Express app and exports it. server.js imports it and calls app.listen(). This lets test files import app without starting a real server, which is the pattern supertest expects.

// src/app.js — create app, wire up middleware and routes, export const express = require('express'); const app = express(); app.use(express.json()); // parse JSON request bodies app.use(express.urlencoded({ extended: false })); // parse form bodies app.get('/', (_req, res) => res.json({ status: 'ok' })); // More routes and middleware will be added in later chapters module.exports = app; // server.js — import app, call listen, handle signals const app = require('./src/app'); const port = process.env.PORT ?? 3000; const server = app.listen(port, () => { console.log(`Listening on http://localhost:${port}`); }); process.on('SIGTERM', () => server.close(() => process.exit(0)));

⚡ Express Version: 4 vs 5

Express 5 (beta since 2015, stable release in 2024) is the current version and is what npm install express installs today. The most important change for your code: async route handlers that throw will automatically be caught and passed to error middleware — in Express 4 you had to wrap them in try/catch and call next(err) manually (or use the express-async-errors package as a patch). Chapter 6 covers error handling in detail, but be aware of which version you're on: express --version or npm list express.

All examples in this course work on both Express 4 and 5. Where there's a behavioural difference, it's noted.

📦 What Gets Installed

npm install express installs Express itself plus a small set of well-known dependencies (e.g. path-to-regexp for route matching, qs for query string parsing, depd for deprecation warnings). Express has deliberately kept its own dependency footprint minimal — everything else (body parsing beyond JSON, templating, sessions, authentication) is a separate npm install. That's a feature, not a limitation: you pay only for what you actually use.

Coding Challenges

Challenge 1 — Multi-Route Hello World

Build a self-contained Express server (single file, no external deps beyond express) with these four routes: GET / returns a JSON object { message: "Welcome to my API", version: "1.0" }; GET /health returns { status: "ok", uptime: <seconds> } using process.uptime(); GET /echo reads a ?message= query parameter and returns { echo: "<value>" }, responding 400 with { error: "message query param required" } if it's missing; GET /time returns the current UTC time as an ISO string. The server should listen on process.env.PORT || 3000 and log the port on startup.

View sample solution ↗

Challenge 2 — app.js / server.js Split

Refactor the Challenge 1 server into the two-file structure: src/app.js creates and configures the Express app (mounts all four routes from Challenge 1) and exports it without calling listen(); server.js imports app, calls listen(), and attaches a SIGTERM handler that closes the server gracefully before exiting. Write a test file app.test.js using supertest that imports app directly (no running server) and verifies: GET / returns 200 with version: "1.0"; GET /health returns 200 with a numeric uptime; GET /echo without a query param returns 400; GET /echo?message=hello returns { echo: "hello" }.

View sample solution ↗

Challenge 3 — Simple Request Logger

Write a custom middleware function requestLogger(req, res, next) that logs [METHOD] /path → STATUS_CODE (Xms) for every request (e.g. [GET] /health → 200 (3ms)). The tricky part: the status code isn't known until the response is sent, so you need to listen for the 'finish' event on res and calculate elapsed time using Date.now() or process.hrtime.bigint(). Mount it on the app from Challenge 2. Write a supertest test that makes two requests and captures console.log output (use jest.spyOn) to verify both log lines appear in the correct format.

View sample solution ↗