Skip to main content

Overview

Middleware in Contract Kit provides a powerful way to handle cross-cutting concerns like authentication, logging, CORS, rate limiting, and error handling. Middleware functions intercept requests and responses, allowing you to implement common patterns without repeating code in every route handler.

What is Middleware?

Middleware is a function that runs during the request/response lifecycle. It can:
  • Inspect requests before they reach your handlers
  • Transform requests or responses
  • Short-circuit the request (e.g., return early for auth failures)
  • Add headers or metadata
  • Log requests and responses
  • Handle errors consistently

Request Lifecycle

When a request arrives, it flows through middleware in the order they’re registered:
Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response
Each middleware can:
  1. Call next(ctx) to pass control to the next middleware/handler
  2. Return a response early to skip remaining middleware and the handler
  3. Transform the response returned by next(ctx) before returning it

Built-in Middleware

Contract Kit provides several built-in middleware functions you can use out of the box.

Logging Middleware

Log requests and responses with automatic duration tracking:
import { createNextServer } from "@contract-kit/next";
import { createLoggingMiddleware } from "@contract-kit/server/middleware";

const server = await createNextServer({
  ports: {},
  middleware: [
    createLoggingMiddleware({
      logger: console,
      requestIdHeader: "x-request-id",
    }),
  ],
  createContext: async ({ req }) => ({
    requestId: crypto.randomUUID(),
  }),
  onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
Options:
  • logger: Logger instance with info() and error() methods
  • requestIdHeader: Header name to include request ID in response
  • onRequestStart: Custom hook called when request starts
  • onRequestEnd: Custom hook called when request completes
Custom logging hooks:
createLoggingMiddleware({
  onRequestStart: ({ ctx, req, contract }) => {
    console.log(`[${ctx.requestId}] ${req.method} ${req.url}`);
  },
  onRequestEnd: ({ ctx, req, res, durationMs, contract }) => {
    console.log(
      `[${ctx.requestId}] ${req.method} ${req.url} - ${res.status} (${durationMs}ms)`
    );
  },
})

CORS Middleware

Handle Cross-Origin Resource Sharing (CORS) headers:
import { createCorsMiddleware } from "@contract-kit/server/middleware";

const server = await createNextServer({
  ports: {},
  middleware: [
    createCorsMiddleware({
      origins: ["http://localhost:3000", "https://myapp.com"],
      credentials: true,
      methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
      headers: ["Content-Type", "Authorization"],
    }),
  ],
  createContext: async () => ({}),
  onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
Options:
  • origins: Allowed origins ("*" for all, or array of specific origins)
  • credentials: Allow credentials (cookies, authorization headers)
  • methods: Allowed HTTP methods
  • headers: Allowed request headers
The middleware automatically:
  • Handles OPTIONS preflight requests
  • Adds appropriate CORS headers to all responses
  • Echoes the request origin when credentials are enabled (CORS spec requirement)

Rate Limiting Middleware

Protect your API from abuse with rate limiting:
import { createRateLimitMiddleware } from "@contract-kit/server/middleware";
import { redisProvider } from "@contract-kit/provider-redis";

const server = await createNextServer({
  ports: {},
  providers: [redisProvider],
  middleware: [
    createRateLimitMiddleware({
      getClientIp: (req) => req.headers.get("cf-connecting-ip") || undefined,
    }),
  ],
  createContext: async ({ req, ports }) => ({
    user: await authenticateUser(req),
    ports,
  }),
  onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
Requirements:
  • A rateLimit port (provided by @contract-kit/provider-redis or @contract-kit/provider-rate-limit-upstash)
  • Rate limit metadata on contracts (see below)
Contract configuration:
import { createContractGroup } from "contract-kit";

const api = createContractGroup();

const getUser = api
  .get("/api/users/:id")
  .path(z.object({ id: z.string() }))
  .response(200, UserSchema)
  .meta({
    rateLimit: {
      max: 100,           // Maximum requests
      windowSec: 60,      // Time window in seconds
      scope: "ip",        // Scope: "user", "ip", or "global"
    },
  });
Scopes:
  • "user": Limit per authenticated user (requires ctx.user.id)
  • "ip": Limit per client IP address
  • "global": Global limit across all requests
Custom key builder:
createRateLimitMiddleware({
  key: ({ ctx, req, scope }) => {
    if (scope === "user" && ctx.user?.id) {
      return `user:${ctx.user.id}`;
    }
    const ip = req.headers.get("x-forwarded-for")?.split(",")[0] || "unknown";
    return `ip:${ip}`;
  },
})

Error Mapping

Map errors to consistent HTTP responses:
import { defaultMapErrorToResponse } from "@contract-kit/server/middleware";

const server = await createNextServer({
  ports: {},
  createContext: async () => ({}),
  onUnhandledError: (error, { ctx, req }) => {
    // Use default error mapper with custom config
    return defaultMapErrorToResponse(error, ctx, {
      mapErrorToResponse: (err, ctx) => {
        // Custom error handling
        if (err instanceof MyCustomError) {
          return {
            status: err.statusCode,
            body: { message: err.message },
          };
        }
        // Fallback to a generic 500 error response
        return {
          status: 500,
          body: { message: "Internal Server Error" },
        };
      },
      includeStackInResponse: process.env.NODE_ENV === "development",
      env: process.env.NODE_ENV as "development" | "production" | "test",
    });
  },
});

Creating Custom Middleware

Create your own middleware to handle application-specific concerns.

Middleware Type

import type { Middleware, HttpContractConfig, HttpRequestLike, HttpResponseLike } from "@contract-kit/server";

type Middleware<Ctx, C extends HttpContractConfig = HttpContractConfig> = (args: {
  req: HttpRequestLike;
  ctx: Ctx;
  contract: C;
  meta?: C["metadata"];
  next: (ctx?: Ctx) => Promise<HttpResponseLike>;
}) => Promise<HttpResponseLike>;

Authentication Middleware

import type { Middleware } from "@contract-kit/server";
import { AppError } from "@contract-kit/errors";

type AppContext = {
  user?: { id: string; email: string } | null;
  ports: AppPorts;
};

const authMiddleware: Middleware<AppContext> = async ({ ctx, req, meta, next }) => {
  // Check if route requires authentication
  const requiresAuth = (meta as { auth?: boolean })?.auth;
  
  if (!requiresAuth) {
    return next(ctx);
  }
  
  // Extract token from header
  const token = req.headers.get("authorization")?.replace("Bearer ", "");
  
  if (!token) {
    return {
      status: 401,
      body: { message: "Missing authorization token" },
    };
  }
  
  try {
    // Validate token and get user
    const user = await ctx.ports.auth.validateToken(token);
    
    // Add user to context for downstream handlers
    return next({ ...ctx, user });
  } catch (error) {
    return {
      status: 401,
      body: { message: "Invalid or expired token" },
    };
  }
};
Use with contract metadata:
const getProfile = api
  .get("/api/profile")
  .response(200, ProfileSchema)
  .meta({ auth: true });

const server = await createNextServer({
  ports: {},
  middleware: [authMiddleware],
  createContext: async () => ({ user: null, ports }),
  onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});

Request Timing Middleware

const timingMiddleware: Middleware<AppContext> = async ({ ctx, req, next }) => {
  const start = performance.now();
  
  const response = await next(ctx);
  
  const duration = performance.now() - start;
  
  return {
    ...response,
    headers: {
      ...(response.headers || {}),
      "x-response-time": `${Math.round(duration)}ms`,
    },
  };
};

Request ID Middleware

const requestIdMiddleware: Middleware<AppContext> = async ({ ctx, req, next }) => {
  // Generate or extract request ID
  const requestId = req.headers.get("x-request-id") || crypto.randomUUID();
  
  // Add to context
  const updatedCtx = { ...ctx, requestId };
  
  // Call next middleware/handler
  const response = await next(updatedCtx);
  
  // Add request ID to response headers
  return {
    ...response,
    headers: {
      ...(response.headers || {}),
      "x-request-id": requestId,
    },
  };
};

Security Headers Middleware

const securityHeadersMiddleware: Middleware<AppContext> = async ({ ctx, next }) => {
  const response = await next(ctx);
  
  return {
    ...response,
    headers: {
      ...(response.headers || {}),
      "X-Content-Type-Options": "nosniff",
      "X-Frame-Options": "DENY",
      "X-XSS-Protection": "1; mode=block",
      "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
    },
  };
};

Middleware Composition

Middleware runs in the order specified. Order matters for dependencies:
const server = await createNextServer({
  ports: {},
  middleware: [
    // 1. Security headers (runs first on request, last on response)
    securityHeadersMiddleware,
    
    // 2. CORS (handles preflight requests, should run before auth)
    createCorsMiddleware({ origins: "*" }),
    
    // 3. Logging (tracks the entire request)
    createLoggingMiddleware({ logger: console }),
    
    // 4. Request ID (available to auth and downstream)
    requestIdMiddleware,
    
    // 5. Authentication (validates user)
    authMiddleware,
    
    // 6. Rate limiting (after auth to use user context)
    createRateLimitMiddleware(),
    
    // 7. Timing (measures request duration)
    timingMiddleware,
  ],
  createContext: async ({ req, ports }) => ({
    requestId: crypto.randomUUID(),
    user: null,
    ports,
  }),
  onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
Best practices for ordering:
  1. Security headers first (apply to all responses)
  2. CORS early (handle preflight requests before auth)
  3. Logging early (capture full request lifecycle)
  4. Request ID early (available to all middleware)
  5. Authentication before authorization checks
  6. Rate limiting after authentication (use user context)
  7. Timing last (measure actual handler time)

Accessing Context and Metadata

Using Context

Context flows through middleware and can be modified:
const enrichContextMiddleware: Middleware<AppContext> = async ({ ctx, req, next }) => {
  // Add data to context
  const enrichedCtx = {
    ...ctx,
    userAgent: req.headers.get("user-agent"),
    ip: req.headers.get("x-forwarded-for"),
    timestamp: Date.now(),
  };
  
  return next(enrichedCtx);
};

Using Metadata

Contract metadata allows middleware to behave differently per route:
const cacheMiddleware: Middleware<AppContext> = async ({ ctx, meta, next }) => {
  const cacheConfig = (meta as { cache?: { ttl: number } })?.cache;
  
  if (!cacheConfig) {
    return next(ctx);
  }
  
  // Check cache
  const cached = await ctx.ports.cache.get(req.url);
  if (cached) {
    return { status: 200, body: cached };
  }
  
  // Call handler and cache result
  const response = await next(ctx);
  if (response.status === 200) {
    await ctx.ports.cache.set(req.url, response.body, cacheConfig.ttl);
  }
  
  return response;
};
Define cache metadata on contracts:
const getProduct = api
  .get("/api/products/:id")
  .path(z.object({ id: z.string() }))
  .response(200, ProductSchema)
  .meta({ cache: { ttl: 300 } }); // Cache for 5 minutes

Short-Circuiting Requests

Middleware can return early to skip the handler:
const maintenanceModeMiddleware: Middleware<AppContext> = async ({ ctx, next }) => {
  const isMaintenanceMode = process.env.MAINTENANCE_MODE === "true";
  
  if (isMaintenanceMode) {
    // Return early, skip handler
    return {
      status: 503,
      body: { message: "Service temporarily unavailable" },
      headers: { "Retry-After": "3600" },
    };
  }
  
  return next(ctx);
};

Testing Middleware

Test middleware in isolation or integration:

Unit Testing

import { expect, test, mock } from "bun:test";
import type { HttpRequestLike } from "@contract-kit/server";

function createMockRequest(url: string, options?: {
  method?: string;
  headers?: Record<string, string>;
}): HttpRequestLike {
  return {
    method: options?.method || "GET",
    url,
    headers: new Headers(options?.headers || {}),
    json: async () => ({}),
    text: async () => "",
  };
}

test("auth middleware blocks unauthenticated requests", async () => {
  const ctx = { user: null, ports: {} };
  const req = createMockRequest("http://localhost/api/profile");
  
  const response = await authMiddleware({
    ctx,
    req,
    meta: { auth: true },
    next: async () => ({ status: 200, body: { message: "OK" } }),
  });
  
  expect(response.status).toBe(401);
  expect(response.body).toEqual({ message: "Missing authorization token" });
});

test("auth middleware allows authenticated requests", async () => {
  const mockAuthPort = {
    validateToken: async (token: string) => ({ id: "user-123", email: "[email protected]" })
  };
  
  const ctx = { user: null, ports: { auth: mockAuthPort } };
  const req = createMockRequest("http://localhost/api/profile", {
    headers: { authorization: "Bearer valid-token" },
  });
  
  const next = mock(async (ctx) => ({ status: 200, body: { message: "OK" } }));
  
  const response = await authMiddleware({
    ctx,
    req,
    meta: { auth: true },
    next,
  });
  
  expect(response.status).toBe(200);
  expect(next).toHaveBeenCalled();
});

Integration Testing

test("middleware pipeline processes requests correctly", async () => {
  // Note: authMiddleware, timingMiddleware, and getProfile contract 
  // should be imported from previous examples or defined in your test setup
  
  const server = await createNextServer({
    ports: {},
    middleware: [
      createLoggingMiddleware({ logger: console }),
      authMiddleware, // From authentication middleware example
      timingMiddleware, // From request timing middleware example
    ],
    routes: [
      {
        contract: getProfile, // From your contracts
        handle: async ({ ctx }) => ({
          status: 200,
          body: { userId: ctx.user?.id },
        }),
      },
    ],
    createContext: async () => ({ user: null, ports: {} }),
    onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
  });
  
  const handler = server.api();
  
  const response = await handler({
    method: "GET",
    url: "/api/profile",
    headers: new Headers({ authorization: "Bearer token" }),
    json: async () => ({}),
    text: async () => "",
  });
  
  expect(response.status).toBe(200);
  expect(response.headers?.["x-response-time"]).toBeDefined();
});

Common Use Cases

API Key Authentication

const apiKeyMiddleware: Middleware<AppContext> = async ({ ctx, req, meta, next }) => {
  const requiresApiKey = (meta as { apiKey?: boolean })?.apiKey;
  
  if (!requiresApiKey) {
    return next(ctx);
  }
  
  const apiKey = req.headers.get("x-api-key");
  
  if (!apiKey) {
    return { status: 401, body: { message: "Missing API key" } };
  }
  
  const isValid = await ctx.ports.auth.validateApiKey(apiKey);
  
  if (!isValid) {
    return { status: 401, body: { message: "Invalid API key" } };
  }
  
  return next(ctx);
};

Request Validation

const validateContentTypeMiddleware: Middleware<AppContext> = async ({ ctx, req, next }) => {
  if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") {
    const contentType = req.headers.get("content-type");
    
    if (!contentType?.includes("application/json")) {
      return {
        status: 415,
        body: { message: "Content-Type must be application/json" },
      };
    }
  }
  
  return next(ctx);
};

Response Compression

const compressionMiddleware: Middleware<AppContext> = async ({ ctx, req, next }) => {
  const response = await next(ctx);
  
  const acceptEncoding = req.headers.get("accept-encoding") || "";
  
  if (acceptEncoding.includes("gzip") && response.body) {
    // Compress response body (implementation depends on your environment)
    // This is a simplified example
    return {
      ...response,
      headers: {
        ...(response.headers || {}),
        "Content-Encoding": "gzip",
      },
    };
  }
  
  return response;
};

Best Practices

Each middleware should have a single responsibility. Don’t combine logging, auth, and rate limiting in one middleware.
Middleware order matters. Security headers should be first, CORS before auth, authentication before authorization.
Don’t hardcode route patterns in middleware. Use contract metadata to declare which routes need specific middleware behavior.
Middleware should catch and handle errors appropriately. Don’t let errors in logging or metrics crash the request.
Create new context objects instead of mutating the existing one. This makes the flow easier to understand and debug.
If middleware requires specific context properties or ports, document them clearly with TypeScript types.
Write unit tests for middleware in isolation before testing the full pipeline.
Middleware runs on every request. Keep it fast and avoid expensive operations in the hot path.

Next Steps