How to Use Route Groups in Gin

Web
Use `r.Group()` to define a common path prefix and middleware for a set of routes, keeping your router configuration DRY and organized.

Route groups batch configuration

You are building a REST API. You start with a few endpoints: /users, /posts, /comments. Everything lives in one router function. Then you add versioning. Every route gets an /api/v1 prefix. You copy-paste the string twenty times. Then you add authentication. You paste the auth check on ten routes. You miss one. A security hole appears. You need a way to batch configuration so you can apply a prefix and middleware once, not per route.

Route groups solve this. They let you define a common path prefix and a set of middleware handlers for a subset of routes. When you register a route inside a group, Gin combines the group's configuration with the route's configuration. You change the prefix in one place and all routes update. You add middleware to the group and all routes inherit it. Groups keep your router clean and reduce repetition.

What a route group actually is

A route group is a sub-router. It holds a base path and a list of middleware. When you call r.Group("/api/v1"), Gin creates a new RouterGroup object. This object stores the path /api/v1 and copies the middleware list from the parent router.

When you register a route inside the group, Gin merges the configurations. The full path becomes the group's base path concatenated with the route's path. The middleware chain becomes the global middleware, followed by the group's middleware, followed by the route's handler.

Think of a route group like a folder in a file system. Files inside the folder inherit the folder's location. If you move the folder, all files move. In Gin, if you change the group prefix, all routes update. If you add a rule to the folder, all files follow the rule. Groups are configuration inheritance for your HTTP endpoints.

Minimal example

Here's the smallest useful group: define a prefix, add a route inside.

package main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()
    // Group applies "/api" prefix to all routes inside the block
    api := r.Group("/api")
    {
        // Route becomes "/api/status"
        api.GET("/status", func(c *gin.Context) {
            c.JSON(200, gin.H{"ok": true})
        })
    }
    r.Run()
}

The group api holds the prefix /api. The route /status is registered on the group. Gin constructs the full path /api/status. The block syntax with braces limits the scope of the api variable. This is idiomatic Go. It keeps the structure visible and prevents the group variable from leaking into the rest of the function.

How the request flows

When a request arrives, Gin matches the path against the registered routes. If the path matches a route inside a group, Gin builds a middleware chain. The chain includes global middleware, group middleware, and the handler. Execution flows through the chain in order.

Each middleware function receives the *gin.Context. It can inspect the request, modify headers, or extract data. If the middleware wants to continue, it calls c.Next(). This passes control to the next handler in the chain. If the middleware calls c.Abort(), the chain stops. The handler never runs. The response is sent immediately.

Middleware can also run code after c.Next() returns. This is useful for logging response times or cleaning up resources. The pattern looks like this:

func loggingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        // Continue to the next handler or route handler
        c.Next()
        // This runs after the handler completes
        duration := time.Since(start)
        log.Printf("Request took %v", duration)
    }
}

The c.Next() call is the pivot point. Code before c.Next() runs on the way down. Code after c.Next() runs on the way back up. This allows middleware to wrap the handler with pre-processing and post-processing logic.

Realistic setup with versioning and auth

Here's a realistic setup with versioning, authentication, and nested groups for admin routes.

package main

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

// authMiddleware blocks requests without an Authorization header
func authMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        if c.GetHeader("Authorization") == "" {
            c.AbortWithStatus(http.StatusUnauthorized)
            return
        }
        c.Next()
    }
}
func main() {
    r := gin.Default()
    // Group captures /api/v1 prefix
    v1 := r.Group("/api/v1")
    {
        // Nested group adds auth and /admin prefix
        admin := v1.Group("/admin")
        admin.Use(authMiddleware())
        {
            // Full path becomes /api/v1/admin/dashboard
            admin.GET("/dashboard", func(c *gin.Context) {
                c.JSON(200, gin.H{"secure": true})
            })
        }
    }
    r.Run(":8080")
}

The v1 group handles all routes under /api/v1. The admin group is nested inside v1. It adds the /admin prefix and the authMiddleware. The dashboard route inherits both. The full path is /api/v1/admin/dashboard. The middleware chain includes the global middleware, the v1 group middleware (if any), the admin group middleware, and the handler.

Nesting groups creates a hierarchy. Parent groups apply to child groups. This lets you isolate concerns. You can have a public API version with a protected admin section inside it. You can add logging to the version group and auth to the admin group. The configuration stays modular.

Pitfalls and errors

Middleware order determines execution. If you attach logging to a parent group and auth to a child, logging runs first. If you reverse the nesting, auth runs first. This matters when you want to log only successful requests or skip logging for health checks. Middleware order is execution order. Get it wrong and your logs lie.

If you forget to call c.Next() in middleware, the handler never runs and the client hangs. The compiler won't catch this. You'll see a timeout in your client. Always call c.Next() unless you intend to abort the request.

If you pass an integer instead of a string to Group, the compiler rejects it with cannot use 123 (untyped int constant) as string value in argument. Route groups expect string paths.

Gin normalizes paths. Trailing slashes are handled consistently. r.Group("/api") and r.Group("/api/") behave the same way. Gin strips trailing slashes from the group prefix and normalizes the route paths. You don't need to worry about double slashes or missing slashes. Trust the router to handle path hygiene.

The block syntax with braces is idiomatic Go. You assign the group to a variable, open a brace, define routes, close the brace. This limits the scope of the group variable and keeps the structure visible. gofmt enforces this style. Don't fight the formatter. The tool decides indentation and layout. You focus on the logic.

Gin middleware doesn't return errors. It uses the context to pass state and calls c.Abort() to stop execution. This keeps the signature simple. You handle errors by writing to the response or logging, not by returning values up a stack. This is a convention difference from some other frameworks. Adapt to the pattern.

When to use groups

Use a route group when multiple routes share a common path prefix like /api/v1.

Use a route group when you need to apply middleware to a subset of routes without affecting the whole application.

Use nested groups when you have hierarchical permissions, such as public routes inside a versioned API that contain protected admin routes.

Use global middleware via r.Use() when the logic applies to every single request, like request ID generation or panic recovery.

Use individual route handlers when the logic is unique to one endpoint and doesn't share structure with others.

Groups are configuration inheritance, not magic. They reduce repetition and keep your router organized. Nesting controls scope. Scope controls safety.

Where to go next