Express was created by TJ Holowaychuk in 2010, inspired by Ruby's Sinatra. It is the de facto standard web framework for Node.js — minimalist by design, providing a thin layer of features without obscuring Node's own APIs.
mkdir my-app && cd my-app
npm init -y
npm install express
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello Express!');
});
app.listen(3000, () => {
console.log('Server running on :3000');
});
Express wraps Node's native http.createServer(), adding routing, middleware chaining, and a cleaner API — but you can still access the raw req and res objects underneath.
const express = require('express');
const app = express();
// app is both a function and an object
// — it holds settings, routes, and
// acts as the request handler
// HTTP method + path + handler
app.get('/', (req, res) => {
res.send('Homepage');
});
app.get('/about', (req, res) => {
res.send('About page');
});
app.post('/contact', (req, res) => {
res.json({ received: true });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}`);
});
// Returns an http.Server instance
// — you can use it for WebSockets, etc.
// Store application settings
app.set('view engine', 'ejs');
app.set('port', 3000);
// Retrieve settings
app.get('view engine'); // 'ejs'
app.get('env'); // 'development'
// Note: app.get(path, handler) is
// also the route method — Express
// overloads based on argument count
app.get('/users', handler); // Read
app.post('/users', handler); // Create
app.put('/users/1', handler); // Replace
app.patch('/users/1',handler); // Update
app.delete('/users/1',handler);// Delete
// Match ALL methods
app.all('/secret', requireAuth, handler);
// Named parameters — captured in req.params
app.get('/users/:id', (req, res) => {
console.log(req.params.id); // '42'
res.send(`User ${req.params.id}`);
});
// Multiple params
app.get('/users/:userId/posts/:postId',
(req, res) => {
// req.params = { userId, postId }
}
);
// GET /search?q=express&page=2
app.get('/search', (req, res) => {
req.query.q; // 'express'
req.query.page; // '2' (always string)
});
// Regex in route paths
app.get(/.*fly$/, handler);
// matches butterfly, dragonfly
// Optional parameter (Express 5 style)
app.get('/users/:id?', handler);
// Route chaining with app.route()
app.route('/books')
.get((req, res) => { /* list */ })
.post((req, res) => { /* create */ });
// Array of handlers / middleware chain
app.get('/dashboard',
authenticate,
authorise('admin'),
(req, res) => {
res.render('dashboard');
}
);
// Each handler can call next()
// to pass control to the next one
// routes/users.js
const router = require('express').Router();
router.get('/', (req, res) => {
res.json(users);
});
router.get('/:id', (req, res) => {
res.json(users[req.params.id]);
});
router.post('/', (req, res) => {
// create user
});
module.exports = router;
// app.js
const usersRouter = require('./routes/users');
const postsRouter = require('./routes/posts');
app.use('/api/users', usersRouter);
app.use('/api/posts', postsRouter);
// GET /api/users → router.get('/')
// GET /api/users/42 → router.get('/:id')
// GET /api/posts → postsRouter.get('/')
const adminRouter = express.Router();
// Applies only to this router's routes
adminRouter.use(requireAuth);
adminRouter.use(requireAdmin);
adminRouter.get('/dashboard', handler);
adminRouter.get('/settings', handler);
app.use('/admin', adminRouter);
routes/
├── index.js ← loads all routers
├── auth.js ← /auth/*
├── users.js ← /api/users/*
├── posts.js ← /api/posts/*
└── admin.js ← /admin/*
// Pre-process :id for all routes
router.param('id', async (req, res, next, id) => {
req.user = await User.findById(id);
if (!req.user) return res.status(404).json(
{ error: 'Not found' }
);
next();
});
| Property | Description |
|---|---|
req.params | Route parameters (:id) |
req.query | Query string (?q=x) |
req.body | Parsed body (needs middleware) |
req.headers | Request headers object |
req.method | GET, POST, PUT, etc. |
req.path | URL path without query |
req.cookies | Cookies (needs cookie-parser) |
req.ip | Client IP address |
// Requires body-parsing middleware
app.use(express.json());
app.use(express.urlencoded({
extended: true
}));
app.post('/api/users', (req, res) => {
console.log(req.body.name);
console.log(req.body.email);
});
| Method | Purpose |
|---|---|
res.send() | Send string/Buffer/object |
res.json() | Send JSON response |
res.render() | Render a view template |
res.redirect() | Redirect to another URL |
res.status() | Set HTTP status code |
res.sendFile() | Send a file as response |
res.set() | Set response headers |
res.cookie() | Set a cookie |
// Status + JSON
res.status(201).json({ id: newUser.id });
// Status + Render
res.status(404).render('404', {
title: 'Not Found'
});
// Redirect with status
res.redirect(301, '/new-url');
// Set header + send
res.set('X-Request-Id', uuid()).json(data);
// Every middleware is a function with
// (req, res, next)
const logger = (req, res, next) => {
console.log(`${req.method} ${req.path}`);
next(); // pass to next middleware
};
app.use(logger);
If you don't call next(), the request hangs — no response is ever sent.
// Middleware executes top to bottom
app.use(express.json()); // 1st
app.use(cors()); // 2nd
app.use(helmet()); // 3rd
app.use('/api', authMiddleware); // 4th
app.get('/api/data', handler); // 5th
// Error handler must be LAST
app.use(errorHandler); // 6th
| Type | Scope |
|---|---|
| Application-level | app.use(fn) — runs on every request |
| Router-level | router.use(fn) — scoped to router |
| Route-level | Inline: app.get(path, fn, handler) |
| Error-handling | (err, req, res, next) — 4 args |
| Built-in | express.json(), express.static() |
| Third-party | cors(), helmet(), morgan() |
Each middleware can modify req/res, end the cycle, or call next() to continue.
// Parses JSON request bodies
app.use(express.json());
// Options
app.use(express.json({
limit: '10kb', // max body size
strict: true, // only objects/arrays
type: 'application/json'
}));
// POST { "name": "Ada" }
app.post('/api/users', (req, res) => {
console.log(req.body.name); // 'Ada'
});
// Parses URL-encoded form data
app.use(express.urlencoded({
extended: true // use qs library
}));
// <form method="POST">
// <input name="email" value="a@b.com">
// </form>
app.post('/login', (req, res) => {
console.log(req.body.email); // 'a@b.com'
});
const path = require('path');
// Serve files from /public
app.use(express.static(
path.join(__dirname, 'public')
));
// With virtual path prefix
app.use('/assets', express.static(
path.join(__dirname, 'public')
));
// /assets/css/style.css → public/css/style.css
// Options
app.use(express.static('public', {
maxAge: '1d', // cache control
index: 'index.html', // default file
dotfiles: 'ignore' // hide .files
}));
// Parse body as Buffer
app.use('/webhook',
express.raw({ type: 'application/octet-stream' })
);
// Parse body as plain text
app.use('/logs',
express.text({ type: 'text/plain' })
);
Less common but useful for webhooks (Stripe, GitHub) and log ingestion endpoints.
| Package | Purpose |
|---|---|
cors | Cross-Origin Resource Sharing headers |
helmet | Security headers (15+ protections) |
morgan | HTTP request logging |
compression | Gzip/Brotli response compression |
express-rate-limit | Rate limiting per IP/key |
cookie-parser | Parse cookies into req.cookies |
express-session | Server-side session management |
multer | Multipart/file upload handling |
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
const rateLimit = require(
'express-rate-limit'
);
app.use(helmet());
app.use(cors({ origin: 'https://mysite.com' }));
app.use(compression());
app.use(morgan('combined'));
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 min
max: 100 // 100 requests per window
});
app.use('/api', limiter);
Security first (helmet, cors), then parsing (json, urlencoded), then logging (morgan), then routes, then error handlers last.
// MUST have exactly 4 parameters
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message
});
});
// Trigger with next(err)
app.get('/fail', (req, res, next) => {
const err = new Error('Something broke');
err.status = 500;
next(err); // skips to error handler
});
class AppError extends Error {
constructor(message, status = 500) {
super(message);
this.status = status;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
// Usage in route
app.get('/users/:id', async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) return next(new NotFoundError('User'));
res.json(user);
});
// Express 4: wrap async handlers
const asyncHandler = (fn) =>
(req, res, next) =>
Promise.resolve(fn(req, res, next))
.catch(next);
app.get('/users', asyncHandler(
async (req, res) => {
const users = await User.find();
res.json(users);
// Thrown errors auto-forwarded to
// error middleware via .catch(next)
}
));
// Express 5: automatic — no wrapper needed!
// Rejected promises are caught automatically
// Place AFTER all routes, BEFORE error handler
app.use((req, res, next) => {
res.status(404).json({
error: `Cannot ${req.method} ${req.path}`
});
});
Use operational errors (bad input, not found) for client-facing messages and programmer errors (bugs) for logging + generic 500. Never leak stack traces in production.
const path = require('path');
// Tell Express which engine to use
app.set('view engine', 'ejs');
// Tell Express where views live
app.set('views', path.join(
__dirname, 'views'
));
// Now .ejs extension is optional:
res.render('home'); // views/home.ejs
app.get('/', (req, res) => {
res.render('home', {
title: 'Welcome',
user: req.user,
items: [1, 2, 3]
});
});
// Data available in the template:
// <%= title %> <%= user.name %>
// Global — available in every render
app.locals.siteName = 'My App';
// Per-request — set in middleware
app.use((req, res, next) => {
res.locals.currentUser = req.user;
next();
});
| Engine | Syntax | Install |
|---|---|---|
| EJS | <%= %> JS in HTML | npm i ejs |
| Pug | Indentation-based | npm i pug |
| Handlebars | {{ }} Mustache | npm i hbs |
| Nunjucks | {% %} Jinja2 | npm i nunjucks |
// Register custom engines
const ejs = require('ejs');
const pug = require('pug');
app.engine('ejs', ejs.renderFile);
app.engine('pug', pug.renderFile);
// Render with explicit extension
res.render('email.ejs', data);
res.render('page.pug', data);
// Default engine for extensionless files
app.set('view engine', 'ejs');
const path = require('path');
// Serve from /public directory
app.use(express.static(
path.join(__dirname, 'public')
));
// File structure:
// public/
// css/style.css → /css/style.css
// js/app.js → /js/app.js
// images/logo.png → /images/logo.png
// index.html → / (auto-served)
// Files accessible under /static/*
app.use('/static', express.static(
path.join(__dirname, 'public')
));
// public/css/style.css
// → /static/css/style.css
// Multiple static directories
app.use(express.static('public'));
app.use(express.static('uploads'));
// Express searches in order declared
app.use(express.static('public', {
maxAge: '30d', // Cache-Control
etag: true, // ETag header
lastModified: true, // Last-Modified
immutable: true, // immutable flag
index: false, // disable auto index
dotfiles: 'ignore', // hide .dotfiles
redirect: false, // no trailing / redirect
fallthrough: true // pass to next handler
}));
// Versioned assets for cache-busting
// /assets/style.abc123.css → maxAge: '1y'
maxAge for versioned/hashed assetscompression middleware for dynamic responses// Express checks NODE_ENV internally
// 'production' enables:
// - view caching
// - less verbose errors
// - better performance
// Check in code
if (app.get('env') === 'production') {
app.set('trust proxy', 1);
app.use(helmet());
}
// Set via CLI
// NODE_ENV=production node app.js
// npm install dotenv
require('dotenv').config();
// .env file
// PORT=3000
// DB_URL=mongodb://localhost/mydb
// SESSION_SECRET=keyboard_cat
// API_KEY=sk_live_abc123
const PORT = process.env.PORT || 3000;
const DB = process.env.DB_URL;
// NEVER commit .env to git!
// Add to .gitignore
| Setting | Purpose |
|---|---|
env | Environment mode |
view engine | Template engine |
views | Views directory |
view cache | Cache compiled templates |
trust proxy | Trust X-Forwarded-* headers |
json spaces | Prettify JSON output |
etag | ETag response header |
case sensitive routing | /Foo ≠ /foo |
// Behind Nginx, AWS ELB, Heroku, etc.
app.set('trust proxy', 1);
// Enables correct:
// - req.ip (client IP, not proxy IP)
// - req.protocol ('https' not 'http')
// - req.hostname
// - secure cookies over HTTPS
Important: Only enable when behind a trusted proxy. Otherwise clients can spoof headers.
const helmet = require('helmet');
app.use(helmet());
// Sets 15+ HTTP headers:
// - Content-Security-Policy
// - X-Content-Type-Options: nosniff
// - X-Frame-Options: DENY
// - Strict-Transport-Security
// - X-XSS-Protection
// ...and more
// Customise individual headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "cdn.example.com"]
}
}
}));
const cors = require('cors');
// Allow specific origin
app.use(cors({
origin: 'https://myapp.com',
methods: ['GET', 'POST'],
credentials: true
}));
// Dynamic origin
app.use(cors({
origin: (origin, cb) => {
const allowed = ['https://a.com', 'https://b.com'];
cb(null, allowed.includes(origin));
}
}));
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: { error: 'Too many requests' },
standardHeaders: true,
legacyHeaders: false
});
app.use('/api/', apiLimiter);
// Stricter for auth routes
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5
});
app.use('/auth/login', authLimiter);
const { body, validationResult } = require(
'express-validator'
);
app.post('/users',
body('email').isEmail().normalizeEmail(),
body('name').trim().notEmpty().escape(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty())
return res.status(400).json(
{ errors: errors.array() }
);
}
);
Always validate and sanitise input. Never trust req.body, req.params, or req.query directly.
my-express-app/
├── app.js ← Express setup
├── server.js ← listen() entry
├── package.json
├── .env
├── config/
│ ├── db.js
│ └── index.js ← merged config
├── routes/
│ ├── index.js ← mounts all routers
│ ├── auth.js
│ ├── users.js
│ └── posts.js
├── controllers/
│ ├── authController.js
│ ├── userController.js
│ └── postController.js
├── services/
│ ├── userService.js
│ └── emailService.js
├── models/
│ ├── User.js
│ └── Post.js
├── middleware/
│ ├── auth.js
│ ├── validate.js
│ └── errorHandler.js
├── views/
│ ├── layouts/
│ ├── partials/
│ └── pages/
├── public/
│ ├── css/
│ ├── js/
│ └── images/
└── tests/
├── routes/
└── services/
app.listen() only (testable app)// routes/users.js
router.get('/:id', userController.getById);
// controllers/userController.js
exports.getById = async (req, res, next) => {
try {
const user = await userService
.findById(req.params.id);
res.json(user);
} catch (err) { next(err); }
};
// services/userService.js
exports.findById = (id) =>
User.findById(id).lean();
Export app from app.js and import it in server.js and in tests. This lets you run supertest(app) without starting a real server.
// Express 4 — must wrap or use library
app.get('/data', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
next(err); // manual forwarding
}
});
// Express 5 — just throw!
app.get('/data', async (req, res) => {
const data = await fetchData();
res.json(data);
// Rejected promise → error middleware
// automatically — no wrapper needed
});
// Express 4: loose, regex-like
app.get('/ab?cd', handler);
// matches /acd, /abcd
// Express 5: uses path-to-regexp v8
// — No optional characters with ?
// — Use {param} for optional segments
app.get('/users{/:id}', handler);
// matches /users and /users/42
// Wildcards use * syntax
app.get('/files/*path', handler);
// req.params.path = rest of URL
| Express 4 | Express 5 |
|---|---|
app.del() | Removed — use app.delete() |
req.host | Removed — use req.hostname |
req.param() | Removed — use req.params / req.query |
res.json(status, obj) | Removed — chain .status().json() |
res.send(status) | Removed — use res.sendStatus() |
res.sendFile()npm install express@5
Express 5 reached stable release in 2024 after years in alpha/beta. Migration is straightforward for most apps — the biggest change is path matching syntax.
const express = require('express');
const path = require('path');
const helmet = require('helmet');
const morgan = require('morgan');
const app = express();
// Config
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Middleware
app.use(helmet());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));
app.locals.siteName = 'TaskBoard';
app.locals.year = new Date().getFullYear();
let tasks = [
{ id: 1, title: 'Learn Express',
done: false },
{ id: 2, title: 'Build an API',
done: true },
];
let nextId = 3;
// Views
app.get('/', (req, res) => {
res.render('home', { title: 'Tasks', tasks });
});
// REST API
app.get('/api/tasks', (req, res) => {
res.json(tasks);
});
app.post('/api/tasks', (req, res) => {
const { title } = req.body;
if (!title) return res.status(400)
.json({ error: 'Title required' });
const task = { id: nextId++, title, done: false };
tasks.push(task);
res.status(201).json(task);
});
app.patch('/api/tasks/:id', (req, res) => {
const task = tasks.find(
t => t.id === +req.params.id
);
if (!task) return res.status(404)
.json({ error: 'Not found' });
Object.assign(task, req.body);
res.json(task);
});
app.delete('/api/tasks/:id', (req, res) => {
tasks = tasks.filter(
t => t.id !== +req.params.id
);
res.sendStatus(204);
});
// 404 handler
app.use((req, res) => {
res.status(404).render('404', {
title: 'Not Found'
});
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`TaskBoard running on :${PORT}`);
});
<h1>Task List</h1>
<ul>
<% tasks.forEach(t => { %>
<li class="<%= t.done ? 'done' : '' %>">
<%= t.title %>
</li>
<% }) %>
</ul>
<form method="POST" action="/api/tasks">
<input name="title" placeholder="New task">
<button type="submit">Add</button>
</form>
# List tasks
curl localhost:3000/api/tasks
# Create task
curl -X POST localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Deploy app"}'
# Toggle done
curl -X PATCH localhost:3000/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"done":true}'
# Delete task
curl -X DELETE localhost:3000/api/tasks/2
(err, req, res, next) signatureexpress.Router() for modular route filesdotenv)| Package | Purpose |
|---|---|
express | Web framework |
helmet | Security headers |
cors | Cross-origin resource sharing |
morgan | HTTP request logging |
express-validator | Input validation |