Skip to main content
Middlewares allow you to run code before your route handler executes. They’re perfect for authentication, logging, rate limiting, and extending the request context with shared data.

Importing

import { Middleware } from "semola/api";

Defining a Middleware

Create a middleware by instantiating the Middleware class with a configuration object:
import { Middleware } from "semola/api";
import { z } from "zod";

const authMiddleware = new Middleware({
  request: {
    headers: z.object({
      authorization: z.string(),
    }),
  },
  response: {
    401: z.object({ error: z.string() }),
  },
  handler: async (c) => {
    const token = c.req.headers.authorization;

    if (!token || !token.startsWith("Bearer ")) {
      return c.json(401, { error: "Unauthorized" });
    }

    const user = await validateToken(token.slice(7));

    if (!user) {
      return c.json(401, { error: "Invalid token" });
    }

    // Return data to extend the context
    return { user };
  },
});

Configuration Options

request (optional)

Define request schemas that will be validated before the middleware executes. Uses Standard Schema-compatible libraries (Zod, Valibot, ArkType, etc.). Supported fields:
  • body - Request body validation
  • params - Path parameter validation
  • query - Query string validation
  • headers - HTTP header validation
  • cookies - Cookie validation
const apiKeyMiddleware = new Middleware({
  request: {
    headers: z.object({
      "x-api-key": z.string(),
    }),
  },
  handler: async (c) => {
    const apiKey = c.req.headers["x-api-key"];
    
    if (!isValidApiKey(apiKey)) {
      return c.json(403, { error: "Invalid API key" });
    }
    
    return { apiKeyValid: true };
  },
});

response (optional)

Define response schemas for different HTTP status codes. The middleware can return a Response object matching these schemas.
const rateLimitMiddleware = new Middleware({
  response: {
    429: z.object({ error: z.string() }),
  },
  handler: async (c) => {
    const ip = c.raw.headers.get("x-forwarded-for");

    if (await isRateLimited(ip)) {
      return c.json(429, { error: "Too many requests" });
    }

    return { ip };
  },
});

handler (required)

The middleware function that executes for each request. Receives a context object and can:
  • Return an object to extend the context for subsequent middlewares and the route handler
  • Return a Response to short-circuit the request
  • Return undefined or void if no context extension is needed
type MiddlewareHandler = (c: Context) =>
  | Response
  | Record<string, unknown>
  | undefined
  | Promise<Response | Record<string, unknown> | undefined>
  | Promise<void>
  | void;

Using Middlewares

Route-Specific Middlewares

Apply middlewares to individual routes using the middlewares array:
api.defineRoute({
  path: "/profile",
  method: "GET",
  middlewares: [authMiddleware] as const,
  response: {
    200: z.object({
      id: z.string(),
      name: z.string(),
    }),
  },
  handler: async (c) => {
    // Access middleware data via c.get()
    const user = c.get("user");

    return c.json(200, {
      id: user.id,
      name: user.name,
    });
  },
});
Important: Use as const on the middlewares array to preserve type inference.

Global Middlewares

Apply middlewares to all routes by passing them to the API constructor:
// Logging middleware
const loggingMiddleware = new Middleware({
  handler: async (c) => {
    const start = Date.now();
    console.log(`${c.raw.method} ${c.raw.url}`);

    return {
      requestStartTime: start,
    };
  },
});

// Apply globally via constructor
const api = new Api({
  middlewares: [loggingMiddleware] as const,
});

// Now all routes will have logging
api.defineRoute({
  path: "/users",
  method: "GET",
  response: {
    200: z.array(UserSchema),
  },
  handler: async (c) => {
    const startTime = c.get("requestStartTime");
    const users = await getUsers();

    console.log(`Request took ${Date.now() - startTime}ms`);
    return c.json(200, users);
  },
});

Context Extension

Returning Data

Middlewares can return an object to extend the context. This data becomes available to subsequent middlewares and the final route handler:
const requestIdMiddleware = new Middleware({
  handler: async () => ({
    requestId: crypto.randomUUID(),
  }),
});

const authMiddleware = new Middleware({
  handler: async () => ({
    user: { id: "123", role: "admin" },
  }),
});

