Opinionated rules for writing robust, testable, and performant Express.js middleware with TypeScript.
You're tired of middleware chaos. Routes that mysteriously hang, error handlers that don't catch async failures, and security vulnerabilities hiding in poorly ordered middleware stacks. Every Express project starts clean, then gradually becomes an unmaintainable mess of callback hell and scattered error handling.
Here's what actually happens in most Express codebases:
next() in the right placeSound familiar? These aren't beginner mistakes—they're architectural debt that accumulates fast.
These Cursor Rules implement a proven middleware architecture that eliminates the guesswork. Instead of wrestling with Express's execution order and async gotchas, you get a systematic approach that works every time.
What you're getting:
Stop debugging middleware execution order. The rules enforce correct global → route-specific → error handler ordering, eliminating the most common Express pitfalls.
Helmet configuration, rate limiting, and input validation are built into the workflow. No more security audits finding basic middleware configuration errors.
Centralized error handling with proper async/await patterns means stack traces that actually point to the problem, not generic "something went wrong" errors.
Parameterized middleware functions that work with supertest + jest out of the box. No more skipping middleware tests because they're "too hard to set up."
Before: Authentication scattered across controllers, inconsistent error handling
// Typical messy auth that's impossible to test
app.use((req, res, next) => {
if (req.path.startsWith('/api/')) {
// Auth logic mixed with routing logic
jwt.verify(req.headers.authorization, secret, (err, user) => {
if (err) res.status(401).json({error: 'Unauthorized'}) // Forgot return!
req.user = user
next()
})
}
next() // Double next() call - request hangs
})
After: Clean, testable, performant auth middleware
// Clean auth middleware applied only where needed
const authMiddleware: RequestHandler = async (req, res, next) => {
const { authorization } = req.headers
if (!authorization) {
throw createHttpError(401, 'Authorization header required')
}
try {
const user = await jwt.verify(authorization, process.env.JWT_SECRET!)
req.user = user
next()
} catch (error) {
next(createHttpError(401, 'Invalid token'))
}
}
// Applied only to protected routes
router.use('/profile', auth(), profileRouter)
Before: Async errors disappearing, inconsistent error responses
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id) // Unhandled promise rejection
res.json(user)
})
After: Bulletproof async error handling
// Global error handler catches everything
const errorHandler = (err: unknown, req: Request, res: Response, _next: NextFunction) => {
if (createHttpError.isHttpError(err)) {
return res.status(err.status).json({
error: err.message,
requestId: req.id
})
}
logger.error('Unhandled error:', err)
res.status(500).json({ error: 'Internal server error' })
}
// Controllers focus on business logic
const getUserController: RequestHandler = async (req, res, next) => {
try {
const { id } = req.params
const user = await User.findById(id)
if (!user) {
throw createHttpError(404, 'User not found')
}
res.json(user)
} catch (error) {
next(error) // Properly forwarded to error handler
}
}
Before: Validation logic scattered across controllers
app.post('/users', (req, res) => {
if (!req.body.email || !req.body.email.includes('@')) {
return res.status(400).json({error: 'Invalid email'})
}
// Validation logic repeated everywhere
})
After: Centralized, reusable validation middleware
export const validateUser = () => [
body('email').isEmail().normalizeEmail(),
body('name').trim().isLength({ min: 2, max: 50 }),
body('age').isInt({ min: 18, max: 120 }),
handleValidationErrors
]
const handleValidationErrors: RequestHandler = (req, res, next) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
throw createHttpError(400, 'Validation failed', {
details: errors.array()
})
}
next()
}
// Clean, declarative route definition
router.post('/users', validateUser(), createUserController)
.cursorrules filenpm install express helmet morgan compression cors express-rate-limit
npm install express-validator http-errors express-async-errors
npm install -D @types/express @types/http-errors
Create your base app structure:
// src/app.ts
import express from 'express'
import 'express-async-errors'
import helmet from 'helmet'
import morgan from 'morgan'
import compression from 'compression'
import cors from 'cors'
import { errorHandler } from './errors/error-handler'
import { userRouter } from './routes/user.router'
const app = express()
// Global middleware (order matters!)
app.use(helmet({ contentSecurityPolicy: false })) // Security first
app.use(compression()) // Compression
app.use(express.json({ limit: '10mb' })) // Parsing
app.use(express.urlencoded({ extended: true }))
app.use(morgan('combined')) // Logging
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }))
// Routes
app.use('/api/users', userRouter)
// Error handler (always last!)
app.use(errorHandler)
export default app
// src/middleware/rate-limit.middleware.ts
import rateLimit from 'express-rate-limit'
export const createRateLimit = (options: { windowMs: number; max: number }) =>
rateLimit({
windowMs: options.windowMs,
max: options.max,
message: { error: 'Too many requests' },
standardHeaders: true,
legacyHeaders: false,
})
// Usage in routes
router.post('/login',
createRateLimit({ windowMs: 15 * 60 * 1000, max: 5 }), // 5 attempts per 15 min
validateLogin(),
loginController
)
// src/middleware/__tests__/auth.middleware.test.ts
import request from 'supertest'
import express from 'express'
import { authMiddleware } from '../auth.middleware'
describe('authMiddleware', () => {
const app = express()
app.use(express.json())
app.use('/protected', authMiddleware)
app.get('/protected/test', (req, res) => res.json({ success: true }))
it('rejects requests without authorization header', async () => {
const response = await request(app)
.get('/protected/test')
.expect(401)
expect(response.body.error).toBe('Authorization header required')
})
it('allows requests with valid token', async () => {
const token = jwt.sign({ userId: 1 }, process.env.JWT_SECRET!)
await request(app)
.get('/protected/test')
.set('Authorization', `Bearer ${token}`)
.expect(200)
})
})
This isn't theoretical advice—it's battle-tested patterns from production Express applications handling millions of requests. The rules encode the hard-earned lessons about middleware execution order, async error handling, and performance optimization that usually take years to learn.
Stop fighting Express middleware. Start building APIs that just work.
Ready to transform your Express development? Install these Cursor Rules and watch your middleware problems disappear.
You are an expert in Node.js, TypeScript, Express.js, and associated middleware tooling.
Key Principles
- Favor small, single-purpose middleware functions; 30 LOC max.
- Always prefer async/await over callbacks; never mix both in the same file.
- Middleware executes top-down: declare global middleware first, route middleware next, error handlers last.
- Never perform blocking work (e.g., crypto, fs sync) inside request/response cycle—off-load or stream.
- Treat middleware as pure functions: no hidden globals, side effects only through `req`, `res`, or `next`.
- Parameterize middleware to maximize reuse: `rateLimiter({ windowMs: 15*60*1000, max: 100 })`.
- Abort early on error conditions; keep the “happy path” closest to the bottom of the function.
- Default to TypeScript strict mode, `esnext` target.
TypeScript / JavaScript Rules
- Use `import` / `export` ES modules; one default export per file.
- Filename suffixes: `*.middleware.ts`, `*.validator.ts`, `*.error.ts`.
- Interfaces: prefix with `I` only for external contracts (`IUserPayload`).
- Destructure `req` properties at the top; never re-assign `req` or `res`.
- Prefer `const` for dependencies, `let` only for re-assignment in algorithm scope.
- Use arrow functions for middleware: `export const authMiddleware: RequestHandler = async (req, res, next) => {}`.
- No semicolons except after immediately-invoked function expressions (IIFEs).
Error Handling and Validation
- Add a single global error-handling middleware at the bottom of `app.ts`:
```ts
app.use(errorHandler); // (err, req, res, next)
```
- Error handler signature: `(err: unknown, req: Request, res: Response, _next: NextFunction)`.
- Always forward async errors with `next(err)` or rely on `express-async-errors`.
- Throw `createHttpError(status, message)` for predictable HTTP errors; avoid generic `Error`.
- Validate all incoming data with `express-validator` or `zod`: attach sanitized data to `req.validated`.
Express Rules (Framework-Specific)
- Global middleware order:
1. Security (helmet, rate-limit, cors)
2. Parsing (express.json, express.urlencoded)
3. Compression (compression)
4. Logging (morgan)
5. Custom global middleware (e.g., request-id)
- Route-level middleware goes directly on router: `router.post('/', validateUser(), controller.create)`.
- Never perform auth on every request globally—mount `auth()` on protected routers only.
- When chaining, keep one concern per line:
```ts
router.get(
'/profile',
auth(),
cache({ ttl: 60 }),
controller.getProfile
);
```
- Prefer router composition: one router per resource, exported from `routes/user.router.ts`.
- Mount routers in `app.ts` alphabetically to avoid merge conflicts.
Testing
- Middleware units are functions: test them with supertest + jest using an `express()` fixture.
- Use parameterized instances in tests to cover variants (e.g., different roles for auth).
- Mock external services (DB, Redis) via [dependency injection]—never hit real services in unit tests.
Performance
- Use `compression` for payloads >1 kB; skip for brotli-capable CDNs.
- Leverage `express-promise-router` to reduce overhead of `express-async-errors` when >100 routes.
- Co-locate middleware with route to minimize require-time cost in serverless cold starts.
Security
- Turn on Helmet with explicit policy overrides; do not use deprecated `helmet()` default.
- Rate limit only mutating endpoints; GET routes should be cached instead.
- Sanitize all user input; escape outgoing HTML where necessary.
Codebase Layout
```
src/
middleware/
auth.middleware.ts
rate-limit.middleware.ts
validate.middleware.ts
routes/
user.router.ts
post.router.ts
controllers/
user.controller.ts
errors/
error-handler.ts
app.ts
```
Common Pitfalls & Guards
- Forgetting to `return`/`next()` ends the response, causing timeouts. Lint rule: ensure every path ends with `return res.*` or `next()`.
- Declaring error handlers before standard middleware prevents normal routing—always last.
- Using `async` but not `await` inside middleware misleads error handling; ESLint rule `require-await`.
- Mutating `req.body` after validation can break downstream consumers—clone if necessary.
Tooling Recommendations
- eslint + `@typescript-eslint/recommended` + custom express plugin.
- Prettier with 100-char line width.
- husky + lint-staged to run `npm run test` and `eslint --fix` before commits.
Version & Dependency Management
- Pin all middleware with caret (`^`) versions; run `npm audit` weekly.
- Upgrade major versions only when changelog reviewed for breaking middleware signature changes.