How to Use Fiber Middleware and Route Groups

Web
Fiber middleware executes before your route handlers, while route groups allow you to organize routes under a common prefix and share middleware across them.

The request chain problem

You are building a user API. You add a login route. Then a profile route. Then an admin route. Suddenly you realize every single route needs to check a JWT token. You copy the token check into five functions. Then you add logging. Now you are copying logging too. The code is bloated, and if the auth logic changes, you have to hunt down every route to fix it. This is where middleware and route groups save you. Middleware runs code before your handler. Route groups let you share that code across a set of paths without repetition.

Middleware and groups in plain words

Think of a request as a package moving through a factory. Before the package reaches the worker who actually builds the product, it passes through inspection stations. One station stamps the date. Another checks the weight. Another verifies the shipping label. If any station rejects the package, it never reaches the builder. Middleware is those stations. A route group is a dedicated assembly line where every package automatically goes through the same set of stations before hitting the specific workers.

Middleware is the bouncer. The handler is the party.

Minimal example

Here is the skeleton. A global middleware logs every request. A group applies a prefix and a second middleware.

package main

import (
	"log"

	"github.com/gofiber/fiber/v2"
)

func main() {
	app := fiber.New()

	// Global middleware runs for every single request, regardless of path
	app.Use(func(c *fiber.Ctx) error {
		log.Println("Request received")
		return c.Next() // Pass control to the next middleware or handler
	})

	// Group adds "/api" prefix and runs auth middleware for all routes inside
	api := app.Group("/api", func(c *fiber.Ctx) error {
		log.Println("Auth check passed")
		return c.Next()
	})

	api.Get("/users", func(c *fiber.Ctx) error {
		return c.SendString("Users list")
	})

	log.Fatal(app.Listen(":3000"))
}

c.Next() is the bridge. Cross it or the request falls.

How the chain executes

When a request hits /api/users, Fiber does not jump straight to the handler. It walks the chain. First, the global middleware runs. It logs "Request received". It calls c.Next(). That call is the handoff. Without c.Next(), the chain stops and the request hangs. Next, the group middleware runs. It logs "Auth check passed" and calls c.Next(). Finally, the handler runs and sends the response. The response bubbles back up through the chain, though middleware usually only cares about the downward trip. If any middleware returns an error or sends a response without calling c.Next(), the chain breaks. The handler never sees the request.

The chain flows down. The response flows up. Respect the direction.

Realistic structure with nested groups

Real apps need more than logging. You might validate tokens, check permissions, or wrap errors. Here is a structure with nested groups and error handling.

package main

import (
	"github.com/gofiber/fiber/v2"
)

// AuthMiddleware validates the token and stops the chain if missing
func AuthMiddleware(c *fiber.Ctx) error {
	token := c.Get("Authorization")
	if token == "" {
		// Returning an error response here prevents c.Next() from running
		return c.Status(401).SendString("Unauthorized")
	}
	return c.Next()
}

Convention aside: Fiber middleware often uses c.Locals() to pass data between middleware and handlers. Store the parsed user object in c.Locals("user", user) in auth middleware, then retrieve it in the handler. This keeps the handler clean and avoids re-parsing the token.

func main() {
	app := fiber.New()

	// Group applies AuthMiddleware to every route under /api
	api := app.Group("/api", AuthMiddleware)

	// Nested group adds another layer of middleware
	admin := api.Group("/admin", func(c *fiber.Ctx) error {
		// Check admin role logic here
		return c.Next()
	})

	admin.Get("/dashboard", func(c *fiber.Ctx) error {
		return c.SendString("Admin data")
	})

	// Route-level middleware applies only to this handler
	api.Get("/users", func(c *fiber.Ctx) error {
		return c.SendString("Users")
	}, func(c *fiber.Ctx) error {
		// Runs after AuthMiddleware, before the handler
		return c.Next()
	})
}

Groups define scope. Middleware defines rules. Keep them separate.

Passing data with locals

