e-width, initial-scale=1.0">
<title><%= locals.pageTitle || 'ProjectBoard' %></title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<%- include('../partials/header') %>
<main class="container">
<%- body %>
</main>
<%- include('../partials/footer') %>
<script src="/js/app.js"></script>
</body>
</html>
```
views/partials/header.ejs
<header class="app-header">
<nav>
<a href="/">Dashboard</a>
<% if (locals.user) { %>
<span class="user-badge"><%= user.name %></span>
<a href="/auth/logout" class="btn-logout">Sign Out</a>
<% } %>
</nav>
</header>
Rationale: Layout injection reduces template coupling. The locals object passes data safely without polluting the global scope. Header/footer partials remain isolated, enabling independent updates.
Step 2: Secure Session Orchestration
Legacy Express relied on built-in session middleware, which is deprecated. Modern implementations use express-session with explicit store configuration, secret rotation, and serialization controls.
src/middleware/session.config.ts
import session from 'express-session';
import connectRedis from 'connect-redis';
import { Redis } from 'ioredis';
const redisClient = new Redis({ host: process.env.REDIS_HOST || 'localhost' });
const RedisStore = connectRedis(session);
export const sessionMiddleware = session({
store: new RedisStore({ client: redisClient, prefix: 'pb:session:' }),
secret: process.env.SESSION_SECRET || 'fallback-dev-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax'
}
});
Rationale: resave: false and saveUninitialized: false prevent unnecessary session writes and reduce store pressure. Redis-backed storage enables horizontal scaling. Cookie flags enforce security boundaries. Secret rotation is handled via environment variables, allowing zero-downtime key updates.
Step 3: RESTful Route Architecture
REST is not just about HTTP verbs; it's about resource-oriented contracts. Controllers should handle a single resource, and routes should follow predictable patterns.
src/controllers/ProjectController.ts
import { Request, Response } from 'express';
export class ProjectController {
async index(req: Request, res: Response) {
const projects = await req.app.locals.db.projects.findMany();
res.render('projects/index', { projects, user: req.session.user });
}
async create(req: Request, res: Response) {
const { title, description } = req.body;
const newProject = await req.app.locals.db.projects.create({
title, description, ownerId: req.session.user.id
});
res.status(201).json(newProject);
}
async update(req: Request, res: Response) {
const { id } = req.params;
const updates = req.body;
const updated = await req.app.locals.db.projects.update(id, updates);
res.json(updated);
}
async remove(req: Request, res: Response) {
const { id } = req.params;
await req.app.locals.db.projects.delete(id);
res.status(204).send();
}
}
src/routes/project.routes.ts
import { Router } from 'express';
import { ProjectController } from '../controllers/ProjectController';
const router = Router();
const controller = new ProjectController();
router.get('/projects', controller.index);
router.post('/projects', controller.create);
router.put('/projects/:id', controller.update);
router.delete('/projects/:id', controller.remove);
export default router;
Rationale: Resource naming (/projects) aligns with REST conventions. Controller methods are async, enabling proper error propagation. Status codes (201, 204) communicate intent explicitly. Route files remain declarative, delegating logic to controllers.
Step 4: Middleware Pipeline Design
Middleware order dictates request flow. Parsers must precede session initialization. Session must precede route handlers. Error boundaries must terminate the pipeline.
src/app.ts
import express from 'express';
import { sessionMiddleware } from './middleware/session.config';
import projectRoutes from './routes/project.routes';
import authRoutes from './routes/auth.routes';
const app = express();
// 1. Static assets (fastest path)
app.use(express.static('public'));
// 2. Body parsing (must precede session/route logic)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 3. Session initialization
app.use(sessionMiddleware);
// 4. View engine configuration
app.set('view engine', 'ejs');
app.set('views', './views');
// 5. Route registration
app.use('/', authRoutes);
app.use('/', projectRoutes);
// 6. Error boundary (catches async failures)
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).render('errors/500', { message: 'Internal Server Error' });
});
export default app;
Rationale: Static files bypass session and parsing overhead. Body parsers transform payloads before session or route handlers access them. Session middleware attaches req.session predictably. Error boundary catches unhandled rejections and prevents stack trace leakage. This sequence eliminates silent failures and ensures deterministic request processing.
Pitfall Guide
Production Express applications fail predictably when developers ignore lifecycle constraints. The following pitfalls represent the most common architectural failures, along with production-tested mitigations.
1. Middleware Sequence Blindness
Explanation: Placing express.json() after session initialization or route handlers causes req.body to be undefined downstream. Express processes middleware sequentially; out-of-order parsers break the pipeline silently.
Fix: Always position body parsers before session and route middleware. Use explicit comments or configuration objects to document pipeline order. Validate with integration tests that assert req.body availability.
2. Session Namespace Collisions
Explanation: Assigning arbitrary properties to req.session without namespacing can overwrite native session methods (destroy, regenerate, save). This causes runtime crashes or state loss.
Fix: Encapsulate session data under a dedicated key: req.session.appState = { user, preferences }. Never attach primitives directly to req.session. Use TypeScript interfaces to enforce structure.
3. EJS Partial Path Fragility
Explanation: Relative paths in EJS include directives break when templates are nested or moved. Hardcoded paths like ../partials/header create maintenance debt and deployment failures.
Fix: Use absolute paths relative to the views directory: <%- include('partials/header') %>. Configure app.set('views', path) and avoid deep nesting. Consider layout engines like express-ejs-layouts for complex applications.
4. REST Verb Simulation Anti-patterns
Explanation: Relying on method-override for PUT/DELETE in production APIs introduces security risks and client compatibility issues. HTML forms lack native verb support, but modern clients use fetch or axios with full HTTP method support.
Fix: Reserve method-override for legacy form submissions. For APIs, enforce standard HTTP verbs. Validate req.method in middleware and reject unsupported verbs with 405 Method Not Allowed.
5. Unhandled Async Route Failures
Explanation: Express does not automatically catch rejected promises in async route handlers. Unhandled rejections crash the Node.js process or leave requests hanging.
Fix: Wrap async handlers in a utility: const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);. Alternatively, use express-async-errors for automatic promise rejection forwarding.
6. Session Serialization Limits
Explanation: Storing large objects, database connections, or circular references in req.session causes serialization failures, memory leaks, or Redis store bloat.
Fix: Serialize only primitive identifiers and lightweight metadata. Store full user profiles in a database or cache, and retrieve them via middleware using req.session.userId. Validate payload size before storage.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-instance deployment, low traffic | In-memory session store | Zero infrastructure overhead, simple setup | $0 (development only) |
| Multi-instance deployment, horizontal scaling | Redis-backed session store | Shared state across nodes, sub-millisecond latency | ~$15-30/mo (managed Redis) |
| Legacy HTML forms requiring PUT/DELETE | method-override middleware | Maintains backward compatibility with form submissions | Low (security risk if misconfigured) |
| Modern SPA/mobile API clients | Native HTTP verbs via fetch/axios | Eliminates override complexity, aligns with REST standards | None |
| Simple dashboard, server-rendered | EJS with layout composition | Fast TTFB, SEO-friendly, minimal client JS | Low (developer time) |
| Complex interactive UI, real-time updates | React/Vue + Express API | Decouples frontend/backend, enables WebSocket scaling | Medium-High (infrastructure + dev) |
Configuration Template
// src/app.ts
import express from 'express';
import session from 'express-session';
import RedisStore from 'connect-redis';
import { Redis } from 'ioredis';
import path from 'path';
const app = express();
const redis = new Redis(process.env.REDIS_URL);
// Pipeline: Static → Parsers → Session → Views → Routes → Errors
app.use(express.static(path.join(__dirname, '../public')));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
store: new RedisStore({ client: redis, prefix: 'app:session:' }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: 86400000 }
}));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../views'));
// Register routes here
// app.use('/', authRoutes);
// app.use('/', resourceRoutes);
// Async error wrapper
const asyncHandler = (fn: Function) => (req: express.Request, res: express.Response, next: express.NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Error boundary
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(`[${new Date().toISOString()}] ${err.message}`);
res.status(err.status || 500).render('errors/default', { message: err.message });
});
export default app;
Quick Start Guide
- Initialize Project: Run
npm init -y && npm install express ejs express-session connect-redis ioredis @types/express @types/express-session typescript ts-node.
- Configure TypeScript: Create
tsconfig.json with module: commonjs, target: es2020, and outDir: dist. Add "scripts": { "dev": "ts-node src/app.ts", "build": "tsc", "start": "node dist/app.js" }.
- Structure Directories: Create
src/controllers, src/routes, src/middleware, views/layouts, views/partials, and public/css.
- Launch Pipeline: Execute
npm run dev. Verify session initialization, body parsing, and route registration by hitting GET / and inspecting req.session in a test endpoint.
- Deploy: Build with
npm run build, set NODE_ENV=production and SESSION_SECRET, and start with npm start. Monitor session store connectivity and error boundary logs.