Overview
Context in Express Zod API is the mechanism for passing data from middlewares to endpoint handlers. It enables dependency injection, authentication state sharing, and access to shared resources like database connections. Context is type-safe and accumulated through the middleware chain.
How Context Works
Context flows through your application in this order:
- Middlewares execute in the order they were added to the factory
- Each middleware returns an object that becomes part of the context
- Context accumulates by merging all middleware returns
- Endpoint handler receives the complete context object
// Middleware 1 returns { user }
// Middleware 2 returns { db }
// Middleware 3 returns { startTime }
// Handler receives ctx = { user, db, startTime }
Providing Context via Middleware
The most common way to provide context is through middleware:
import { Middleware, defaultEndpointsFactory } from "express-zod-api";
import { z } from "zod";
const authMiddleware = new Middleware({
input: z.object({
apiKey: z.string(),
}),
handler: async ({ input }) => {
const user = await db.users.findByApiKey(input.apiKey);
if (!user) throw createHttpError(401);
// This object becomes part of ctx
return { user };
},
});
const factory = defaultEndpointsFactory
.addMiddleware(authMiddleware);
const endpoint = factory.build({
handler: async ({ ctx }) => {
// ctx.user is available and type-safe
return { userId: ctx.user.id };
},
});
Request-Independent Context
For context that doesn’t depend on the request, use addContext():
import { defaultEndpointsFactory } from "express-zod-api";
import { readFile } from "node:fs/promises";
const factory = defaultEndpointsFactory.addContext(async () => {
// Runs for every request
const config = JSON.parse(
await readFile("config.json", "utf-8")
);
return { config };
});
const endpoint = factory.build({
handler: async ({ ctx }) => {
// ctx.config is available
const apiUrl = ctx.config.apiUrl;
},
});
addContext() runs on every request. For expensive operations like database connections, consider using singletons or connection pools instead.
Type-Safe Context
TypeScript automatically infers the context type from your middlewares:
const authMw = new Middleware({
handler: async () => ({
user: { id: 1, name: "Alice" },
}),
});
const dbMw = new Middleware({
handler: async () => ({
db: await createDbConnection(),
}),
});
const factory = defaultEndpointsFactory
.addMiddleware(authMw)
.addMiddleware(dbMw);
const endpoint = factory.build({
handler: async ({ ctx }) => {
// TypeScript knows:
// ctx.user: { id: number; name: string }
// ctx.db: DbConnection
const userName: string = ctx.user.name; // ✅ Type-safe
const result = await ctx.db.query(); // ✅ Autocomplete works
},
});
Context Accumulation
Context from multiple middlewares is merged:
const mw1 = new Middleware({
handler: async () => ({ a: 1 }),
});
const mw2 = new Middleware({
handler: async () => ({ b: 2 }),
});
const mw3 = new Middleware({
handler: async () => ({ c: 3 }),
});
const factory = defaultEndpointsFactory
.addMiddleware(mw1)
.addMiddleware(mw2)
.addMiddleware(mw3);
const endpoint = factory.build({
handler: async ({ ctx }) => {
// ctx = { a: 1, b: 2, c: 3 }
return ctx;
},
});
Accessing Previous Context
Middlewares can access context from previously executed middlewares:
const authMiddleware = new Middleware({
handler: async () => ({
user: { id: 1, role: "admin" },
}),
});
const permissionMiddleware = new Middleware({
handler: async ({ ctx }) => {
// ctx.user is available from authMiddleware
if (ctx.user.role !== "admin") {
throw createHttpError(403, "Admin required");
}
return { isAdmin: true };
},
});
const factory = defaultEndpointsFactory
.addMiddleware(authMiddleware)
.addMiddleware(permissionMiddleware);
Context is accumulated in order. Later middlewares can access context from earlier ones, but not vice versa.
Shared Resources
Database Connections
For persistent database connections, use a singleton pattern instead of creating connections per request:
// db.ts - Create once
import mongoose from "mongoose";
export const db = mongoose.connect("mongodb://localhost/mydb");
// endpoints.ts - Import and use
import { db } from "./db";
const endpoint = factory.build({
handler: async () => {
const users = await db.collection("users").find();
return { users };
},
});
For connection pools or per-request connections:
import { defaultEndpointsFactory } from "express-zod-api";
import { createPool } from "generic-pool";
const pool = createPool({
create: async () => await createDbConnection(),
destroy: async (conn) => await conn.close(),
}, { max: 10 });
const factory = defaultEndpointsFactory.addContext(async () => {
const db = await pool.acquire();
return { db };
});
Configuration
For static configuration, use imports instead of context:
// config.ts
export const config = {
apiUrl: process.env.API_URL,
apiKey: process.env.API_KEY,
};
// endpoint.ts
import { config } from "./config";
const endpoint = factory.build({
handler: async () => {
// Use config directly
return { apiUrl: config.apiUrl };
},
});
For dynamic configuration that changes per request:
const factory = defaultEndpointsFactory.addContext(async () => {
const config = await loadDynamicConfig();
return { config };
});
Resource Cleanup
Clean up resources in a custom Result Handler:
import { ResultHandler, EndpointsFactory } from "express-zod-api";
import { z } from "zod";
const resultHandlerWithCleanup = new ResultHandler({
positive: (output) => z.object({ data: output }),
negative: z.object({ error: z.string() }),
handler: ({ error, output, response, ctx }) => {
// Cleanup: check if db connection exists
if ("db" in ctx && ctx.db) {
ctx.db.release(); // Return to pool
// or ctx.db.close() for direct connections
}
if (error) {
return void response.status(500).json({ error: error.message });
}
response.json({ data: output });
},
});
const factory = new EndpointsFactory(resultHandlerWithCleanup);
Common Patterns
Authentication Context
const authMiddleware = new Middleware({
security: { type: "header", name: "authorization" },
input: z.object({
authorization: z.string().regex(/^Bearer .+$/),
}),
handler: async ({ input }) => {
const token = input.authorization.replace("Bearer ", "");
const user = await verifyJWT(token);
if (!user) {
throw createHttpError(401, "Invalid token");
}
return {
user: {
id: user.id,
email: user.email,
role: user.role,
},
};
},
});
const requestMetadataMiddleware = new Middleware({
handler: async ({ request }) => {
return {
requestId: request.headers["x-request-id"] || generateId(),
startTime: Date.now(),
userAgent: request.headers["user-agent"],
ip: request.ip,
};
},
});
Feature Flags
const featureFlagsMiddleware = new Middleware({
handler: async ({ ctx }) => {
const flags = await featureFlagService.getFlags(ctx.user?.id);
return {
features: {
newUI: flags.includes("new-ui"),
betaFeatures: flags.includes("beta"),
},
};
},
});
const endpoint = factory
.addMiddleware(authMiddleware)
.addMiddleware(featureFlagsMiddleware)
.build({
handler: async ({ ctx }) => {
if (ctx.features.newUI) {
return { ui: "new" };
}
return { ui: "classic" };
},
});
Tenant Isolation
const tenantMiddleware = new Middleware({
input: z.object({
tenantId: z.string(),
}),
handler: async ({ input, ctx }) => {
const tenant = await db.tenants.findById(input.tenantId);
if (!tenant) {
throw createHttpError(404, "Tenant not found");
}
// Verify user has access to tenant
if (!tenant.userIds.includes(ctx.user.id)) {
throw createHttpError(403, "Access denied");
}
return {
tenant,
db: createTenantDb(tenant.id),
};
},
});
Context in Result Handlers
Result handlers receive context but it may be partial if middleware execution was interrupted:
const resultHandler = new ResultHandler({
positive: (output) => z.object({ data: output }),
negative: z.object({ error: z.string() }),
handler: ({ error, output, response, ctx }) => {
// ctx may be incomplete if an error occurred in middleware
// Always check for property existence
if ("user" in ctx && ctx.user) {
response.set("X-User-Id", ctx.user.id);
}
if (error) {
return void response.status(500).json({ error: error.message });
}
response.json({ data: output });
},
});
Always use the in operator to check if context properties exist in Result Handlers, since context may be incomplete if middleware execution was interrupted by an error.
Best Practices
- Use singletons for persistent resources: Database connections, configuration, etc.
- Keep context minimal: Only include what endpoints actually need
- Type your context: Let TypeScript infer types from middleware returns
- Order middlewares carefully: Authentication before authorization, etc.
- Clean up resources: Use Result Handlers to release connections
- Avoid expensive operations: Don’t create new database connections per request
- Check property existence: Always verify context properties exist in Result Handlers
❌ Anti-pattern: Creating connections per request
// Don't do this
const factory = defaultEndpointsFactory.addContext(async () => {
const db = await mongoose.connect("mongodb://localhost/mydb");
return { db };
});
✅ Better: Use a singleton
// db.ts
export const db = mongoose.connect("mongodb://localhost/mydb");
// endpoint.ts
import { db } from "./db";
✅ Better: Use a connection pool
const pool = createConnectionPool();
const factory = defaultEndpointsFactory.addContext(async () => {
const db = await pool.acquire();
return { db };
});
// Don't forget cleanup in Result Handler
See Also