Guides

Middleware Stacks

A larger application can treat middleware as feature modules: each module publishes the context it owns, declares the dependencies it needs, and either continues the chain or handles its own request branch.

Use this shape when an application has enough middleware that order, dependencies, and host boundaries need to be visible in one place.

What the Stack Is Doing

Layer Job Middleware lesson
Worker adapter Maps (request, env, ctx) into host.env, host.runtime, and host.waitUntil Keep host details at the adapter edge.
Common middleware Adds database, i18n, email, storage, auth, and sync features Compose the app by dependency order.
Feature middleware Owns one context capability, and sometimes one request branch Keep feature setup and feature routes close together.
Final router Registers the public route tree and handlers after common middleware Route handlers receive the fully assembled context.

In code, the spine looks like this:

import {
  chain,
  toFetchHandler,
  type Middleware,
  type RequestContext,
  type RequestHandler,
} from 'alien-middleware'

import type { WorkerEnv } from './env'

declare const dbMiddleware: Middleware
declare const i18nMiddleware: Middleware
declare const sesMiddleware: Middleware
declare const storageMiddleware: Middleware
declare const authMiddleware: Middleware
declare const syncMiddleware: Middleware
declare const finalRouter: Middleware

const commonMiddleware = chain<WorkerEnv, {}, ExecutionContext>()
  .use(dbMiddleware)
  .use(i18nMiddleware)
  .use(sesMiddleware)
  .use(storageMiddleware)
  .use(authMiddleware)
  .use(syncMiddleware)

export const apiHandler = chain<WorkerEnv, {}, ExecutionContext>()
  .use(commonMiddleware)
  .use(finalRouter)

The middleware names and router are application modules. The important part is that each module is still a normal middleware or middleware chain.

The environment type and runtime type are declared once at the top of the stack. Downstream middleware can then use context.env(name) and context.host.runtime without restating the host contract on every route.

Put Host Concerns at the Adapter Edge

The Worker fetch wrapper is the only place that knows Cloudflare's (request, env, ctx) calling convention. It turns those arguments into the generic host object used by middleware.

function createWorkerFetchHandler(handler: RequestHandler) {
  return async (request: Request, env: WorkerEnv, ctx: ExecutionContext) => {
    try {
      return await toFetchHandler(handler, {
        host: {
          env(name) {
            return env[name as keyof WorkerEnv]
          },
          runtime: ctx,
          waitUntil(promise) {
            ctx.waitUntil(promise)
          },
        },
      })(request)
    } catch (error) {
      ctx.waitUntil(reportWorkerException(error, request, env))
      return Response.json(
        { message: 'Internal server error' },
        { status: 500 }
      )
    }
  }
}

Inside the chain, middleware only sees the portable context API:

context.env('DATABASE_URL')
context.host.runtime
context.waitUntil(writeAuditLog())

TRuntime does not need to be a { name: string } object. Use the host object that middleware needs at runtime.

Let Features Declare Their Dependencies

Feature middleware should be able to state the context it needs. For example, auth middleware can include the database and email middleware it depends on before it publishes auth and session().

type AuthContext = {
  auth: AuthService
  session(): Promise<Session | null>
}

const authMiddleware = chain<WorkerEnv, {}, ExecutionContext>()
  .use(dbMiddleware)
  .use(sesMiddleware)
  .use(context => {
    return {
      get auth() {
        return createAuth({
          database: context.db,
          ses: () => context.ses,
        })
      },
      session() {
        return context.auth.api.getSession({
          headers: context.request.headers,
        })
      },
    } satisfies AuthContext
  })

The top-level stack can still include dbMiddleware and sesMiddleware explicitly. The middleware runner skips duplicate middleware functions for a request, so shared dependencies can be declared both by a feature module and by the common stack without running twice.

That keeps feature modules portable: tests or smaller routers can use authMiddleware without remembering every prerequisite, while the application stack still shows the full dependency order.

Publish Lazy Context Resources

Stack-style middleware returns getters and methods instead of eagerly opening every resource. Getter descriptors are copied to the request context, so downstream code reads them as normal properties.

type DbContext = {
  db: WorkerDb
}

let db: WorkerDb | undefined

const dbMiddleware = chain(
  (context: RequestContext<WorkerEnv, {}, ExecutionContext>) => {
    const databaseUrl = getDatabaseUrl(context)

    return {
      get db() {
        if (!databaseUrl) {
          throw new Error('A database connection string is required')
        }

        return (db ??= createWorkerDb(databaseUrl))
      },
    } satisfies DbContext
  }
)

Use module-level caches for resources that can be reused across requests, such as database clients or SDK clients. Use closure variables for values that should be memoized only for the current request.

type I18nContext = {
  i18n(): Promise<WorkerI18n>
}

