Middleware

> Add middleware for authentication, logging, CORS, rate limiting, and custom request processing in Velocity.

Middleware wraps Velocity handlers to add cross-cutting concerns — auth, logging, CORS, rate limiting, CSRF. Each middleware receives the next handler in the chain and returns a new handler.

Signature

Velocity middleware uses the router.MiddlewareFunc type:

type HandlerFunc    func(*router.Context) error
type MiddlewareFunc func(next HandlerFunc) HandlerFunc

A minimal middleware:

package middleware

import "github.com/velocitykode/velocity/router"

func Logging(next router.HandlerFunc) router.HandlerFunc {
    return func(c *router.Context) error {
        c.Log().Info("request",
            "method", c.Request.Method,
            "path",   c.Request.URL.Path,
        )
        return next(c)
    }
}

Stacks: global, web, API

Applications typically split middleware into three scopes:

  • Global — runs on every request (logging, CORS, recovery, maintenance mode)
  • Web — runs on browser/HTML routes (sessions, CSRF, view engine)
  • API — runs on JSON API routes (rate limiting, JSON enforcement)

You declare them in a single function passed to v.Middleware(...):

// internal/app/middleware.go
package app

import (
    "myapp/internal/middleware"

    "github.com/velocitykode/velocity"
    "github.com/velocitykode/velocity/csrf"
    "github.com/velocitykode/velocity/view"
)

func Middleware(m *velocity.MiddlewareStack) {
    // Runs on every request.
    m.Global(
        middleware.LoggingMiddleware,
        middleware.TrustProxiesMiddleware,
        middleware.CORSMiddleware,
        middleware.PreventRequestsDuringMaintenanceMiddleware,
        middleware.ValidatePostSizeMiddleware(10<<20),
        middleware.TrimStringsMiddleware,
        middleware.ConvertEmptyStringsToNullMiddleware,
    )

    // The CSRF and view middleware need their service instances —
    // pull them from the *app.Services container.
    s := m.Services()
    csrfInstance := s.CSRF.(*csrf.CSRF)
    viewEngine := s.View.(*view.Engine)

    // Runs on routes inside r.Web(...).
    m.Web(
        middleware.SessionMiddleware,    // session cookie before CSRF
        middleware.CSRFTokenMiddleware,  // expose token to templates
        csrfInstance.RouterMiddleware(), // validate CSRF on unsafe methods
        viewEngine.Middleware(),         // Inertia version + headers
    )

    // Runs on routes inside r.API(prefix, ...).
    m.API(
        middleware.EnsureJSONMiddleware,
    )
}

Wire it into main.go via the bootstrap chain:

chain := v.
    Providers(app.Configure).
    Middleware(app.Middleware).        // <— this function
    Routes(routes.Register).
    Events(app.Events(v.Log))

When you register routes through v.Routes(...), the r.Web(...) group automatically gets the web stack and r.API(prefix, ...) gets the API stack — see Routing.

Per-group and per-route middleware

Inside a Web or API closure, attach middleware to a sub-group or a single route:

func Register(r *velocity.Routing) {
    r.Web(func(web router.Router) {
        // Group with middleware — applies to every route in the closure.
        web.Group("", func(auth router.Router) {
            auth.Get("/dashboard", handlers.Dashboard).Name("dashboard")
            auth.Get("/account", handlers.Account)
        }).Use(middleware.Auth)

        // Single route with middleware.
        web.Post("/contact", handlers.Contact).Use(middleware.RateLimit(5))
    })
}

Group("", fn).Use(mw) is the idiom for grouping routes purely so they share middleware. .Use(mw) after a verb method attaches middleware to just that one route.

Order of execution

Middleware wraps from the outside in. The first middleware in the list runs first on the way in and last on the way out:

m.Global(
    middleware.RecoveryMiddleware,  // outermost — catches panics from everything below
    middleware.LoggingMiddleware,   // logs the request
    middleware.AuthMiddleware,      // innermost — runs just before the handler
)

Within a request, scopes run in this order: global → web/API → group → route → handler.

Parameterized middleware

Middleware that needs configuration returns a MiddlewareFunc:

func RateLimit(requestsPerMinute int) router.MiddlewareFunc {
    limiter := rate.NewLimiter(rate.Limit(requestsPerMinute)/60, requestsPerMinute)

    return func(next router.HandlerFunc) router.HandlerFunc {
        return func(c *router.Context) error {
            if !limiter.Allow() {
                return c.JSON(http.StatusTooManyRequests, map[string]string{
                    "error": "rate limit exceeded",
                })
            }
            return next(c)
        }
    }
}

// Usage — call once at registration to capture the limiter, then attach.
m.API(middleware.RateLimit(100))

The limiter is built once in the outer call. Every request shares it — exactly what you want for rate limiting.

Passing data through context

Use the request context to hand data from middleware to the handler:

func Auth(next router.HandlerFunc) router.HandlerFunc {
    return func(c *router.Context) error {
        user, err := authenticate(c.Request)
        if err != nil {
            return c.Redirect(http.StatusSeeOther, "/login")
        }

        ctx := context.WithValue(c.Request.Context(), "user", user)
        c.Request = c.Request.WithContext(ctx)
        return next(c)
    }
}

// In the handler
func Dashboard(c *router.Context) error {
    user := c.Request.Context().Value("user").(*models.User)
    return c.JSON(http.StatusOK, user)
}

For request-scoped values that don’t need to flow into downstream handlers, c.Set(key, value) / c.Get(key) is the lighter option — see HTTP Router > Per-request storage.

Common patterns

Short-circuit

Return without calling next to stop the chain. Useful for auth/guest gates:

func Guest(next router.HandlerFunc) router.HandlerFunc {
    return func(c *router.Context) error {
        if auth.FromContext(c).Check(c.Request) {
            return c.Redirect(http.StatusSeeOther, "/dashboard")
        }
        return next(c)
    }
}

Recover from panics

func Recovery(next router.HandlerFunc) router.HandlerFunc {
    return func(c *router.Context) (err error) {
        defer func() {
            if r := recover(); r != nil {
                c.Log().Error("panic recovered",
                    "value", r,
                    "stack", debug.Stack(),
                )
                err = fmt.Errorf("internal server error")
            }
        }()
        return next(c)
    }
}

Register Recovery first in the global stack so it wraps everything.

CORS

func CORS(next router.HandlerFunc) router.HandlerFunc {
    return func(c *router.Context) error {
        c.Response.Header().Set("Access-Control-Allow-Origin", "*")
        c.Response.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
        c.Response.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if c.Request.Method == http.MethodOptions {
            c.Response.WriteHeader(http.StatusOK)
            return nil
        }
        return next(c)
    }
}

Testing middleware

For unit tests, call the middleware directly with a stub next-handler:

func TestAuthRedirectsGuests(t *testing.T) {
    called := false
    next := func(c *router.Context) error {
        called = true
        return nil
    }

    req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
    rec := httptest.NewRecorder()
    c := router.NewContext(rec, req)

    if err := middleware.Auth(next)(c); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if called {
        t.Fatal("next handler should not run for unauthenticated request")
    }
    if rec.Code != http.StatusSeeOther {
        t.Errorf("expected 303, got %d", rec.Code)
    }
}

For end-to-end tests through the full middleware chain, register the middleware on a fresh router and exercise it via httptest.

Best practices

  • Keep each middleware focused on a single concern.
  • Register Recovery first in the global stack so it wraps everything.
  • Prefer context values over package globals for per-request state.
  • For expensive setup (rate limiter, DB pool), build once in the outer function and capture it in the closure — don’t construct it inside the inner handler on every request.