Middleware often extracts data that the handler needs. Fiber provides c.Locals() for this. Locals are a key-value store scoped to the request. They survive the middleware chain and are available in the handler.

// Middleware stores parsed user in the request context
func AuthMiddleware(c *fiber.Ctx) error {
	user, err := parseToken(c.Get("Authorization"))
	if err != nil {
		return c.Status(401).SendString("Unauthorized")
	}
	// Locals persists data for the lifetime of this request only
	c.Locals("user", user)
	return c.Next()
}

// Handler retrieves the user from locals
func GetProfile(c *fiber.Ctx) error {
	// Type assertion is required since Locals returns interface{}
	user := c.Locals("user").(User)
	return c.JSON(user)
}

Locals are the scratchpad. Use them to pass data, not to store state.

Context and cancellation

Fiber's *fiber.Ctx embeds context.Context. This means you can pass cancellation signals through the chain. Middleware should respect deadlines. If a handler takes too long, the context cancels. Middleware can check c.Context().Err() to detect cancellation early and avoid wasted work.

// Middleware checks for cancellation before processing
func TimeoutGuard(c *fiber.Ctx) error {
	// Context.Err() returns non-nil if deadline exceeded or cancelled
	if err := c.Context().Err(); err != nil {
		return c.Status(408).SendString("Request cancelled")
	}
	return c.Next()
}

Context is the timer. Check it early. Cancel often.

Testing middleware

You can test middleware in isolation without spinning up a full server. Fiber provides app.Test() for this. Create a request, pass it through the app, and verify the response. This catches logic errors before deployment.

func TestAuthMiddleware(t *testing.T) {
	app := fiber.New()
	app.Use(AuthMiddleware)
	app.Get("/test", func(c *fiber.Ctx) error {
		return c.SendString("OK")
	})

	// Create a request without the Authorization header
	req := httptest.NewRequest("GET", "/test", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}

	// Verify the middleware blocked the request
	if resp.StatusCode != 401 {
		t.Errorf("Expected 401, got %d", resp.StatusCode)
	}
}

Test middleware in isolation. Mock the handler. Verify the chain.

Pitfalls and errors

The most common mistake is forgetting c.Next(). If your middleware logs something and returns without calling c.Next(), the request stops. The handler never runs. The client hangs waiting for a response that never comes. The browser spins forever.

Another trap is middleware order. Fiber executes middleware in the order you register it. If you put a rate limiter after your database query, you have already wasted the database call. Put expensive checks first. Put logging last if you want to measure duration, or first if you want to catch panics early.

You can also accidentally create a loop if you redirect to a path that triggers the same middleware recursively. The compiler will not catch this. You will see a stack overflow at runtime.

When you pass a function to Group or Use, the signature must match func(*fiber.Ctx) error. If you pass a function with the wrong signature, the compiler rejects it with cannot use func literal (value of type func()) as func(*fiber.Ctx) error value in argument. Check your parameter types.

Convention aside: When you write middleware as a method on a struct, follow Go naming conventions. The receiver should be a short variable name, usually the first letter of the type. (a *Auth) Middleware(c *fiber.Ctx) error. Not (this *Auth). This keeps the code idiomatic.

Go errors are values. Middleware should return errors explicitly. Do not panic in middleware unless the app is unrecoverable. Use c.Status().Send() to return HTTP errors cleanly. The community expects if err != nil { return err } patterns even in middleware functions.

Forgetting c.Next() kills the request. Check the chain.

When to use middleware and groups

Use global middleware when every request needs the same treatment, like panic recovery or request ID generation. Use a route group when a set of paths shares a prefix and common middleware, like authentication for an API namespace. Use nested groups when you have hierarchical permissions, such as admin routes requiring both authentication and a role check. Use route-level middleware when a single endpoint needs unique logic, like rate limiting only on a search route. Use plain handlers without middleware when the route is simple and public, like a health check endpoint.

Middleware is a chain. Break the chain and the request dies.

Where to go next