Module 04: Express.js & REST APIs
Goal: Master Express.js and learn how to build production-ready REST APIs. Time: 2-3 days of focused study Prerequisites: Module 01-03
Table of Contents
- HTTP Fundamentals
- What is Express.js?
- Routing
- Middleware — The Core Concept
- Request & Response Objects
- Error Handling
- REST API Design
- Input Validation
- File Uploads
- Serving Static Files
- CORS
- Rate Limiting
- Project Structure
- Real-World API Example
- Interview Questions
1. HTTP Fundamentals
Before Express, you need to understand HTTP.
HTTP Methods
Method │ Purpose │ Idempotent │ Safe │ Request Body
────────┼──────────────────────┼────────────┼──────┼─────────────
GET │ Retrieve resource │ ✅ Yes │ ✅ │ No
POST │ Create resource │ ❌ No │ ❌ │ Yes
PUT │ Replace resource │ ✅ Yes │ ❌ │ Yes
PATCH │ Partial update │ ❌ No │ ❌ │ Yes
DELETE │ Delete resource │ ✅ Yes │ ❌ │ Optional
HEAD │ Like GET, no body │ ✅ Yes │ ✅ │ No
OPTIONS │ Supported methods │ ✅ Yes │ ✅ │ No
Idempotent = same request multiple times has same effect
Safe = doesn't modify the server stateHTTP Status Codes
1xx — Informational
100 Continue
2xx — Success
200 OK — Standard success
201 Created — Resource created (after POST)
204 No Content — Success, no body (after DELETE)
3xx — Redirection
301 Moved Permanently — URL changed permanently
302 Found — Temporary redirect
304 Not Modified — Use cached version
4xx — Client Error
400 Bad Request — Invalid input
401 Unauthorized — Not authenticated
403 Forbidden — Authenticated but not authorized
404 Not Found — Resource doesn't exist
405 Method Not Allowed — Wrong HTTP method
409 Conflict — Resource already exists
422 Unprocessable Entity — Validation failed
429 Too Many Requests — Rate limited
5xx — Server Error
500 Internal Server Error — Generic server error
502 Bad Gateway — Upstream server error
503 Service Unavailable — Server temporarily down
504 Gateway Timeout — Upstream timeoutHTTP Headers
Request Headers:
Content-Type: application/json — Body format
Authorization: Bearer <token> — Authentication
Accept: application/json — Expected response format
User-Agent: Mozilla/5.0... — Client info
Cookie: sessionId=abc123 — Cookies
Response Headers:
Content-Type: application/json — Body format
Set-Cookie: sessionId=abc123 — Set browser cookie
Cache-Control: max-age=3600 — Caching instructions
Access-Control-Allow-Origin: * — CORS
X-RateLimit-Remaining: 99 — Custom headers (X- prefix)2. What is Express.js?
Express.js is a minimal, flexible web framework for Node.js. It provides:
- Routing (URL → handler mapping)
- Middleware system (request/response pipeline)
- Template engine support
- Static file serving
const express = require('express');
const app = express();
// Built-in middleware for parsing JSON bodies
app.use(express.json());
// Simple route
app.get('/', (req, res) => {
res.json({ message: 'Hello, World!' });
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});3. Routing
Basic Routing
const express = require('express');
const app = express();
// Route = HTTP method + URL path + handler function
app.get('/users', (req, res) => {
res.json({ users: [] }); // GET /users
});
app.post('/users', (req, res) => {
res.status(201).json({ user: req.body }); // POST /users
});
app.put('/users/:id', (req, res) => {
res.json({ updated: req.params.id }); // PUT /users/123
});
app.patch('/users/:id', (req, res) => {
res.json({ patched: req.params.id }); // PATCH /users/123
});
app.delete('/users/:id', (req, res) => {
res.status(204).send(); // DELETE /users/123
});
// All methods
app.all('/secret', (req, res) => {
res.send('Any method works here');
});Route Parameters
// Parameters in URL path (required)
app.get('/users/:userId', (req, res) => {
console.log(req.params.userId); // "123" for /users/123
res.json({ userId: req.params.userId });
});
// Multiple parameters
app.get('/users/:userId/posts/:postId', (req, res) => {
console.log(req.params); // { userId: "1", postId: "5" }
});
// Optional parameter pattern
app.get('/users/:userId/posts/:postId?', (req, res) => {
// postId is optional — matches /users/1 AND /users/1/posts/5
});
// Query parameters (from URL: /search?q=hello&page=2)
app.get('/search', (req, res) => {
console.log(req.query.q); // "hello"
console.log(req.query.page); // "2" (always strings!)
console.log(req.query); // { q: "hello", page: "2" }
});Router — Modular Routes
// routes/users.js
const express = require('express');
const router = express.Router();
// These routes are relative to where the router is mounted
router.get('/', (req, res) => {
res.json({ users: [] });
});
router.get('/:id', (req, res) => {
res.json({ user: { id: req.params.id } });
});
router.post('/', (req, res) => {
res.status(201).json({ user: req.body });
});
module.exports = router;
// app.js
const express = require('express');
const userRoutes = require('./routes/users');
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes); // Mount at /api/users
// Now these work:
// GET /api/users → router's '/' handler
// GET /api/users/123 → router's '/:id' handler
// POST /api/users → router's '/' handlerRoute Chaining
// Chain methods on the same path
app.route('/users')
.get((req, res) => {
res.json({ users: [] });
})
.post((req, res) => {
res.status(201).json({ user: req.body });
});
app.route('/users/:id')
.get((req, res) => res.json({ user: {} }))
.put((req, res) => res.json({ updated: true }))
.delete((req, res) => res.status(204).send());4. Middleware — The Core Concept
Middleware functions are functions that have access to req, res, and next. They execute in order and form a pipeline.
Request → Middleware 1 → Middleware 2 → ... → Route Handler → Response
│ │
▼ ▼
(can modify (can modify
req/res or req/res or
end request) end request)Types of Middleware
const express = require('express');
const app = express();
// 1. APPLICATION-LEVEL MIDDLEWARE — runs on every request
app.use((req, res, next) => {
console.log(`${req.method} ${req.url} — ${new Date().toISOString()}`);
next(); // MUST call next() to pass to next middleware/route
});
// 2. ROUTE-SPECIFIC MIDDLEWARE — runs only on specific routes
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
// Verify token (we'll learn JWT in Module 05)
req.user = { id: 1, name: 'Alice' }; // Attach user to request
next();
}
app.get('/profile', authenticate, (req, res) => {
res.json({ user: req.user }); // Only runs if authenticate calls next()
});
// Multiple middleware on one route
app.post('/admin/users',
authenticate, // Check if logged in
requireRole('admin'), // Check if admin
validateBody(schema), // Validate input
(req, res) => { // Finally, the route handler
res.status(201).json({ user: req.body });
}
);
// 3. BUILT-IN MIDDLEWARE
app.use(express.json()); // Parse JSON body
app.use(express.urlencoded({ extended: true })); // Parse form data
app.use(express.static('public')); // Serve static files
// 4. THIRD-PARTY MIDDLEWARE
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
app.use(cors()); // Enable CORS
app.use(helmet()); // Security headers
app.use(morgan('combined')); // HTTP request logging
app.use(compression()); // Gzip compression
// 5. ERROR-HANDLING MIDDLEWARE — has 4 parameters (err, req, res, next)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.statusCode || 500).json({
error: err.message || 'Internal Server Error'
});
});How next() Works
// next() passes control to the NEXT middleware/route handler
app.use((req, res, next) => {
console.log('1. First middleware');
next(); // Go to next middleware
console.log('5. Back in first middleware (after everything)');
});
app.use((req, res, next) => {
console.log('2. Second middleware');
next(); // Go to route handler
console.log('4. Back in second middleware');
});
app.get('/', (req, res) => {
console.log('3. Route handler');
res.send('Hello');
});
// Output for GET /:
// 1. First middleware
// 2. Second middleware
// 3. Route handler
// 4. Back in second middleware
// 5. Back in first middleware
// next('route') — skip remaining middleware on this route
app.get('/user/:id',
(req, res, next) => {
if (req.params.id === '0') {
next('route'); // Skip to next app.get('/user/:id') handler
} else {
next(); // Continue to the next middleware in this chain
}
},
(req, res) => {
res.send('Regular user');
}
);
app.get('/user/:id', (req, res) => {
res.send('Special user (id: 0)');
});
// next(error) — jump to error handling middleware
app.get('/risky', (req, res, next) => {
try {
throw new Error('Something broke!');
} catch (err) {
next(err); // Skips all remaining middleware, goes to error handler
}
});Building Real Middleware
// 1. Request Timer
function requestTimer(req, res, next) {
const start = process.hrtime.bigint();
res.on('finish', () => {
const end = process.hrtime.bigint();
const durationMs = Number(end - start) / 1e6;
console.log(`${req.method} ${req.url} — ${res.statusCode} — ${durationMs.toFixed(2)}ms`);
});
next();
}
// 2. Request ID (for tracking in logs)
const crypto = require('crypto');
function requestId(req, res, next) {
req.id = req.headers['x-request-id'] || crypto.randomUUID();
res.setHeader('X-Request-ID', req.id);
next();
}
// 3. API Key Authentication
function apiKeyAuth(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
if (apiKey !== process.env.API_KEY) {
return res.status(403).json({ error: 'Invalid API key' });
}
next();
}
// 4. Role-based access control (factory pattern!)
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage:
app.delete('/users/:id', authenticate, requireRole('admin'), deleteUser);5. Request & Response Objects
Request Object (req)
app.post('/api/users', (req, res) => {
// ---- URL INFORMATION ----
req.url; // '/api/users?sort=name'
req.originalUrl; // '/api/users?sort=name' (preserves original)
req.path; // '/api/users'
req.method; // 'POST'
req.protocol; // 'http' or 'https'
req.hostname; // 'localhost'
req.ip; // '127.0.0.1'
req.baseUrl; // '/api' (if using router mounted at /api)
// ---- PARAMETERS ----
req.params; // Route params: { id: '123' }
req.query; // Query string: { sort: 'name', limit: '10' }
req.body; // Parsed body (needs express.json() middleware)
// ---- HEADERS ----
req.headers; // All headers (lowercase keys)
req.headers['content-type']; // 'application/json'
req.get('Content-Type'); // Same thing (case-insensitive)
req.headers.authorization; // 'Bearer token123'
// ---- COOKIES (needs cookie-parser middleware) ----
req.cookies; // { sessionId: 'abc123' }
req.signedCookies; // { authToken: 'xyz' }
// ---- CHECKS ----
req.is('json'); // Check Content-Type: 'json' or false
req.accepts('json'); // Check Accept header
req.xhr; // Was it an AJAX request?
req.secure; // Is HTTPS?
req.fresh; // Is the response still "fresh" (caching)?
});Response Object (res)
app.get('/api/demo', (req, res) => {
// ---- SENDING RESPONSES ----
res.send('Hello'); // Send string (auto Content-Type)
res.json({ hello: 'world' }); // Send JSON (sets Content-Type)
res.status(201).json({ id: 1 }); // Set status + JSON
res.sendStatus(204); // Send just status code
res.end(); // End response without data
// ---- HEADERS ----
res.set('X-Custom', 'value'); // Set header
res.set({ // Set multiple headers
'X-Custom': 'value',
'Cache-Control': 'no-store'
});
res.get('Content-Type'); // Get response header
// ---- COOKIES ----
res.cookie('sessionId', 'abc123', {
httpOnly: true, // Can't be accessed by JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour in milliseconds
});
res.clearCookie('sessionId');
// ---- REDIRECTS ----
res.redirect('/login'); // 302 redirect (temporary)
res.redirect(301, '/new-url'); // 301 redirect (permanent)
// ---- FILES ----
res.sendFile('/absolute/path/to/file.pdf');
res.download('/path/to/file.pdf', 'report.pdf'); // Force download
// ---- STREAMING ----
res.type('text/event-stream'); // For SSE (Server-Sent Events)
res.write('data: hello\n\n'); // Write chunk
// Don't call res.end() until you're done streaming
});6. Error Handling
The Async Handler Pattern
// Problem: Express doesn't catch errors in async route handlers!
app.get('/users', async (req, res) => {
const users = await User.find(); // If this throws, Express WON'T catch it!
res.json(users);
});
// Solution 1: try/catch in every route (tedious)
app.get('/users', async (req, res, next) => {
try {
const users = await User.find();
res.json(users);
} catch (err) {
next(err); // Pass to error handler
}
});
// Solution 2: asyncHandler wrapper (recommended)
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);
}));
// Now if User.find() throws, the error goes to the error handler automatically!
// Note: Express 5 (currently in beta) handles this natively!Custom Error Classes
// errors.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message, errors = []) {
super(message, 400);
this.errors = errors;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401);
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403);
}
}
module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError, ForbiddenError };Centralized Error Handler
// This MUST be the LAST app.use() — after all routes
app.use((err, req, res, next) => {
// Log the error
console.error(`[${req.id}] Error:`, err);
// Operational errors (expected — bad input, not found, etc.)
if (err.isOperational) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
...(err.errors && { errors: err.errors }), // Validation errors
});
}
// Programming errors (unexpected — bugs, crashes)
// Don't leak details to client
res.status(500).json({
status: 'error',
message: process.env.NODE_ENV === 'production'
? 'Something went wrong'
: err.message,
});
});
// Usage in routes:
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));7. REST API Design
Resource-Based URLs
✅ Good (noun-based, resource-centric):
GET /api/users — List all users
POST /api/users — Create a user
GET /api/users/123 — Get user 123
PUT /api/users/123 — Replace user 123
PATCH /api/users/123 — Update user 123
DELETE /api/users/123 — Delete user 123
GET /api/users/123/posts — Get posts by user 123
POST /api/users/123/posts — Create a post for user 123
❌ Bad (verb-based, action-centric):
GET /api/getUsers
POST /api/createUser
GET /api/getUserById?id=123
POST /api/deleteUserPagination, Filtering, Sorting
// GET /api/users?page=2&limit=20&sort=-createdAt&role=admin&search=alice
app.get('/api/users', asyncHandler(async (req, res) => {
const {
page = 1,
limit = 20,
sort = '-createdAt', // - prefix = descending
role,
search,
} = req.query;
// Build filter
const filter = {};
if (role) filter.role = role;
if (search) filter.name = { $regex: search, $options: 'i' };
// Parse sort
const sortField = sort.startsWith('-') ? sort.slice(1) : sort;
const sortOrder = sort.startsWith('-') ? -1 : 1;
// Query with pagination
const skip = (parseInt(page) - 1) * parseInt(limit);
const [users, total] = await Promise.all([
User.find(filter)
.sort({ [sortField]: sortOrder })
.skip(skip)
.limit(parseInt(limit)),
User.countDocuments(filter)
]);
res.json({
data: users,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit)),
hasNext: skip + users.length < total,
hasPrev: parseInt(page) > 1,
}
});
}));Standard API Response Format
// Consistent response structure
// Success response
{
"status": "success",
"data": { ... },
"pagination": { ... } // Only for list endpoints
}
// Error response
{
"status": "error",
"message": "User not found",
"code": "NOT_FOUND",
"errors": [ // For validation errors
{ "field": "email", "message": "Invalid email format" }
]
}
// Helper functions
function sendSuccess(res, data, statusCode = 200) {
res.status(statusCode).json({
status: 'success',
data,
});
}
function sendPaginated(res, data, pagination) {
res.json({
status: 'success',
data,
pagination,
});
}8. Input Validation
Using Joi (Popular Validation Library)
// npm install joi
const Joi = require('joi');
// Define schemas
const schemas = {
createUser: Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128)
.pattern(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)/)
.message('Password must have uppercase, lowercase, and number')
.required(),
age: Joi.number().integer().min(13).max(120).optional(),
role: Joi.string().valid('user', 'admin').default('user'),
}),
updateUser: Joi.object({
name: Joi.string().min(2).max(50),
email: Joi.string().email(),
age: Joi.number().integer().min(13).max(120),
}).min(1), // At least one field required for PATCH
queryParams: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sort: Joi.string().valid('name', '-name', 'createdAt', '-createdAt'),
search: Joi.string().max(100),
}),
};
// Validation middleware factory
function validate(schema, source = 'body') {
return (req, res, next) => {
const { error, value } = schema.validate(req[source], {
abortEarly: false, // Report all errors, not just first
stripUnknown: true, // Remove fields not in schema
});
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message,
}));
return res.status(400).json({
status: 'error',
message: 'Validation failed',
errors,
});
}
req[source] = value; // Replace with validated/sanitized data
next();
};
}
// Usage:
app.post('/api/users',
validate(schemas.createUser),
asyncHandler(async (req, res) => {
// req.body is now validated and sanitized
const user = await User.create(req.body);
res.status(201).json({ data: user });
})
);
app.get('/api/users',
validate(schemas.queryParams, 'query'),
asyncHandler(async (req, res) => {
// req.query is validated with defaults applied
const { page, limit, sort, search } = req.query;
// ...
})
);9. File Uploads
// npm install multer
const multer = require('multer');
const path = require('path');
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/'); // Directory must exist
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
// File filter
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, and WebP images are allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
}
});
// Single file upload
app.post('/api/avatar', upload.single('avatar'), (req, res) => {
// req.file contains the uploaded file info
console.log(req.file);
// { fieldname, originalname, filename, mimetype, size, path, ... }
res.json({ url: `/uploads/${req.file.filename}` });
});
// Multiple files
app.post('/api/gallery', upload.array('photos', 10), (req, res) => {
// req.files is an array of file objects
const urls = req.files.map(f => `/uploads/${f.filename}`);
res.json({ urls });
});
// Multiple fields
app.post('/api/product',
upload.fields([
{ name: 'thumbnail', maxCount: 1 },
{ name: 'gallery', maxCount: 5 }
]),
(req, res) => {
console.log(req.files.thumbnail); // Array with 1 file
console.log(req.files.gallery); // Array with up to 5 files
}
);10. Serving Static Files
const express = require('express');
const path = require('path');
const app = express();
// Serve files from 'public' directory
app.use(express.static('public'));
// Now: http://localhost:3000/css/style.css serves public/css/style.css
// With URL prefix
app.use('/static', express.static('public'));
// Now: http://localhost:3000/static/css/style.css
// With absolute path (recommended)
app.use('/static', express.static(path.join(__dirname, 'public')));
// Multiple static directories
app.use(express.static('public'));
app.use(express.static('uploads'));
// Express searches directories in order
// With caching (production)
app.use(express.static('public', {
maxAge: '1d', // Cache for 1 day
etag: true, // Enable ETag
lastModified: true, // Enable Last-Modified header
index: 'index.html', // Default file for directories
}));11. CORS
// Cross-Origin Resource Sharing
// Browser security feature that blocks requests from different origins
// npm install cors
const cors = require('cors');
// Allow all origins (development only!)
app.use(cors());
// Allow specific origins
app.use(cors({
origin: ['http://localhost:3000', 'https://myapp.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies
maxAge: 86400, // Preflight cache (seconds)
}));
// Dynamic origin (e.g., from database)
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = ['http://localhost:3000', 'https://myapp.com'];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
}
}));
// Per-route CORS
app.get('/api/public', cors(), (req, res) => {
res.json({ public: true });
});12. Rate Limiting
// npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// Global rate limiter
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
message: {
status: 'error',
message: 'Too many requests, please try again later.',
},
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false, // Disable X-RateLimit-* headers
});
app.use('/api/', globalLimiter);
// Stricter limiter for auth endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 attempts per hour
message: { error: 'Too many login attempts. Try again in 1 hour.' },
});
app.post('/api/auth/login', authLimiter, loginHandler);
app.post('/api/auth/register', authLimiter, registerHandler);13. Project Structure
my-api/
├── src/
│ ├── index.js # Entry point — creates and starts server
│ ├── app.js # Express app setup (middleware, routes)
│ ├── config/
│ │ ├── index.js # Configuration (env vars, defaults)
│ │ └── database.js # Database connection
│ ├── routes/
│ │ ├── index.js # Route aggregator
│ │ ├── auth.routes.js # Auth routes
│ │ └── user.routes.js # User routes
│ ├── controllers/
│ │ ├── auth.controller.js
│ │ └── user.controller.js
│ ├── services/
│ │ ├── auth.service.js # Business logic
│ │ └── user.service.js
│ ├── models/
│ │ └── user.model.js # Database models
│ ├── middleware/
│ │ ├── auth.js # Authentication middleware
│ │ ├── validate.js # Validation middleware
│ │ ├── errorHandler.js # Error handling middleware
│ │ └── rateLimiter.js
│ ├── utils/
│ │ ├── asyncHandler.js
│ │ ├── logger.js
│ │ └── errors.js # Custom error classes
│ └── validators/
│ ├── auth.validator.js # Joi schemas for auth
│ └── user.validator.js
├── tests/
│ ├── unit/
│ └── integration/
├── .env # Environment variables (not in git!)
├── .env.example # Template for .env
├── .gitignore
├── package.json
└── README.mdExample: How Files Connect
// src/index.js
const app = require('./app');
const config = require('./config');
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const routes = require('./routes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use('/api', routes);
app.use(errorHandler);
module.exports = app;
// src/routes/index.js
const router = require('express').Router();
const userRoutes = require('./user.routes');
const authRoutes = require('./auth.routes');
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
module.exports = router;
// src/routes/user.routes.js
const router = require('express').Router();
const { getUsers, getUser, updateUser } = require('../controllers/user.controller');
const auth = require('../middleware/auth');
const { validate } = require('../middleware/validate');
const { updateUserSchema } = require('../validators/user.validator');
router.get('/', auth, getUsers);
router.get('/:id', auth, getUser);
router.patch('/:id', auth, validate(updateUserSchema), updateUser);
module.exports = router;
// src/controllers/user.controller.js
const userService = require('../services/user.service');
const asyncHandler = require('../utils/asyncHandler');
const getUsers = asyncHandler(async (req, res) => {
const users = await userService.findAll(req.query);
res.json({ status: 'success', data: users });
});
const getUser = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
res.json({ status: 'success', data: user });
});
module.exports = { getUsers, getUser, updateUser };
// src/services/user.service.js
const User = require('../models/user.model');
const { NotFoundError } = require('../utils/errors');
class UserService {
async findAll(query) {
return User.find().sort('-createdAt');
}
async findById(id) {
const user = await User.findById(id);
if (!user) throw new NotFoundError('User');
return user;
}
}
module.exports = new UserService();14. Real-World API Example
// Complete mini API: Todo List
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// In-memory database (use a real DB in production)
let todos = [];
// GET /api/todos — List all todos
app.get('/api/todos', (req, res) => {
const { status, search } = req.query;
let filtered = [...todos];
if (status) filtered = filtered.filter(t => t.status === status);
if (search) filtered = filtered.filter(t =>
t.title.toLowerCase().includes(search.toLowerCase())
);
res.json({ status: 'success', data: filtered, count: filtered.length });
});
// GET /api/todos/:id — Get single todo
app.get('/api/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === req.params.id);
if (!todo) {
return res.status(404).json({ status: 'error', message: 'Todo not found' });
}
res.json({ status: 'success', data: todo });
});
// POST /api/todos — Create todo
app.post('/api/todos', (req, res) => {
const { title, description } = req.body;
if (!title || title.trim().length === 0) {
return res.status(400).json({
status: 'error',
message: 'Title is required',
});
}
const todo = {
id: crypto.randomUUID(),
title: title.trim(),
description: description?.trim() || '',
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
todos.push(todo);
res.status(201).json({ status: 'success', data: todo });
});
// PATCH /api/todos/:id — Update todo
app.patch('/api/todos/:id', (req, res) => {
const index = todos.findIndex(t => t.id === req.params.id);
if (index === -1) {
return res.status(404).json({ status: 'error', message: 'Todo not found' });
}
const { title, description, status } = req.body;
if (status && !['pending', 'in-progress', 'done'].includes(status)) {
return res.status(400).json({
status: 'error',
message: 'Status must be: pending, in-progress, or done',
});
}
todos[index] = {
...todos[index],
...(title && { title: title.trim() }),
...(description !== undefined && { description: description.trim() }),
...(status && { status }),
updatedAt: new Date().toISOString(),
};
res.json({ status: 'success', data: todos[index] });
});
// DELETE /api/todos/:id — Delete todo
app.delete('/api/todos/:id', (req, res) => {
const index = todos.findIndex(t => t.id === req.params.id);
if (index === -1) {
return res.status(404).json({ status: 'error', message: 'Todo not found' });
}
todos.splice(index, 1);
res.status(204).send();
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ status: 'error', message: 'Internal server error' });
});
app.listen(3000, () => console.log('Todo API running on :3000'));15. Interview Questions
Q1: What is middleware in Express? Explain the middleware chain.
Answer: Middleware functions are functions that have access to req, res, and next. They form a pipeline — each request passes through them in order. Each middleware can: modify req/res, end the request-response cycle, or call next() to pass control to the next middleware. They're used for logging, authentication, parsing, error handling, etc.
Q2: How does Express handle errors?
Answer: Express has a special error-handling middleware identified by 4 parameters: (err, req, res, next). When you call next(err), Express skips all remaining non-error middleware and goes directly to the error handler. For async routes, you need to either wrap in try/catch and call next(err), or use an asyncHandler wrapper. Express 5 will handle async errors natively.
Q3: What's the difference between app.use() and app.get()?
Answer: app.use() matches ANY HTTP method and uses prefix matching (e.g., /api matches /api/users). app.get() only matches GET requests and uses exact path matching. app.use() is for middleware that should run on all routes; app.get() is for specific route handlers.
Q4: How would you structure a large Express application?
Answer: Use separation of concerns: Routes (URL mapping) → Controllers (request handling) → Services (business logic) → Models (data access). Use express.Router() for modular routes. Keep middleware in a dedicated directory. Use environment-based configuration. This makes the code testable, maintainable, and follows the single responsibility principle.
Q5: Explain the difference between PUT and PATCH.
Answer: PUT replaces the entire resource — you must send all fields. PATCH partially updates — you only send the fields you want to change. PUT is idempotent (same request always produces the same result). PATCH is technically not guaranteed to be idempotent but is often implemented as such.
Q6: How do you handle file uploads in Express?
Answer: Express doesn't handle file uploads natively — you need middleware like Multer. Multer parses multipart/form-data requests, stores files (to disk or memory), and adds file info to req.file or req.files. You can configure storage destination, filename, file size limits, and file type filters.
Next Module: 05 - Authentication & Security — JWT, sessions, CSRF, XSS, and protecting your APIs.