Middleware
Server-side request interceptors that run before loaders and API routes. Use them for authentication, redirects, request validation, and context enrichment.
Defining Middleware
Middleware wraps the rest of the request — loaders, API handlers, and any
inner middleware — using a next() function. Modules live in
src/middleware/ and export a middleware function:
import { redirect, type MiddlewareFn } from "@pracht/core";
export const middleware: MiddlewareFn = async ({ request }, next) => {
const session = await getSession(request);
// Short-circuit: return without calling next()
if (!session) {
return redirect("/login", { request });
}
// Continue to the rest of the chain (and the loader/handler)
return next();
};Calling await next() runs the rest of the request and resolves to the final
Response. That means middleware can wrap try/catch/finally around the whole
request — useful for logging, tracing, and timing:
import type { MiddlewareFn } from "@pracht/core";
export const middleware: MiddlewareFn = async ({ request }, next) => {
const span = startSpan({ url: request.url, method: request.method });
try {
const response = await next();
span.setAttribute("status", response.status);
return response;
} catch (err) {
span.recordError(err);
throw err;
} finally {
span.end();
}
};Applying Middleware
Register middleware by name in defineApp, then reference them in routes or groups:
export const app = defineApp({
middleware: {
auth: "./middleware/auth.ts",
rateLimit: "./middleware/rate-limit.ts",
},
routes: [
// Applied to a single route
route("/profile", "./routes/profile.tsx", { middleware: ["auth"] }),
// Applied to a group — all children inherit
group({ middleware: ["auth"], shell: "app" }, [
route("/dashboard", "./routes/dashboard.tsx"),
route("/settings", "./routes/settings.tsx"),
]),
],
});Middleware Stacking
Middleware from groups and routes is combined. A route inside a group with ["auth"] that also declares ["rateLimit"] runs both in order:
auth(from group)rateLimit(from route)- Loader / API route
Middleware Results
Middleware always returns a Response. There are two ways to produce one:
| Return | Effect |
|---|---|
return next() |
Continue to the next middleware (or loader/handler) and return its response |
return redirect(...) |
Short-circuit with a redirect; pass { request } for method-aware 302/303 defaults |
return new Response(...) |
Short-circuit with any custom response |
If middleware returns without calling next(), the rest of the chain — and
the loader/handler — is skipped.
Mutating context
Middleware can read and mutate args.context directly. Earlier middleware
sets values, later middleware (and the loader/API handler) sees them:
export const middleware: MiddlewareFn = async ({ context, request }, next) => {
(context as { user?: User }).user = await getSession(request);
return next();
};The context object is shared by reference — there's no merge step.
Without a Manifest (Higher-Order Functions)
When using the pages router (or any setup without routes.ts), there is no manifest to register middleware in. Instead, wrap API handlers with plain higher-order functions:
import type { ApiRouteArgs, ApiRouteHandler } from "@pracht/core";
export function withAuth(handler: ApiRouteHandler): ApiRouteHandler {
return async (args: ApiRouteArgs) => {
const session = args.request.headers.get("cookie")?.includes("session=");
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(args);
};
}import { withAuth } from "../lib/with-auth";
export const GET = withAuth(({ request }) => {
return Response.json({ user: "Alice" });
});Multiple wrappers compose naturally: withAuth(withRateLimit(handler)). See API Routes for more detail and stacking examples.