Middleware design seem very hard to do correct

I’m creating an http micro-framework and the thing I can’t fully understand is middlewares.
I’ve looked up zap to see how they do this, but they only do global chain of middlewares, which I don’t personally like. I would like to have global chains and per-route middlewares. But there’s another problem.

Approach 1. Let’s say I create a logger:

pub const Handler = struct {
    userdata: ?*anyopaque,
    call_fn: *const fn (?*anyopaque, Context) anyerror!void,
};

pub const Logger = struct {
    next: Handler,
    pub fn handler(self: *Logger) Handler {
        return .{ .userdata = self, .call_fn = Logger.callFn };
    }
    pub fn callFn(erased: ?*anyopaque, context: Context) !void {
        const logger: *Logger = @ptrCast(@alignCast(erased));
        // Imagine some useful work here.
        retrun try logger.callFn(context);
    }
};

This approach works, but if I want to create per-route middleware I’ll have to create as many instances as there are routes, which becomes verbose really fast.

I considered to create a helper method which will allocate middleware on heap, but then I’ll have to put deinit in Handler interface to prevent memory leaks. And also creating allocation for every middleware on every route seem a bit too much, I might be wrong though. This pushed me towards next approach.

Approach 2:

pub const Handler = struct {
    userdata: ?*anyopaque,
    call_fn: ErasedHandler,
    const ErasedHandler = *const fn (userdata: ?*anyopaque, context: *Context, next: mw.Chain.Next) anyerror!Response;
};

pub const Logger = struct {
    pub fn handler(self: *Logger) Handler {
        return .{ .userdata = null, .call_fn = Logger.callFn };
    }
    pub fn callFn(erased: ?*anyopaque, context: Context, next: Chain.Next) !void {
        const logger: *Logger = @ptrCast(@alignCast(erased));
        // Imagine some useful work here.
        retrun try next.call(context);
    }
};

pub const Chain = struct {
    next: Next,

    pub const Next = struct {
        handlers: []const Handler,

        pub const empty: Next = .{ .handlers = &.{} };

        pub fn call(self: Next, context: *Context) anyerror!Response {
            std.debug.assert(self.handlers.len > 0);
            const h = self.handlers[0];
            const next: Next = .{ .handlers = self.handlers[1..] };
            return h.call_fn(h.userdata, context, next);
        }
    };

    pub fn new(handlers: []const Handler) Chain {
        return .{ .next = .{ .handlers = handlers } };
    }

    pub fn handler(self: *Chain) Handler {
        return .{ .userdata = self, .call_fn = Chain.callFn };
    }

    fn callFn(erased: ?*anyopaque, context: *Context, _: Next) anyerror!Response {
        const chain: *Chain = @ptrCast(@alignCast(erased));
        return try chain.next.call(context);
    }
};

This approach does lead to a better dx, but leaves ErasedHandler with next even if it’s never used, like in endpoint. Though i erase a type in such a way that Next cannot be an argument of an endpoint, but if created manually without type erasure it’s still possible.

Now if I want to have a chain of middlewares I’ll have to create Chain instance for every of them,
but I don’t have to create instances of middlewares, because they are they don’t have a state about next. It leads to this code:

var logger_instance = mw.Logger.new();
const logger = logger_instance.handler();

var chain = mw.Chain.new(&.{ logger, .new(ping) });
try router.insert(gpa, "/ping", chain.handler());

It looks cleaner, but I still have to create chain instance for every route, because handler needs mutable pointer.

I have these 2 approaches in mind but both of them feels like I’m missing something important.
I’m also aware of rust/tower like approach, but it doesn’t feel feasible to do in zig comfortably and go/http approach. It was simple, but we don’t have closures and if we do it by hand we’ll have problems from approach 1 again.

What am I missing here?