const i18nMiddleware = chain().use(context => {
  let i18n: Promise<WorkerI18n> | undefined

  return {
    i18n() {
      return (i18n ??= createI18n(
        context.request.headers.get('accept-language')
      ))
    },
  } satisfies I18nContext
})

This pattern lets the common stack include capabilities that only some routes need. Missing configuration fails when the route actually touches the resource, not when the worker starts or when unrelated routes run.

Let Feature Middleware Own Request Branches

Middleware does not have to be only context setup. A feature can publish context and then handle a path before the final route tree runs.

const authMiddleware = chain<WorkerEnv, {}, ExecutionContext>()
  .use(authContextMiddleware)
  .use(context => {
    if (context.url.pathname.startsWith('/api/auth/')) {
      return context.auth.handler(context.request)
    }
  })

For non-matching requests, return nothing and the common stack continues. For matching requests, return a Response and the rest of the request-phase chain is skipped.

Feature middleware can also be a router. A storage feature can add an uploadStorage context property and own a local upload endpoint before the final application router receives the request.

import { routes } from 'alien-middleware/router'

const storageContextMiddleware = chain().use(context => {
  return {
    get uploadStorage() {
      return getUploadStorage(context)
    },
  }
})

const storageMiddleware = routes(storageContextMiddleware)

storageMiddleware.use(
  'POST',
  '/api/uploads/local/:objectName',
  async context => {
    if (!context.uploadStorage.putLocalUpload) {
      return Response.json(
        { code: 'LOCAL_UPLOAD_NOT_CONFIGURED' },
        { status: 404 }
      )
    }

    await context.uploadStorage.putLocalUpload({
      body: context.request.body,
      objectName: context.params.objectName,
    })

    return Response.json({ ok: true })
  }
)

Keep this pattern for feature-owned endpoints: auth callbacks, local upload helpers, sync endpoints, webhooks, or other routes that need a feature's private setup before normal route handlers.

Bridge External Handlers Deliberately

Some libraries only accept a Request, not an alien-middleware context. The template bridges those libraries by creating a request handler from context getters, then calling it only on the path it owns.

type SyncContext = DbContext & AuthContext

const syncMiddleware = chain<WorkerEnv, SyncContext, ExecutionContext>()
  .use(context => {
    return {
      get syncHandler() {
        return getSyncHandler(
          () => context.db,
          () => context.auth
        )
      },
    }
  })
  .use(context => {
    if (context.url.pathname !== '/api/sync') {
      return
    }

    return context.syncHandler(context.request)
  })

When the external handler calls back later with only the original Request, use a WeakMap<Request, Value> for request-scoped data and delete it in a finally block after the external handler finishes.

const usersByRequest = new WeakMap<Request, User>()

async function handleSync(request: Request) {
  const user = await readAuthenticatedUser(request)

  if (!user) {
    return Response.json({ code: 'AUTH_REQUIRED' }, { status: 401 })
  }

  usersByRequest.set(request, user)

  try {
    return await syncHandler.handle(request)
  } finally {
    usersByRequest.delete(request)
  }
}

That keeps request-only library APIs from forcing global mutable state into the middleware context.

Share Env Readers Outside Requests

Scheduled jobs and maintenance tasks may need the same environment-dependent helpers as request middleware. Extract helpers around the smallest interface they need instead of requiring a full request context.

type EnvReader<TEnv extends object> = {
  env<TName extends keyof TEnv>(name: TName): TEnv[TName]
}

function getUploadStorage(context: EnvReader<WorkerEnv>) {
  const bucket = context.env('R2_UPLOADS')

  if (bucket) {
    return createR2UploadStorage(bucket)
  }

  return createB2UploadStorage({
    applicationKey: context.env('B2_APPLICATION_KEY'),
    applicationKeyId: context.env('B2_APPLICATION_KEY_ID'),
  })
}

Request middleware can pass context. Scheduled jobs can pass a tiny adapter over the host environment:

function scheduled(
  _controller: unknown,
  env: WorkerEnv,
  ctx: ExecutionContext
) {
  ctx.waitUntil(
    cleanupUploads({
      storage: getUploadStorage({
        env(name) {
          return env[name]
        },
      }),
    })
  )
}

Stack Checklist

Use this shape when an application has more than a few middleware modules:

Question Good stack answer
Where are host-specific arguments converted? In one adapter wrapper that calls toFetchHandler.
Where are app-wide dependencies ordered? In one common chain before route registration.
Can a feature run in isolation? Yes, it includes its own middleware prerequisites.
Can the common stack include the same prerequisite? Yes, duplicate middleware functions are skipped per request.
Are expensive resources opened eagerly? No, publish lazy getters or methods on the context.
Can a feature own its special endpoint? Yes, return a response from feature middleware before the final route tree.
Can non-request jobs reuse helpers? Yes, helpers accept small env-reader interfaces.

A good middleware stack is basically a spaceship crew roster: everyone has a station, and nobody opens the airlock until their route actually needs it.