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.