Express.js is the most popular Node.js web framework — minimal, flexible, and used by millions of developers to build web servers, REST APIs, and full-stack applications. If you know basic JavaScript and Node.js, Express is the fastest path to building real backends.
This tutorial takes you from zero to building a complete REST API with authentication, database integration, and production-ready patterns.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Install Express and run your first server |
| Routing | Handle GET, POST, PUT, DELETE requests |
| Middleware | Use and write custom middleware |
| REST APIs | Build a complete CRUD API |
| Templates | Render HTML with EJS |
| Error handling | Handle errors gracefully |
| Authentication | Protect routes with JWT |
| Database | Connect to MongoDB and PostgreSQL |
| Real projects | 3 complete projects |
Why Express.js?
| Express.js | Fastify | Koa | NestJS | Hono | |
|---|---|---|---|---|---|
| Speed | Fast | Fastest | Fast | Fast | Very fast |
| Learning curve | Very low | Low | Low | High | Low |
| Ecosystem | Massive | Growing | Smaller | Large | Growing |
| Opinionated | No | No | No | Yes | No |
| TypeScript | Optional | Optional | Optional | First-class | Optional |
| Best for | General, APIs, prototyping | High-perf APIs | Middleware-heavy | Enterprise, structure | Edge, microservices |
Express dominates because:
- ~60 million npm downloads/week — largest backend Node.js framework
- Minimal core — add only what you need
- Huge ecosystem — thousands of compatible middleware packages
- Job market — required in most Node.js/backend job descriptions
Prerequisites
You need:
- Basic JavaScript (variables, functions, objects, arrays, Promises)
- Node.js installed (v18+ recommended — nodejs.org)
- A terminal and code editor (VS Code recommended)
Verify Node.js is installed:
node --version # v18.x.x or higher
npm --version # 9.x.x or higher
1. Your First Express Server
Installation
mkdir my-express-app
cd my-express-app
npm init -y
npm install express
Hello World
Create index.js:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello, Express!');
});
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});
Run it:
node index.js
Open http://localhost:3000 — you'll see Hello, Express!
Using nodemon for development
Install nodemon to auto-restart on file changes:
npm install --save-dev nodemon
Add to package.json:
{
"scripts": {
"dev": "nodemon index.js",
"start": "node index.js"
}
}
Now run npm run dev and the server restarts automatically.
2. Routing
Routes map HTTP methods and URL paths to handler functions.
Basic routes
const express = require('express');
const app = express();
// GET request
app.get('/', (req, res) => {
res.send('Home page');
});
// POST request
app.post('/users', (req, res) => {
res.send('Create user');
});
// PUT request
app.put('/users/:id', (req, res) => {
res.send(`Update user ${req.params.id}`);
});
// DELETE request
app.delete('/users/:id', (req, res) => {
res.send(`Delete user ${req.params.id}`);
});
app.listen(3000);
Route parameters
// URL: /users/42
app.get('/users/:id', (req, res) => {
const { id } = req.params;
res.json({ userId: id });
});
// Multiple params: /posts/2025/javascript
app.get('/posts/:year/:slug', (req, res) => {
const { year, slug } = req.params;
res.json({ year, slug });
});
Query strings
// URL: /search?q=express&page=2
app.get('/search', (req, res) => {
const { q, page = 1 } = req.query;
res.json({ query: q, page: Number(page) });
});
express.Router
Organize routes into separate files:
// routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => res.json({ users: [] }));
router.post('/', (req, res) => res.json({ message: 'User created' }));
router.get('/:id', (req, res) => res.json({ id: req.params.id }));
router.put('/:id', (req, res) => res.json({ message: 'User updated' }));
router.delete('/:id', (req, res) => res.json({ message: 'User deleted' }));
module.exports = router;
// index.js
const express = require('express');
const usersRouter = require('./routes/users');
const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);
app.listen(3000);
HTTP methods cheat sheet
| Method | Purpose | Body | Idempotent |
|---|---|---|---|
| GET | Read resource | No | Yes |
| POST | Create resource | Yes | No |
| PUT | Replace resource | Yes | Yes |
| PATCH | Partial update | Yes | Yes |
| DELETE | Remove resource | No | Yes |
3. Middleware
Middleware are functions that run between the request and response. They have access to req, res, and next.
Request → Middleware 1 → Middleware 2 → Route Handler → Response
Built-in middleware
const express = require('express');
const app = express();
// Parse JSON body
app.use(express.json());
// Parse URL-encoded forms
app.use(express.urlencoded({ extended: true }));
// Serve static files from 'public' directory
app.use(express.static('public'));
Writing custom middleware
// Logging middleware
const logger = (req, res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
next(); // MUST call next() to continue
};
// Apply globally
app.use(logger);
// Apply to specific route
app.get('/protected', logger, (req, res) => {
res.send('Protected route');
});
Middleware execution order
app.use((req, res, next) => {
console.log('Middleware 1');
next();
});
app.use((req, res, next) => {
console.log('Middleware 2');
next();
});
app.get('/', (req, res) => {
console.log('Route handler');
res.send('Done');
});
// Output: Middleware 1 → Middleware 2 → Route handler
Popular middleware packages
| Package | Purpose | Install |
|---|---|---|
cors |
Enable CORS | npm i cors |
helmet |
Security headers | npm i helmet |
morgan |
HTTP request logging | npm i morgan |
compression |
Gzip responses | npm i compression |
express-rate-limit |
Rate limiting | npm i express-rate-limit |
cookie-parser |
Parse cookies | npm i cookie-parser |
multer |
File uploads | npm i multer |
express-validator |
Input validation | npm i express-validator |
Using third-party middleware:
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
app.use(cors());
app.use(helmet());
app.use(morgan('dev'));
4. Request and Response Objects
Request object (req)
app.post('/example', (req, res) => {
req.params // URL params: { id: '42' }
req.query // Query string: { page: '2' }
req.body // Request body (needs express.json())
req.headers // HTTP headers
req.method // 'GET', 'POST', etc.
req.url // '/example'
req.ip // Client IP
req.cookies // Cookies (needs cookie-parser)
req.get('Authorization') // Get specific header
});
Response object (res)
app.get('/demo', (req, res) => {
// Send text
res.send('Hello');
// Send JSON
res.json({ message: 'ok' });
// Set status code
res.status(201).json({ created: true });
// Send HTML
res.send('<h1>Hello</h1>');
// Redirect
res.redirect('/new-url');
res.redirect(301, '/permanent-url');
// Set header
res.set('Content-Type', 'text/plain');
res.set('X-Custom-Header', 'value');
// Set cookie
res.cookie('session', 'abc123', { httpOnly: true, secure: true });
// Clear cookie
res.clearCookie('session');
// Send file
res.sendFile('/absolute/path/to/file.html');
// Download file
res.download('/path/to/file.pdf', 'report.pdf');
});
Common status codes
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid input |
| 401 | Unauthorized | Not logged in |
| 403 | Forbidden | Logged in but no permission |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource |
| 422 | Unprocessable Entity | Validation error |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
5. Handling Request Body
app.use(express.json()); // Required for JSON bodies
app.post('/users', (req, res) => {
const { name, email, age } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
// Process the data...
res.status(201).json({ message: 'User created', user: { name, email, age } });
});
Test with curl:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com", "age": 30}'
Input validation with express-validator
npm install express-validator
const { body, validationResult } = require('express-validator');
app.post('/users',
// Validation rules
body('email').isEmail().normalizeEmail(),
body('name').notEmpty().trim().isLength({ min: 2, max: 50 }),
body('age').optional().isInt({ min: 0, max: 120 }),
// Handler
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
const { name, email, age } = req.body;
res.status(201).json({ message: 'User created', user: { name, email, age } });
}
);
6. Error Handling
Synchronous errors
app.get('/error', (req, res, next) => {
try {
throw new Error('Something went wrong');
} catch (err) {
next(err); // Pass to error handler
}
});
Async errors (Express 5 or manual)
// Express 4: wrap async handlers
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
}));
Global error handler
// Must have 4 parameters: (err, req, res, next)
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
console.error(`[${new Date().toISOString()}] ${status} - ${message}`);
res.status(status).json({
error: {
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
},
});
});
404 handler (place before error handler)
app.use((req, res, next) => {
res.status(404).json({ error: `Route ${req.method} ${req.url} not found` });
});
Custom error class
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.status = statusCode;
this.name = 'AppError';
}
}
// Usage
app.get('/items/:id', asyncHandler(async (req, res) => {
const item = await findItem(req.params.id);
if (!item) throw new AppError('Item not found', 404);
res.json(item);
}));
7. REST API — Complete CRUD Example
A full in-memory notes API:
const express = require('express');
const app = express();
app.use(express.json());
// In-memory store
let notes = [
{ id: 1, title: 'First note', body: 'Hello world', createdAt: new Date().toISOString() },
];
let nextId = 2;
// GET all notes
app.get('/api/notes', (req, res) => {
const { search } = req.query;
let result = notes;
if (search) {
const term = search.toLowerCase();
result = notes.filter(n =>
n.title.toLowerCase().includes(term) ||
n.body.toLowerCase().includes(term)
);
}
res.json({ notes: result, total: result.length });
});
// GET single note
app.get('/api/notes/:id', (req, res) => {
const id = Number(req.params.id);
const note = notes.find(n => n.id === id);
if (!note) return res.status(404).json({ error: 'Note not found' });
res.json(note);
});
// POST create note
app.post('/api/notes', (req, res) => {
const { title, body } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const note = { id: nextId++, title, body: body || '', createdAt: new Date().toISOString() };
notes.push(note);
res.status(201).json(note);
});
// PUT update note
app.put('/api/notes/:id', (req, res) => {
const id = Number(req.params.id);
const index = notes.findIndex(n => n.id === id);
if (index === -1) return res.status(404).json({ error: 'Note not found' });
const { title, body } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
notes[index] = { ...notes[index], title, body: body || '', updatedAt: new Date().toISOString() };
res.json(notes[index]);
});
// PATCH partial update
app.patch('/api/notes/:id', (req, res) => {
const id = Number(req.params.id);
const index = notes.findIndex(n => n.id === id);
if (index === -1) return res.status(404).json({ error: 'Note not found' });
const updates = {};
if (req.body.title !== undefined) updates.title = req.body.title;
if (req.body.body !== undefined) updates.body = req.body.body;
notes[index] = { ...notes[index], ...updates, updatedAt: new Date().toISOString() };
res.json(notes[index]);
});
// DELETE note
app.delete('/api/notes/:id', (req, res) => {
const id = Number(req.params.id);
const index = notes.findIndex(n => n.id === id);
if (index === -1) return res.status(404).json({ error: 'Note not found' });
notes.splice(index, 1);
res.status(204).send();
});
// Error handler
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message || 'Server error' });
});
app.listen(3000, () => console.log('Notes API at http://localhost:3000'));
Test the API:
# Get all notes
curl http://localhost:3000/api/notes
# Create note
curl -X POST http://localhost:3000/api/notes \
-H "Content-Type: application/json" \
-d '{"title": "My note", "body": "Note content"}'
# Update note
curl -X PUT http://localhost:3000/api/notes/1 \
-H "Content-Type: application/json" \
-d '{"title": "Updated", "body": "New content"}'
# Delete note
curl -X DELETE http://localhost:3000/api/notes/1
# Search
curl "http://localhost:3000/api/notes?search=hello"
8. Serving Templates with EJS
For server-rendered HTML:
npm install ejs
const express = require('express');
const path = require('path');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
const todos = [
{ id: 1, text: 'Learn Express', done: true },
{ id: 2, text: 'Build an API', done: false },
];
app.get('/', (req, res) => {
res.render('index', {
title: 'My Todo App',
todos,
count: todos.filter(t => !t.done).length,
});
});
app.listen(3000);
views/index.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
<p><%= count %> task(s) remaining</p>
<ul>
<% todos.forEach(todo => { %>
<li style="<%= todo.done ? 'text-decoration: line-through' : '' %>">
<%= todo.text %>
</li>
<% }) %>
</ul>
</body>
</html>
EJS syntax
| Syntax | Purpose | Example |
|---|---|---|
<%= value %> |
Output escaped HTML | <%= user.name %> |
<%- value %> |
Output raw HTML | <%- htmlContent %> |
<% code %> |
Execute JS (no output) | <% if (user) { %> |
<%# comment %> |
EJS comment | <%# this is hidden %> |
<%- include('partial') %> |
Include partial | <%- include('header') %> |
9. Authentication with JWT
npm install jsonwebtoken bcryptjs
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-prod';
// Mock user store (use a real database in production)
const users = [];
// Register
app.post('/api/auth/register', async (req, res) => {
const { email, password, name } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
if (users.find(u => u.email === email)) {
return res.status(409).json({ error: 'Email already registered' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = { id: users.length + 1, email, name, password: hashedPassword };
users.push(user);
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
expiresIn: '7d',
});
res.status(201).json({ token, user: { id: user.id, email, name } });
});
// Login
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
expiresIn: '7d',
});
res.json({ token, user: { id: user.id, email: user.email, name: user.name } });
});
// Auth middleware
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
// Protected route
app.get('/api/me', authenticate, (req, res) => {
const user = users.find(u => u.id === req.user.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
const { password, ...safeUser } = user;
res.json(safeUser);
});
app.listen(3000);
Test:
# Register
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "secret123", "name": "Alice"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "secret123"}'
# Access protected route (replace TOKEN with the token from login)
curl http://localhost:3000/api/me \
-H "Authorization: Bearer TOKEN"
10. Connecting to a Database
MongoDB with Mongoose
npm install mongoose
const express = require('express');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/myapp')
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB error:', err));
// Define schema
const postSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
body: { type: String, required: true },
author: { type: String, default: 'Anonymous' },
published: { type: Boolean, default: false },
}, { timestamps: true });
const Post = mongoose.model('Post', postSchema);
// Routes
app.get('/api/posts', async (req, res) => {
const posts = await Post.find({ published: true }).sort({ createdAt: -1 });
res.json(posts);
});
app.post('/api/posts', async (req, res) => {
try {
const post = new Post(req.body);
await post.save();
res.status(201).json(post);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.get('/api/posts/:id', async (req, res) => {
try {
const post = await Post.findById(req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
res.json(post);
} catch {
res.status(400).json({ error: 'Invalid ID' });
}
});
app.delete('/api/posts/:id', async (req, res) => {
const post = await Post.findByIdAndDelete(req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
res.status(204).send();
});
app.listen(3000);
PostgreSQL with pg
npm install pg
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://localhost/myapp',
});
// Create table (run once)
pool.query(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// GET all todos
app.get('/api/todos', async (req, res) => {
const result = await pool.query('SELECT * FROM todos ORDER BY created_at DESC');
res.json(result.rows);
});
// POST create todo
app.post('/api/todos', async (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'Title required' });
const result = await pool.query(
'INSERT INTO todos (title) VALUES ($1) RETURNING *',
[title]
);
res.status(201).json(result.rows[0]);
});
// PATCH toggle done
app.patch('/api/todos/:id', async (req, res) => {
const result = await pool.query(
'UPDATE todos SET done = NOT done WHERE id = $1 RETURNING *',
[req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Not found' });
res.json(result.rows[0]);
});
11. Project Structure
A well-organized Express project:
my-api/
├── src/
│ ├── routes/
│ │ ├── users.js
│ │ ├── posts.js
│ │ └── index.js # Combine all routes
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── errorHandler.js
│ │ └── validate.js
│ ├── models/
│ │ ├── User.js
│ │ └── Post.js
│ ├── controllers/
│ │ ├── userController.js
│ │ └── postController.js
│ ├── services/
│ │ └── emailService.js
│ ├── config/
│ │ └── database.js
│ └── app.js # Express setup (no listen)
├── index.js # Entry point (listen here)
├── .env
├── .env.example
├── .gitignore
└── package.json
src/app.js:
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const routes = require('./routes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api', routes);
// 404
app.use((req, res) => {
res.status(404).json({ error: `${req.method} ${req.url} not found` });
});
// Error handler (must be last)
app.use(errorHandler);
module.exports = app;
index.js:
const app = require('./src/app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
12. Environment Variables
Never hardcode secrets. Use .env files:
npm install dotenv
.env:
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://localhost/mydb
JWT_SECRET=your-super-secret-key-change-this
CORS_ORIGIN=http://localhost:5173
.gitignore:
node_modules/
.env
*.log
Load in index.js (first line):
require('dotenv').config();
const app = require('./src/app');
const PORT = process.env.PORT || 3000;
app.listen(PORT);
Access anywhere:
const secret = process.env.JWT_SECRET;
const dbUrl = process.env.DATABASE_URL;
3 Projects
Project 1: Todo REST API
A simple but complete REST API with filtering and persistence:
// todo-api/index.js
require('dotenv').config();
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
app.use(express.json());
const DATA_FILE = path.join(__dirname, 'todos.json');
const load = () => {
if (!fs.existsSync(DATA_FILE)) return [];
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
};
const save = (todos) => {
fs.writeFileSync(DATA_FILE, JSON.stringify(todos, null, 2));
};
let todos = load();
let nextId = todos.length ? Math.max(...todos.map(t => t.id)) + 1 : 1;
// GET all (with optional filter)
app.get('/api/todos', (req, res) => {
const { done, search } = req.query;
let result = todos;
if (done !== undefined) {
result = result.filter(t => t.done === (done === 'true'));
}
if (search) {
const term = search.toLowerCase();
result = result.filter(t => t.title.toLowerCase().includes(term));
}
res.json({
todos: result,
total: result.length,
pending: todos.filter(t => !t.done).length,
});
});
// POST create
app.post('/api/todos', (req, res) => {
const { title, priority = 'medium' } = req.body;
if (!title?.trim()) {
return res.status(400).json({ error: 'Title required' });
}
const todo = {
id: nextId++,
title: title.trim(),
done: false,
priority,
createdAt: new Date().toISOString(),
};
todos.push(todo);
save(todos);
res.status(201).json(todo);
});
// PATCH toggle/update
app.patch('/api/todos/:id', (req, res) => {
const id = Number(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) return res.status(404).json({ error: 'Not found' });
if (req.body.done !== undefined) todo.done = req.body.done;
if (req.body.title) todo.title = req.body.title;
todo.updatedAt = new Date().toISOString();
save(todos);
res.json(todo);
});
// DELETE
app.delete('/api/todos/:id', (req, res) => {
const id = Number(req.params.id);
const index = todos.findIndex(t => t.id === id);
if (index === -1) return res.status(404).json({ error: 'Not found' });
todos.splice(index, 1);
save(todos);
res.status(204).send();
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Todo API on http://localhost:${PORT}`));
Project 2: Blog API with Authentication
Full CRUD blog API with JWT auth and user ownership:
// blog-api/index.js
require('dotenv').config();
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET || 'dev-secret';
// In-memory stores
const users = [];
const posts = [];
let userId = 1;
let postId = 1;
// Auth middleware
const auth = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
// Auth routes
app.post('/api/register', async (req, res) => {
const { email, password, name } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
if (users.find(u => u.email === email)) return res.status(409).json({ error: 'Email taken' });
const user = { id: userId++, email, name, password: await bcrypt.hash(password, 10) };
users.push(user);
const token = jwt.sign({ id: user.id, email }, SECRET, { expiresIn: '7d' });
res.status(201).json({ token, user: { id: user.id, email, name } });
});
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, email }, SECRET, { expiresIn: '7d' });
res.json({ token, user: { id: user.id, email, name: user.name } });
});
// Post routes
app.get('/api/posts', (req, res) => {
res.json(posts.map(p => ({ ...p, body: p.body.slice(0, 200) + '...' })));
});
app.get('/api/posts/:id', (req, res) => {
const post = posts.find(p => p.id === Number(req.params.id));
if (!post) return res.status(404).json({ error: 'Not found' });
res.json(post);
});
app.post('/api/posts', auth, (req, res) => {
const { title, body } = req.body;
if (!title || !body) return res.status(400).json({ error: 'Title and body required' });
const post = { id: postId++, title, body, authorId: req.user.id, createdAt: new Date().toISOString() };
posts.push(post);
res.status(201).json(post);
});
app.put('/api/posts/:id', auth, (req, res) => {
const post = posts.find(p => p.id === Number(req.params.id));
if (!post) return res.status(404).json({ error: 'Not found' });
if (post.authorId !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
const { title, body } = req.body;
if (title) post.title = title;
if (body) post.body = body;
post.updatedAt = new Date().toISOString();
res.json(post);
});
app.delete('/api/posts/:id', auth, (req, res) => {
const index = posts.findIndex(p => p.id === Number(req.params.id));
if (index === -1) return res.status(404).json({ error: 'Not found' });
if (posts[index].authorId !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
posts.splice(index, 1);
res.status(204).send();
});
app.listen(3000, () => console.log('Blog API on http://localhost:3000'));
Project 3: File Upload API
Handle image uploads with multer:
npm install multer sharp
// upload-api/index.js
const express = require('express');
const multer = require('multer');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const app = express();
const UPLOAD_DIR = path.join(__dirname, 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// Store files in memory, process with sharp
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, and WebP are allowed'));
}
},
});
app.post('/api/upload', upload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.webp`;
const outputPath = path.join(UPLOAD_DIR, filename);
await sharp(req.file.buffer)
.resize(800, 800, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(outputPath);
const stats = fs.statSync(outputPath);
res.json({
filename,
url: `/uploads/${filename}`,
originalSize: req.file.size,
processedSize: stats.size,
savedPercent: Math.round((1 - stats.size / req.file.size) * 100),
});
});
// Serve uploaded files
app.use('/uploads', express.static(UPLOAD_DIR));
// Handle multer errors
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large (max 5MB)' });
}
}
res.status(400).json({ error: err.message });
});
app.listen(3000, () => console.log('Upload API on http://localhost:3000'));
Test:
curl -X POST http://localhost:3000/api/upload \
-F "image=@/path/to/photo.jpg"
Express vs Related Terms
| Term | What it is |
|---|---|
| Express | Minimal Node.js web framework |
| Koa | Next-gen framework from Express team, uses async/await natively |
| Fastify | Faster Express alternative, schema-based validation |
| NestJS | Opinionated framework built on Express, Angular-like structure |
| Hono | Ultra-fast, edge-compatible framework |
| middleware | Function that processes req/res before route handler |
| router | Express module for organizing routes in groups |
| EJS | Embedded JavaScript templating engine |
| Pug | Another Express-compatible template engine (formerly Jade) |
| multer | Middleware for handling multipart/form-data (file uploads) |
| cors | Cross-Origin Resource Sharing middleware |
| helmet | Security middleware that sets HTTP headers |
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Forgetting express.json() |
req.body is undefined |
Add app.use(express.json()) before routes |
| No error handler with 4 params | Errors not caught globally | Use (err, req, res, next) signature |
Not calling next(err) in async |
Unhandled promise rejections | Wrap async handlers or use asyncHandler |
| Sending response twice | Cannot set headers after they are sent |
Add return before res.send() / res.json() |
Committing .env |
Secrets exposed | Add .env to .gitignore |
app.use() after routes |
Middleware never runs | Register middleware before route handlers |
| No 404 handler | Requests to unknown routes hang or return odd responses | Add app.use((req, res) => res.status(404).json(...)) |
| Hardcoded secrets | Security vulnerability | Use process.env.SECRET + .env |
Learning Path
| Stage | Topics | Resources |
|---|---|---|
| 1. Basics | Routing, req/res, static files | This guide + Express docs |
| 2. Middleware | Custom middleware, error handling, popular packages | Express docs |
| 3. REST APIs | CRUD, status codes, JSON, validation | Build the projects above |
| 4. Auth | JWT, sessions, OAuth | passport.js, jsonwebtoken docs |
| 5. Databases | MongoDB/Mongoose or PostgreSQL/pg or Prisma | Respective docs |
| 6. Testing | Jest + Supertest for API testing | Supertest docs |
| 7. Production | Rate limiting, logging, env config, Docker | Morgan, helmet, docker docs |
Express vs Other Node.js Frameworks
| Express | Fastify | Koa | NestJS | Hono | |
|---|---|---|---|---|---|
| Weekly downloads | ~60M | ~8M | ~3M | ~5M | ~2M |
| Bundle size | Small | Small | Small | Large | Tiny |
| Performance | Good | Best | Good | Good | Excellent |
| Learning curve | Very Low | Low | Low | High | Low |
| TypeScript | Optional | Optional | Optional | First-class | Optional |
| Structure | Unopinionated | Unopinionated | Unopinionated | MVC / DI | Unopinionated |
| Middleware | Custom API | Plugin API | async functions | Guards/Interceptors | async functions |
| Best for | General APIs, learning, prototyping | High-perf APIs | Minimal APIs | Enterprise apps | Edge functions |
6 FAQ
Q: Do I need to know Node.js before learning Express?
Basic Node.js — modules, npm, require, basic async — is enough to start. You'll deepen Node.js knowledge while learning Express.
Q: Should I use Express or NestJS for a new project?
Express for prototypes, simple APIs, or when you want full control. NestJS if you're building a large-scale application and want Angular-like structure with decorators and dependency injection.
Q: How do I handle file uploads?
Use the multer middleware. See Project 3 above. For large-scale file handling, upload to cloud storage (S3, Cloudinary) instead of local disk.
Q: Is Express still relevant in 2025?
Yes — Express has ~60M weekly downloads and is used in production by countless companies. Newer frameworks are faster, but Express's ecosystem, documentation, and job-market presence remain dominant. Express 5 (with native async error handling) was also released.
Q: How do I deploy an Express app?
Common options: Render, Railway, Heroku (paid), Fly.io, or a VPS with PM2 + Nginx. Docker works on any platform. Run node index.js in production — or use a process manager like PM2 for auto-restart.
Q: REST API vs GraphQL — which should I build with Express?
REST for most APIs — simpler, better cache support, widely understood. GraphQL when clients need to fetch exactly the data shape they want (common in large frontends with many data types). You can use Express with either via express-graphql or @apollo/server.