name: api-framework-express description: Express.js routes, middleware, error handling, request/response patterns
API Development with Express.js
Quick Guide: Use Express.js for building REST APIs with middleware-based request processing. The framework excels at composable middleware chains, modular routing via
express.Router(), and centralized error handling with 4-argument error middleware(err, req, res, next).
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST define error-handling middleware with 4 arguments: (err, req, res, next))
(You MUST register error handlers AFTER all routes and other middleware)
(You MUST call next(err) to forward async errors in Express 4 - Express 5 handles this automatically)
(You MUST use express.json() and express.urlencoded() for body parsing)
Auto-detection: Express.js, express, app.use, app.get, app.post, app.put, app.delete, express.Router, req.params, req.query, req.body, res.json, res.status, middleware, next(), error handler, router.use, express.static, express.json, express.urlencoded
When to use:
- Building REST APIs with composable middleware patterns
- Need modular route organization with
express.Router() - Require centralized error handling across all routes
- Building APIs that need body parsing, static files, or cookie handling
- Creating route guards for authentication/authorization
When NOT to use:
- Need auto-generated OpenAPI documentation (consider Hono + OpenAPI or Fastify + Swagger)
- Building edge/serverless functions where cold start matters (consider Hono)
- Need strict type safety with Zod validation built-in (consider Hono + zod-openapi)
- GraphQL APIs (use Apollo Server or similar)
Key patterns covered:
- Middleware chain with
app.use()andnext() - Routing with
app.get/post/put/deleteand route parameters - Modular routes with
express.Router() - Error handling with 4-argument middleware
- Async/await error forwarding patterns
- Request validation and parsing
- Response patterns with
res.json()andres.status() - Static file serving with
express.static() - Route guards for authentication/authorization
Detailed Resources:
- For code examples:
- middleware.md - Middleware chains, error handling, validation, auth guards
- routing.md - Modular routes, parameters, response patterns
- For decision frameworks and anti-patterns, see reference.md
Philosophy
Middleware-first architecture. Express processes requests through a chain of middleware functions. Each middleware can modify request/response objects, end the response, or call next() to continue the chain.
Use Express when: Need mature ecosystem with extensive middleware library, building traditional REST APIs, team familiarity with Express patterns.
Use alternatives when: Need OpenAPI generation (Hono), edge deployment (Hono/Fastify), strict type safety with schema validation (tRPC).
Core Patterns
Pattern 1: Application Setup with Built-in Middleware
Set up Express with body parsing middleware and basic configuration.
Constants
const PORT = 3000;
const JSON_LIMIT = "10mb";
Implementation
// src/app.ts
import express from "express";
import type { Express } from "express";
import { userRoutes } from "./routes/user-routes";
import { productRoutes } from "./routes/product-routes";
import { errorHandler } from "./middleware/error-handler";
const app: Express = express();
// Built-in middleware for body parsing
app.use(express.json({ limit: JSON_LIMIT }));
app.use(express.urlencoded({ extended: true }));
// Mount route modules
app.use("/api/users", userRoutes);
app.use("/api/products", productRoutes);
// Error handler MUST be last
app.use(errorHandler);
export { app };
Why good: Named constants for configuration, built-in body parsers registered early, error handler registered last, modular route mounting
// WRONG: Magic strings and error handler in wrong position
const app = express();
app.use(errorHandler); // Error handler too early - won't catch route errors
app.use(express.json({ limit: "10mb" })); // Magic string
app.use("/api/users", userRoutes);
Why bad: Error handler before routes means it never catches errors, magic strings make configuration hard to maintain
Pattern 2: Modular Routes with express.Router()
Create modular, mountable route handlers for better organization.
// src/routes/user-routes.ts
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
const router = Router();
const HTTP_OK = 200;
const HTTP_CREATED = 201;
const HTTP_NOT_FOUND = 404;
// GET /api/users
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const users = await getUsersFromDatabase();
res.status(HTTP_OK).json({ data: users });
} catch (error) {
next(error); // Forward to error handler
}
});
// GET /api/users/:id
router.get("/:id", async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const user = await getUserById(id);
if (!user) {
res.status(HTTP_NOT_FOUND).json({ error: "User not found" });
return;
}
res.status(HTTP_OK).json({ data: user });
} catch (error) {
next(error);
}
});
// POST /api/users
router.post("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const userData = req.body;
const newUser = await createUser(userData);
res.status(HTTP_CREATED).json({ data: newUser });
} catch (error) {
next(error);
}
});
// Named export (project convention)
export { router as userRoutes };
Why good: Router isolates related routes, named HTTP status constants, explicit error forwarding with next(error), named export follows convention
// WRONG: All routes in main app file
const app = express();
app.get("/api/users", (req, res) => {
/* ... */
});
app.get("/api/users/:id", (req, res) => {
/* ... */
});
app.post("/api/users", (req, res) => {
/* ... */
});
app.get("/api/products", (req, res) => {
/* ... */
});
// ... hundreds more lines
export default app; // Default export
Why bad: God file with all routes, no separation of concerns, default export violates convention
Pattern 3: Error Handling Middleware (4 Arguments)
Error handlers MUST have 4 arguments: (err, req, res, next).
// src/middleware/error-handler.ts
import type { Request, Response, NextFunction } from "express";
const HTTP_BAD_REQUEST = 400;
const HTTP_INTERNAL_ERROR = 500;
interface AppError extends Error {
statusCode?: number;
code?: string;
}
const errorHandler = (
err: AppError,
req: Request,
res: Response,
next: NextFunction,
): void => {
console.error(`[Error] ${req.method} ${req.path}:`, err.message);
// Already sent response - delegate to default handler
if (res.headersSent) {
next(err);
return;
}
const statusCode = err.statusCode || HTTP_INTERNAL_ERROR;
const message = err.message || "Internal server error";
res.status(statusCode).json({
error: {
message,
code: err.code || "INTERNAL_ERROR",
path: req.path,
},
});
};
export { errorHandler };
Why good: 4 arguments identifies this as error middleware, checks headersSent to avoid double-response, logs with request context, consistent error shape
// WRONG: Only 3 arguments - Express won't recognize as error handler
const errorHandler = (err, req, res) => {
res.status(500).send("Error"); // Magic number
};
// WRONG: Not checking headersSent
const errorHandler = (err, req, res, next) => {
res.status(500).json({ error: err.message }); // May crash if headers sent
};
Why bad: 3-argument function treated as regular middleware (ignores err), not checking headersSent causes "headers already sent" crashes
Pattern 4: Async Error Handling
Forward errors from async functions to the error handler.
Express 4 Pattern (Manual Forwarding)
// src/routes/product-routes.ts
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
const router = Router();
const HTTP_OK = 200;
// Option 1: try/catch with next(error)
router.get("/:id", async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await getProductById(req.params.id);
res.status(HTTP_OK).json({ data: product });
} catch (error) {
next(error); // REQUIRED in Express 4
}
});
// Option 2: Promise .catch(next)
router.get("/featured", (req: Request, res: Response, next: NextFunction) => {
getFeaturedProducts()
.then((products) => res.status(HTTP_OK).json({ data: products }))
.catch(next);
});
export { router as productRoutes };
Wrapper Function for Cleaner Code
// src/utils/async-handler.ts
import type { Request, Response, NextFunction, RequestHandler } from "express";
type AsyncHandler = (
req: Request,
res: Response,
next: NextFunction,
) => Promise<void>;
const asyncHandler = (fn: AsyncHandler): RequestHandler => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
export { asyncHandler };
// Usage with wrapper
import { asyncHandler } from "../utils/async-handler";
router.get(
"/:id",
asyncHandler(async (req, res) => {
const product = await getProductById(req.params.id);
res.status(HTTP_OK).json({ data: product });
// Errors automatically forwarded to error handler
}),
);
Why good: asyncHandler removes try/catch boilerplate, errors automatically forwarded, cleaner route definitions
// WRONG: Missing error forwarding in Express 4
router.get("/:id", async (req, res) => {
const product = await getProductById(req.params.id); // Error = unhandled rejection
res.json({ data: product });
});
Why bad: Async errors not caught by Express 4, causes unhandled promise rejection and request hangs
Pattern 5: Request Validation Middleware
Validate request data before handlers process it.
// src/middleware/validate-request.ts
import type { Request, Response, NextFunction } from "express";
const HTTP_BAD_REQUEST = 400;
const MIN_NAME_LENGTH = 1;
const MAX_NAME_LENGTH = 100;
interface UserCreateBody {
name: string;
email: string;
}
const validateUserCreate = (
req: Request,
res: Response,
next: NextFunction,
): void => {
const { name, email } = req.body as UserCreateBody;
const errors: string[] = [];
if (!name || name.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) {
errors.push(
`Name must be between ${MIN_NAME_LENGTH} and ${MAX_NAME_LENGTH} characters`,
);
}
if (!email || !email.includes("@")) {
errors.push("Valid email is required");
}
if (errors.length > 0) {
res.status(HTTP_BAD_REQUEST).json({
error: {
message: "Validation failed",
details: errors,
},
});
return;
}
next();
};
export { validateUserCreate };
// Apply validation middleware to route
router.post("/", validateUserCreate, async (req, res, next) => {
try {
// req.body is validated at this point
const user = await createUser(req.body);
res.status(HTTP_CREATED).json({ data: user });
} catch (error) {
next(error);
}
});
Why good: Validation separated from business logic, named constants for limits, early return on validation failure, reusable across routes
Pattern 6: Route Parameters and Query Strings
Access dynamic URL segments and query parameters.
// src/routes/search-routes.ts
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
const router = Router();
const HTTP_OK = 200;
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
interface SearchQuery {
q?: string;
page?: string;
limit?: string;
sort?: string;
}
// GET /api/search?q=term&page=1&limit=20&sort=date
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
try {
const { q, page, limit, sort } = req.query as SearchQuery;
const pageNum = parseInt(page || String(DEFAULT_PAGE), 10);
const limitNum = Math.min(
parseInt(limit || String(DEFAULT_LIMIT), 10),
MAX_LIMIT,
);
const results = await searchItems({
query: q || "",
page: pageNum,
limit: limitNum,
sort: sort || "relevance",
});
res.status(HTTP_OK).json({
data: results.items,
pagination: {
page: pageNum,
limit: limitNum,
total: results.total,
},
});
} catch (error) {
next(error);
}
});
// Route parameters: GET /api/users/:userId/posts/:postId
router.get(
"/users/:userId/posts/:postId",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { userId, postId } = req.params;
const post = await getPostByUserAndId(userId, postId);
res.status(HTTP_OK).json({ data: post });
} catch (error) {
next(error);
}
},
);
export { router as searchRoutes };
Why good: Query params typed, default values with named constants, parseInt with radix 10, limit capped at MAX_LIMIT to prevent abuse
Pattern 7: Route Guards (Authentication/Authorization)
Protect routes with middleware that validates access.
// src/middleware/auth-guard.ts
import type { Request, Response, NextFunction } from "express";
const HTTP_UNAUTHORIZED = 401;
const HTTP_FORBIDDEN = 403;
interface AuthenticatedRequest extends Request {
user?: {
id: string;
role: string;
};
}
const requireAuth = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): void => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
res.status(HTTP_UNAUTHORIZED).json({
error: { message: "Authentication required" },
});
return;
}
try {
// Verify token with your auth solution
const user = verifyToken(token);
req.user = user;
next();
} catch {
res.status(HTTP_UNAUTHORIZED).json({
error: { message: "Invalid token" },
});
}
};
const requireRole = (allowedRoles: string[]) => {
return (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): void => {
if (!req.user) {
res.status(HTTP_UNAUTHORIZED).json({
error: { message: "Authentication required" },
});
return;
}
if (!allowedRoles.includes(req.user.role)) {
res.status(HTTP_FORBIDDEN).json({
error: { message: "Insufficient permissions" },
});
return;
}
next();
};
};
export { requireAuth, requireRole };
export type { AuthenticatedRequest };
// Apply guards to routes
import { requireAuth, requireRole } from "../middleware/auth-guard";
// All routes in this router require authentication
router.use(requireAuth);
// Only admins can delete users
router.delete("/:id", requireRole(["admin"]), async (req, res, next) => {
try {
await deleteUser(req.params.id);
res.status(HTTP_OK).json({ message: "User deleted" });
} catch (error) {
next(error);
}
});
Why good: Auth middleware reusable, role-based guard configurable, extends Request type for type safety, clear separation of auth concerns
Pattern 8: Static Files and Built-in Middleware
Serve static assets and use Express built-in middleware.
// src/app.ts
import express from "express";
import path from "path";
const app = express();
const STATIC_MAX_AGE_MS = 86400000; // 1 day in milliseconds
// Serve static files from 'public' directory
app.use(
express.static(path.join(__dirname, "public"), {
maxAge: STATIC_MAX_AGE_MS,
etag: true,
}),
);
// Serve uploaded files from 'uploads' with restricted access
app.use(
"/uploads",
requireAuth, // Protect uploads
express.static(path.join(__dirname, "uploads")),
);
export { app };
Why good: Caching configured with named constant, path.join for cross-platform compatibility, protected static route with auth middleware
Pattern 9: Response Patterns with Consistent Shape
Standardize API responses for predictable client consumption.
// src/utils/response-helpers.ts
import type { Response } from "express";
const HTTP_OK = 200;
const HTTP_CREATED = 201;
const HTTP_NO_CONTENT = 204;
const HTTP_BAD_REQUEST = 400;
const HTTP_NOT_FOUND = 404;
interface SuccessResponse<T> {
success: true;
data: T;
meta?: Record<string, unknown>;
}
interface ErrorResponse {
success: false;
error: {
message: string;
code?: string;
details?: unknown;
};
}
const sendSuccess = <T>(
res: Response,
data: T,
statusCode: number = HTTP_OK,
meta?: Record<string, unknown>,
): void => {
const response: SuccessResponse<T> = { success: true, data };
if (meta) {
response.meta = meta;
}
res.status(statusCode).json(response);
};
const sendCreated = <T>(res: Response, data: T): void => {
sendSuccess(res, data, HTTP_CREATED);
};
const sendNoContent = (res: Response): void => {
res.status(HTTP_NO_CONTENT).send();
};
const sendError = (
res: Response,
message: string,
statusCode: number = HTTP_BAD_REQUEST,
code?: string,
details?: unknown,
): void => {
const response: ErrorResponse = {
success: false,
error: { message, code, details },
};
res.status(statusCode).json(response);
};
const sendNotFound = (res: Response, resource: string = "Resource"): void => {
sendError(res, `${resource} not found`, HTTP_NOT_FOUND, "NOT_FOUND");
};
export { sendSuccess, sendCreated, sendNoContent, sendError, sendNotFound };
// Usage in routes
import {
sendSuccess,
sendCreated,
sendNotFound,
} from "../utils/response-helpers";
router.get("/:id", async (req, res, next) => {
try {
const user = await getUserById(req.params.id);
if (!user) {
sendNotFound(res, "User");
return;
}
sendSuccess(res, user);
} catch (error) {
next(error);
}
});
Why good: Consistent response shape, typed responses, reusable helpers reduce boilerplate, status codes as named constants
Pattern 10: Middleware Order Best Practices
Order matters for security, parsing, and error handling.
// src/app.ts
import express from "express";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
const app = express();
const RATE_LIMIT_WINDOW_MS = 900000; // 15 minutes
const RATE_LIMIT_MAX_REQUESTS = 100;
const JSON_LIMIT = "10mb";
// 1. Security headers FIRST
app.use(helmet());
// 2. CORS configuration
app.use(
cors({
origin: process.env.ALLOWED_ORIGINS?.split(",") || [],
credentials: true,
}),
);
// 3. Rate limiting (before body parsing to save resources)
app.use(
rateLimit({
windowMs: RATE_LIMIT_WINDOW_MS,
max: RATE_LIMIT_MAX_REQUESTS,
standardHeaders: true,
legacyHeaders: false,
}),
);
// 4. Body parsing
app.use(express.json({ limit: JSON_LIMIT }));
app.use(express.urlencoded({ extended: true }));
// 5. Request logging (after parsing, before routes)
app.use(requestLogger);
// 6. Routes
app.use("/api/users", userRoutes);
app.use("/api/products", productRoutes);
// 7. 404 handler (after all routes)
app.use((req, res) => {
res.status(404).json({ error: { message: "Route not found" } });
});
// 8. Error handler LAST
app.use(errorHandler);
export { app };
Why good: Security (helmet) first, rate limiting before expensive body parsing, error handler absolutely last, 404 handler catches unmatched routes
Integration Guide
Works with:
- Your validation library: Validate
req.bodyin middleware before handlers - Your database solution: Query in route handlers, forward errors with
next(error) - Your authentication solution: Implement as route guard middleware
- Your logging solution: Add request/error logging middleware
Replaces / Conflicts with:
- Hono: Both are HTTP frameworks - choose one per project
- Fastify: Both are HTTP frameworks - choose one per project
- Koa: Both are HTTP frameworks - choose one per project
CRITICAL REMINDERS
Before implementing ANY Express route, verify these requirements are met:
All code must follow project conventions in CLAUDE.md
(You MUST define error-handling middleware with 4 arguments: (err, req, res, next))
(You MUST register error handlers AFTER all routes and other middleware)
(You MUST call next(err) to forward async errors in Express 4 - Express 5 handles this automatically)
(You MUST use express.json() and express.urlencoded() for body parsing)
Failure to follow these rules will cause unhandled errors and broken middleware chains.