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
reqandresthat raw Node lacks - Nothing else — no ORM, no auth, no templating. You assemble those yourself.
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:
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();
});
The req Object — Complete Reference
Everything the client sent lives on req. These are the properties you'll use daily:
GET /users/:id → req.params.id. Always strings. Cast before comparing to DB ids.?page=2&sort=name → { page: '2', sort: 'name' }. Always strings. Never trust.express.json() or express.urlencoded() middleware. Undefined without it.req.headers.authorization, req.headers['content-type'].'GET', 'POST', 'DELETE'…'/users/42'. Use req.url to include the query string.app.set('trust proxy', 1) when behind a load balancer to read from X-Forwarded-For.req.get('Authorization'). Equivalent to req.headers.authorization.cookie-parser middleware. Not available by default.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
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
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!
});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;
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?"