Skip to content

Defining middleware in every route file becomes tedious as your application grows. You end up repeatedly importing/declaring the same authentication checks, logging setup, or body parser overrides across dozens of routes.

KosmoJS solves this with Cascading Middleware.

Create a use.ts file in any folder, and its default exported middleware automatically wraps all routes in that folder and its subfolders - no imports or manual wiring required.

This hierarchical approach lets you organize middleware the same way you organize routes, keeping related concerns together and eliminating repetition.

🎯 How it Works ​

The pattern is straightforward: place a use.ts file in a folder, and every route beneath that folder inherits its middleware.

txt
api/users/
β”œβ”€β”€ about/
β”‚   └── index.ts
β”œβ”€β”€ account/
β”‚   β”œβ”€β”€ index.ts
β”‚   └── use.ts
β”œβ”€β”€ index.ts
└── use.ts

In this structure:

  • users/use.ts wraps all routes under /api/users (including about, account, and the users index)
  • users/account/use.ts wraps only routes under /api/users/account

When a request hits /api/users/account, the middleware execution order is:

  1. Global middleware from api/use.ts
  2. users/use.ts (parent folder)
  3. users/account/use.ts (current folder)
  4. Route-specific middleware from users/account/index.ts
  5. Final route handler

Parent middleware always runs before child middleware. This predictable order mirrors the folder hierarchy, making it easy to reason about what executes when.

When you create a new use.ts file, KosmoJS instantly generates boilerplate content.

Depending on your editor, this content may appear immediately or after briefly unfocusing and refocusing the file.

The generated structure gives you a starting point:

api/users/use.ts
ts
import { use } from "_/front/api";

export default [
  use(async (ctx, next) => {
    // Your middleware logic here
    return next();
  })
];

You can define multiple middleware in a single use.ts file, and they'll execute in the order you define them.

πŸ’Ό Common Use Cases ​

Authentication ​

Apply authentication to entire route subtrees without repeating the logic:

api/admin/use.ts
ts
import { use } from "_/front/api";

export default [
  use(async (ctx, next) => {
    const token = ctx.headers.authorization?.replace("Bearer ", ""); 
    ctx.assert(token, 401, "Authentication required");

    const user = await verifyToken(token);
    ctx.assert(user.role === "admin", 403, "Admin access required");

    ctx.state.user = user;
    return next();
  })
];

Now every route under /api/admin requires admin authentication. Individual route files can focus on their business logic, assuming ctx.state.user is already validated and available.

Request Logging ​

Add structured logging for specific parts of your API:

api/payments/use.ts
ts
import { use } from "_/front/api";

export default [
  use(async (ctx, next) => {
    const start = Date.now(); 
    const requestId = crypto.randomUUID();

    ctx.state.requestId = requestId;
    console.log(`[${requestId}] ${ctx.method} ${ctx.path}`);

    try {
      await next();
    } finally {
      const duration = Date.now() - start;
      console.log(`[${requestId}] completed in ${duration}ms`);
    }
  })
]);

The onion model allows you to do work both before and after the route handler executes, perfect for timing, logging, or cleaning up resources.

Rate Limiting ​

Apply rate limits to specific endpoint groups:

api/public/use.ts
ts
import { use } from "_/front/api";
import rateLimit from "koa-ratelimit";

export default [
  use(
    rateLimit({
      driver: "memory",
      db: new Map(),
      duration: 60000, // 1 minute
      max: 100,
    })
  )
];

Public endpoints get rate limiting, while internal or authenticated endpoints (in different folders) don't inherit this restriction.

⚠️ Important Considerations ​

Parameter Availability ​

Cascading middleware run for all routes in their folder hierarchy, including routes that may not define all the parameters you expect.

txt
api/users/
β”œβ”€β”€ [id]/
β”‚   β”œβ”€β”€ posts/
β”‚   β”‚   └── index.ts    // Has 'id' param
β”‚   └── index.ts        // Has 'id' param
β”œβ”€β”€ index.ts            // NO 'id' param
└── use.ts

If users/use.ts tries to access ctx.params.id, it will work for /users/123 but ctx.params.id will be undefined for /users.

Keep middleware generic. Focus on concerns that apply uniformly - authentication, logging, body parsing - rather than parameter-specific logic.

If you need parameter validation or transformation, do it in the route handler itself or in route-specific middleware defined within that route's index.ts.

Middleware Hierarchy ​

Cascading middleware execute from the outermost scope inward.

For a request to /api/admin/users/123 the flow looks like:

txt
api/
β”œβ”€β”€ use.ts               // Runs 1st (global middleware)
└── admin/
    β”œβ”€β”€ use.ts           // Runs 2nd
    └── users/
        β”œβ”€β”€ use.ts       // Runs 3th
        └── [id]/
            └── index.ts // Runs 4th (route handler)

Handlers can not skip parent middleware. If a parent folder has use.ts, all child routes inherit it. This constraint keeps the middleware hierarchy predictable.

Method-Specific Middleware ​

Just like inline middleware, cascading middleware can run on specific HTTP methods:

api/users/use.ts
ts
import { use } from "_/front/api";

export default [
  use(
    async (ctx, next) => {
      // authentication logic
      return next();
    },
    { on: ["POST", "PUT", "PATCH", "DELETE"] }, 
  )
];

This middleware only runs for methods that modify data.

🎨 Multiple middleware in one file ​

A single use.ts can define multiple middleware functions. They execute in the order you define them:

api/users/use.ts
ts
import { use } from "_/front/api";

export default [
  // First: Logging
  use(async (ctx, next) => {
    console.log(`Request: ${ctx.method} ${ctx.path}`);
    return next();
  }),

  // Second: Authentication (only for certain methods)
  use(
    async (ctx, next) => {
      const token = ctx.headers.authorization?.replace("Bearer ", "");
      ctx.assert(token, 401, "Authentication required");
      ctx.state.user = await verifyToken(token);
      return next();
    },
    { on: ["POST", "PUT", "DELETE"] },
  ),

  // Third: Request timing
  use(async (ctx, next) => {
    const start = Date.now();
    await next();
    ctx.set("X-Response-Time", `${Date.now() - start}ms`);
  }),
];

This keeps related middleware organized in one place while maintaining clear separation of concerns.

πŸ’‘ Best Practices ​

Use for cross-cutting concerns: Authentication, logging, rate limiting, and CORS are perfect candidates. These apply broadly to groups of routes.

Keep it generic: Avoid parameter-specific logic or assumptions about request structure that won't hold for all routes in the hierarchy.

Use slots for overrides: Use the slot option when you need to replace global middleware - without slot your middleware runs alongside the global middleware instead of replacing it.

Organize by feature: If you have a /api/admin section with different authentication requirements, give it its own use.ts rather than adding conditional logic to a parent middleware.

Consider middleware composition: A deeply nested route might inherit middleware from multiple use.ts files. Make sure they compose well - each layer should add value without conflicting with parent middleware.

Document middleware behavior: Leave comments in your use.ts files explaining what they do and why they're needed. Future you (and your teammates) will appreciate the context.


Cascading middleware transform how you organize cross-cutting concerns. Instead of scattering the same authentication checks across dozens of files, you define them once at the appropriate level and let the hierarchy do the work.

As your API grows, this pattern keeps your codebase maintainable by mirroring your route structure in your middleware organization.

Released under the MIT License.