MiddlewarePipeline chains middleware together and short-circuits to a Response when one rejects the request.
The pipeline works in both router styles. The route module wrapper changes:
- App router API routes live at
app/api/**/route.tsand export named HTTP method handlers such asGETorPOST. The handler receives theRequestdirectly. - Pages router API routes live at
pages/api/**and export named HTTP method handlers or adefaultfallback handler. The handler receives anAPIContextasctx; usectx.requestwhen a middleware expects aRequest.
Prerequisites
- At least one API route in your project (see API routes).
- The dev server running so you can hit the routes with
curl.
Built-in middleware
CORS
Rate limiting
keyGenerator with a trusted client or
account identifier.
Logging
Timeout
Pipeline composition
Combine middleware into a pipeline:Route-specific middleware
Apply middleware only to matching URL patterns:Run the pipeline
Usehandle() to run the middleware chain and then your route handler. If a middleware short-circuits (returns a Response, for example a rate-limit rejection), handle() returns that response and your handler never runs; otherwise your handler runs as the terminal step:
ctx.request:
handle()vsexecute().execute()is a lower-level variant with no terminal handler: it returns the short-circuiting middleware’sResponse, or a synthesized 404 Not Found when the chain passes through. It always resolves to aResponse(neverundefined), soif (await pipeline.execute(request))is always truthy; useexecute()only when a middleware is always expected to produce the response. For the common “middleware, then my route handler” case, preferhandle().
In-memory state across requests
Middleware and route handlers created at module scope, for example arateLimit() store or a module-level counter, behave differently by environment:
- In development, the dev server re-evaluates each route module on every request so edits hot-reload. A fresh module scope means module-level variables and default in-memory stores are re-created per request: a counter always reads back its initial value, and the default in-memory rate-limit store never accumulates across requests. To exercise threshold behavior in dev, drive the pipeline multiple times within a single request, or use an external store.
- In production, the compiled route module is cached per release, so module-scoped state persists across requests within one server process and one release. It is still not shared across multiple instances, and it resets on every redeploy (and under memory-pressure eviction).
RateLimitStore interface and the Redis example in the rate-limit reference) rather than module-scoped memory.
Cleanup callbacks
Register teardown logic that runs once per request, after the response body has finished, been canceled, or errored:onTeardown callbacks run for every handle() and execute() call, so a
module-scoped route pipeline fires them on each request. For streamed responses,
cleanup waits until the body reaches EOF, is canceled by the consumer, or
errors. Bodyless, locked, or already-read responses and handler or middleware
exceptions clean up before the handle()/execute() promise resolves. Callback
errors are logged and swallowed, never surfaced to the client.
For long-lived pipelines that need one-shot cleanup on shutdown rather than per
request, call pipeline.teardown() explicitly. Unlike the per-request run,
teardown() drains and discards the callbacks so they never fire again.
Custom middleware
A middleware is a function that receives a context object and anext function. Access the request via c.request:
Project-wide root middleware
Addmiddleware.ts, middleware.js, or middleware.mjs at the project root to run middleware before every project route. Export one middleware function or an array of functions:
c.env.
Production middleware is cached by project, environment, and immutable release or preview branch. Preview cache invalidation reloads the file after source changes, and the cache has a fixed entry limit. A missing file passes through normally.
Root middleware runs in front of your project’s routes, not in front of the platform’s. A control-plane dispatch is the signed request the platform sends to your runtime to build a release asset manifest for a deploy, or to start, resume, or cancel a run. It bypasses root middleware and goes straight to the handler that verifies its signature.
Middleware could not authorize one of these requests in any case. Infrastructure headers, including the dispatch signature, are withheld from project code, so middleware that gates on a credential rejects the platform’s request to build your own deploy.
The signature-keyed bypass is narrow. It applies only to a request that both addresses one of those platform routes and carries the signature header the receiving handler verifies. An unsigned request to POST /api/control-plane/runs/{runId}/execute, an unsigned request to the agents list route, and any other path under /api/control-plane/, including your own routes in that namespace, still run your middleware.
Three run-lifecycle routes are a longstanding exception and bypass middleware whether or not they are signed: POST /api/control-plane/runs/{runId}/stream, POST /api/control-plane/runs/{runId}/resume, and DELETE /api/control-plane/runs/{runId}. Do not rely on middleware to gate those three paths.
A signed channel dispatch bypasses root middleware on the same terms. This is the request the platform sends to POST /channels/invoke to run one of your agents on a message from Slack or Discord, and it carries its own envelope under its own header, verified by the channel handler rather than by the control-plane signature check. Your middleware does not run for your project’s channel traffic. An unsigned request to POST /channels/invoke still runs your middleware.
Production loading is fail-closed. If a declared middleware file cannot be read, compiled, or validated as a middleware export, a dedicated server does not start and a shared server returns an error only for the affected project request. Failed shared loads are not cached, so a corrected deployment can recover without restarting unrelated projects. Development loading remains nonfatal and reports the loading error in the server log.
Verify it worked
Hit a route with and without the headers the middleware expects:Origin header and confirm
Access-Control-Allow-Origin is set on the response.