[ Building Remix apps ]
Wire the router: middleware, context and services
Build the global middleware chain once, publish services onto the request context, and let tests swap them.
Last updated 2026-09-29
Every request your Worker serves passes through one router, and the router's global middleware
decides what a handler can count on before it runs: a log, a trace, the user agent, and your
own services. This guide adds @sdxc/http, @sdxc/logger,
@sdxc/trace-context and @sdxc/user-agent to that
chain in a single composition root, then publishes a database opened through
@sdxc/data-table-d1 as ctx.db, so handlers read it and a test
replaces it without touching a handler.
npm add remix @sdxc/http @sdxc/logger @sdxc/trace-context \
@sdxc/user-agent @sdxc/data-table-d1
The logger, the trace and asyncContext() all keep per-request state in AsyncLocalStorage,
so enable the nodejs_compat compatibility flag on the Worker.
One logger per worker
A logger is configuration, not a per-request object. Create it once in its own module, so the
router and anything else that opens a log (a job dispatcher, a cron handler) write records
under the same service name and a query can group them.
import { createLogger } from "@sdxc/logger";
export const logger = createLogger({ service: "my-app" });
The log(logger) middleware opens one wide record per request, publishes it as ctx.log,
and writes it once the response settles. Handlers never construct a logger; they add fields
to the one already open.
The composition root
Build the router in a function rather than at module scope. The Worker calls it per request, and a test calls the same function, so what the test drives is the chain production runs.
import type { Middleware } from "remix/router";
import { headRequests } from "@sdxc/http/middleware/head-requests";
import { log } from "@sdxc/logger/middleware";
import { trace } from "@sdxc/trace-context/middleware";
import { userAgent } from "@sdxc/user-agent/middleware";
import { asyncContext } from "remix/middleware/async-context";
import { createRouter } from "remix/router";
import defaultHandler from "~/app/http/controllers/default-handler";
import home from "~/app/http/controllers/home";
import routes from "~/routes/web";
import { logger } from "./logger";
export default function application() {
let middleware: Middleware[] = [
headRequests(),
asyncContext(),
log(logger) as Middleware,
trace() as Middleware,
userAgent(),
// …then the Remix middleware your app already runs:
// cop(), formData(), a renderer
];
let router = createRouter({ middleware, defaultHandler });
router.map(routes.home, home);
return router;
}
The order carries meaning:
headRequests()goes first. The router matches methods strictly, so without it aHEADprobe from a monitor falls through to the 404 handler. It dispatches theHEADas aGETand strips the body, and because it leads the chain, aHEADruns through every guard aGETdoes.asyncContext()is there for@sdxc/user-agent/helpers. Predicates such asisTouch()read the current request through it when called with no argument, so a component deep inside a render asks without being handed anything.trace()afterlog(). It continues the caller'straceparentor starts a trace, publishes it asctx.trace, and stampstrace_idandspan_idon the log that is already open.userAgent()anywhere before the handlers. It reads theUser-Agentheader once and publishes the parsed browser, engine, system and device asctx.userAgent.
The Remix middleware after them (cross-origin protection, form parsing, rendering) is unchanged by any of this; see the Remix documentation for those.
The array is annotated Middleware[], so a middleware whose return type describes what it
adds to the context is cast to the plain type. The properties stay typed anyway: each package
augments RequestContext in remix/router, so installing log() is what makes ctx.log
exist in your editor.
Publishing a service
A service a handler needs, such as the database, belongs on the context rather than in an
import. Middleware creates a context key, declares the property on RequestContext, and sets
the value per request.
import type { Database as DataTable } from "remix/data-table";
import type { Middleware } from "remix/router";
import { createContextKey } from "remix/router";
export const Database = createContextKey<DataTable>();
declare module "remix/router" {
interface RequestContext {
db: DataTable;
}
}
export default function database(source: () => DataTable): Middleware {
return (ctx, next) => {
ctx.set(Database, source(), { property: "db" });
return next();
};
}
The middleware takes a function that opens the database, not the database itself. The production source reads the D1 binding at request time, so each request uses the binding it was served with:
import { createD1DatabaseAdapter } from "@sdxc/data-table-d1";
import { env } from "cloudflare:workers";
import { Database } from "remix/data-table";
export function openDatabase(): Database {
return new Database(createD1DatabaseAdapter(env.DB));
}
A test hands in a source of its own. Pass it through the composition root, defaulting to the production one:
import type { Database as DataTable } from "remix/data-table";
import type { Middleware } from "remix/router";
import { headRequests } from "@sdxc/http/middleware/head-requests";
import { log } from "@sdxc/logger/middleware";
import { trace } from "@sdxc/trace-context/middleware";
import { userAgent } from "@sdxc/user-agent/middleware";
import { asyncContext } from "remix/middleware/async-context";
import { createRouter } from "remix/router";
import defaultHandler from "~/app/http/controllers/default-handler";
import home from "~/app/http/controllers/home";
import project from "~/app/http/controllers/project";
import database from "~/app/http/middleware/database";
import { openDatabase } from "~/app/lib/database";
import routes from "~/routes/web";
import { logger } from "./logger";
export default function application(openDb: () => DataTable = openDatabase) {
let middleware: Middleware[] = [
headRequests(),
asyncContext(),
log(logger) as Middleware,
trace() as Middleware,
userAgent(),
database(openDb),
// …then cop(), formData() and a renderer, as before
];
let router = createRouter({ middleware, defaultHandler });
router.map(routes.home, home);
router.map(routes.project, project);
return router;
}
Reading it from a handler
A handler reads everything from ctx, and adds what it learned to the log instead of
writing a line of its own. Project is your model and ProjectPage your view.
import * as s from "remix/data-schema";
import { createAction } from "remix/router";
import Project from "~/app/data/project";
import ProjectPage from "~/resources/views/project";
import routes from "~/routes/web";
export default createAction(routes.project, async (ctx) => {
let { id } = s.parse(s.object({ id: s.string() }), ctx.params);
ctx.log.set({ project: { id } });
let project = await ctx.log.time("db", () => Project.find(ctx.db, id));
return ctx.render(
<ProjectPage project={project} browser={ctx.userAgent.browser} />,
);
});
ctx.log.time("db", …) adds db.count and db.duration_ms to the request's single record,
and project.id becomes a field you can filter on. Code with no ctx in reach calls
currentLog() from @sdxc/logger, which returns the same log.
Swapping it in a test
Because the service arrives through the composition root, a test builds the real router with
a different source and sends it a request. createTestDatabase is a helper of your own that
opens an in-memory D1 database with your schema applied.
import { expect, test } from "vitest";
import { createTestDatabase } from "~/app/lib/test/database";
import application from "./app";
test("the home page lists projects", async () => {
let db = await createTestDatabase();
let router = application(() => db);
let response = await router.fetch(new Request("https://app.test/"));
expect(response.status).toBe(200);
});
Nothing is mocked: the log, the trace and the rest of the chain run as they do in production, and only the database differs.
The Worker entry is then a thin wrapper:
import application from "./app";
export default {
async fetch(request: Request) {
return await application().fetch(request);
},
} satisfies ExportedHandler<Cloudflare.Env>;
Where to go next
Validate forms and route params — what to do with
ctx.formDataandctx.paramsonce they arrive.Build the interface with remix/ui — what goes inside
ctx.render.Logs, traces and timings — getting more out of
ctx.logandctx.trace.Test Workers apps — building
createTestDatabaseand testing against real bindings.