api.defineRoute({
  path: "/admin",
  method: "POST",
  middlewares: [requestIdMiddleware, authMiddleware] as const,
  response: {
    200: z.object({ message: z.string() }),
  },
  handler: async (c) => {
    // Access data from both middlewares
    const requestId = c.get("requestId");
    const user = c.get("user");

    console.log(`Request ${requestId} by user ${user.id}`);
    return c.json(200, { message: "Success" });
  },
});

Type Safety

Middleware data is fully typed. TypeScript automatically infers the types from the data you return:
const typedMiddleware = new Middleware({
  handler: async (c) => {
    return {
      userId: "123",
      isAdmin: true,
      permissions: ["read", "write"],
    };
  },
});

api.defineRoute({
  path: "/test",
  method: "GET",
  middlewares: [typedMiddleware] as const,
  response: {
    200: z.object({ ok: z.boolean() }),
  },
  handler: async (c) => {
    // TypeScript infers these types automatically:
    const userId = c.get("userId"); // string
    const isAdmin = c.get("isAdmin"); // boolean
    const permissions = c.get("permissions"); // string[]

    return c.json(200, { ok: true });
  },
});

Execution Order

Multiple Middlewares

Middlewares execute in order, accumulating context data:
  1. Global middlewares (from API constructor) run first
  2. Route-specific middlewares run after global middlewares
  3. Within each group, middlewares run in array order
  4. Each middleware can access data from previous middlewares
// Global: runs on all routes (defined in constructor)
const api = new Api({
  middlewares: [loggingMiddleware] as const,
});

// Route-specific: runs only on this route (after logging)
api.defineRoute({
  path: "/admin",
  method: "GET",
  middlewares: [authMiddleware, adminRoleMiddleware] as const,
  response: {
    200: z.object({ data: z.string() }),
  },
  handler: async (c) => {
    // Has access to data from all three middlewares
    const startTime = c.get("requestStartTime"); // from loggingMiddleware
    const user = c.get("user"); // from authMiddleware

    return c.json(200, { data: "Admin data" });
  },
});
Execution flow:
  1. loggingMiddleware executes → adds requestStartTime
  2. authMiddleware executes → adds user
  3. adminRoleMiddleware executes → checks user.role
  4. Route handler executes → has access to all context data

Early Return

Middlewares can return a Response to short-circuit the request. When this happens, subsequent middlewares and the route handler won’t execute:
const rateLimitMiddleware = new Middleware({
  response: {
    429: z.object({ error: z.string() }),
  },
  handler: async (c) => {
    const ip = c.raw.headers.get("x-forwarded-for");

    if (await isRateLimited(ip)) {
      // Return Response - handler won't execute
      return c.json(429, { error: "Too many requests" });
    }

    // Return data - continue to next middleware/handler
    return { ip };
  },
});

Schema Validation

Middlewares can define request and response schemas that are validated independently.

Validation Behavior

  • Each middleware validates its request data against its own schema before executing
  • Route validates its request data against its own schema after all middlewares complete
  • All schemas must pass validation - there is no merging or replacement
  • Different properties (body vs. query vs. headers) from different middlewares and routes are all validated
const apiKeyMiddleware = new Middleware({
  request: {
    headers: z.object({
      "x-api-key": z.string(),
    }),
  },
  response: {
    403: z.object({ error: z.string() }),
  },
  handler: async (c) => {
    const apiKey = c.req.headers["x-api-key"];

    if (!isValidApiKey(apiKey)) {
      return c.json(403, { error: "Invalid API key" });
    }

    return { apiKeyValid: true };
  },
});

// Route with additional headers
api.defineRoute({
  path: "/data",
  method: "GET",
  middlewares: [apiKeyMiddleware] as const,
  request: {
    headers: z.object({
      "accept-language": z.string().optional(),
    }),
  },
  response: {
    200: z.object({ data: z.array(z.string()) }),
  },
  handler: async (c) => {
    // Both x-api-key (from middleware) and accept-language (from route) are validated
    const lang = c.req.headers["accept-language"];

    return c.json(200, { data: ["item1", "item2"] });
  },
});

Common Patterns

Authentication Middleware

const authMiddleware = new Middleware({
  request: {
    headers: z.object({
      authorization: z.string(),
    }),
  },
  response: {
    401: z.object({ error: z.string() }),
  },
  handler: async (c) => {
    const token = c.req.headers.authorization;

    if (!token || !token.startsWith("Bearer ")) {
      return c.json(401, { error: "Unauthorized" });
    }

    const user = await validateToken(token.slice(7));

    if (!user) {
      return c.json(401, { error: "Invalid token" });
    }

    return { user };
  },
});

Parameterized Middlewares

Create reusable middleware factories:
const createRoleMiddleware = (requiredRole: string) => {
  return new Middleware({
    response: {
      403: z.object({ error: z.string() }),
    },
    handler: async (c) => {
      const user = c.get("user"); // From authMiddleware

      if (user.role !== requiredRole) {
        return c.json(403, { error: "Forbidden" });
      }

      return {};
    },
  });
};

// Use different roles for different routes
api.defineRoute({
  path: "/admin",
  method: "GET",
  middlewares: [authMiddleware, createRoleMiddleware("admin")] as const,
  response: {
    200: z.object({ message: z.string() }),
  },
  handler: async (c) => {
    return c.json(200, { message: "Admin area" });
  },
});

api.defineRoute({
  path: "/moderator",
  method: "GET",
  middlewares: [authMiddleware, createRoleMiddleware("moderator")] as const,
  response: {
    200: z.object({ message: z.string() }),
  },
  handler: async (c) => {
    return c.json(200, { message: "Moderator area" });
  },
});

CORS Middleware

const corsMiddleware = new Middleware({
  handler: async (c) => {
    // CORS would typically be handled at response time,
    // but you can add headers here if needed
    return { corsEnabled: true };
  },
});

Database Transaction Middleware

const transactionMiddleware = new Middleware({
  handler: async (c) => {
    const tx = await db.beginTransaction();

    return { transaction: tx };
  },
});

api.defineRoute({
  path: "/transfer",
  method: "POST",
  middlewares: [transactionMiddleware] as const,
  request: {
    body: z.object({
      from: z.string(),
      to: z.string(),
      amount: z.number(),
    }),
  },
  response: {
    200: z.object({ success: z.boolean() }),
  },
  handler: async (c) => {
    const tx = c.get("transaction");

    try {
      await debit(tx, c.req.body.from, c.req.body.amount);
      await credit(tx, c.req.body.to, c.req.body.amount);
      await tx.commit();

      return c.json(200, { success: true });
    } catch (error) {
      await tx.rollback();
      throw error;
    }
  },
});

Request Context Middleware

const contextMiddleware = new Middleware({
  handler: async (c) => {
    return {
      requestId: crypto.randomUUID(),
      timestamp: Date.now(),
      ip: c.raw.headers.get("x-forwarded-for") || "unknown",
      userAgent: c.raw.headers.get("user-agent") || "unknown",
    };
  },
});

Logging Middleware

const loggingMiddleware = new Middleware({
  handler: async (c) => {
    const start = Date.now();
    console.log(`${c.raw.method} ${c.raw.url}`);

    return {
      requestStartTime: start,
    };
  },
});

Handler Context

The middleware handler receives a context object (c) with:

Request Data

  • c.req.body - Validated request body
  • c.req.params - Validated path parameters
  • c.req.query - Validated query parameters
  • c.req.headers - Validated headers
  • c.req.cookies - Validated cookies
  • c.raw - Underlying Request object

Response Methods

  • c.json(status, data) - JSON response with validation
  • c.text(status, text) - Plain text response
  • c.html(status, html) - HTML response
  • c.redirect(status, url) - HTTP redirect

Context Accessors

  • c.get(key) - Access data from previous middlewares

Best Practices

  1. Use as const on middleware arrays to preserve type inference
  2. Return objects for context extension, not Response objects, unless you want to short-circuit
  3. Keep middlewares focused - each middleware should do one thing well
  4. Order matters - place auth before role checks, logging before everything
  5. Validate early - use request schemas to catch invalid data before handler execution
  6. Use parameterized factories for reusable middleware patterns
  7. Access previous middleware data using c.get() in dependent middlewares

Build docs developers (and LLMs) love