← Back to all tutorials
Go FiberEpisode 6

Form, Locals & Next

Learn how to handle HTML form submissions, pass data between middleware and handlers using Locals, and control request flow with the Next function in Go Fiber. Covers multipart forms, file uploads, middleware chaining, and real-world patterns for building complete web applications.

Three core concepts power Fiber's request handling: Forms (receiving user-submitted data from HTML forms), Locals (passing data between middleware and handlers within a single request), and Next (controlling whether the request continues to the next handler in the chain). Together, they enable sophisticated request processing pipelines.

Handling Form Data

URL-Encoded Forms

Standard HTML forms submit data as URL-encoded key-value pairs:

// HTML form:
// <form method="POST" action="/register">
//   <input name="name" type="text" />
//   <input name="email" type="email" />
//   <input name="password" type="password" />
//   <button type="submit">Register</button>
// </form>

app.Post("/register", func(c *fiber.Ctx) error {
    // Read individual form fields
    name := c.FormValue("name")
    email := c.FormValue("email")
    password := c.FormValue("password")

    // Validate
    if name == "" || email == "" || password == "" {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "All fields are required",
        })
    }

    if len(password) < 8 {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Password must be at least 8 characters",
        })
    }

    // Process registration...
    return c.JSON(fiber.Map{
        "message": "Registration successful",
        "user":    name,
    })
})

Binding Forms to Structs

Use BodyParser to bind form data directly to a struct:

type RegisterForm struct {
    Name     string `form:"name"`
    Email    string `form:"email"`
    Password string `form:"password"`
    Age      int    `form:"age"`
    Newsletter bool `form:"newsletter"`
}

app.Post("/register", func(c *fiber.Ctx) error {
    var form RegisterForm
    
    if err := c.BodyParser(&form); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Invalid form data",
        })
    }

    // form.Name, form.Email, etc. are populated
    fmt.Printf("Name: %s, Email: %s, Age: %d\n", form.Name, form.Email, form.Age)

    return c.JSON(fiber.Map{"success": true})
})

Note: Use form struct tags for form data (not json). BodyParser automatically detects the content type (JSON, form-urlencoded, or multipart) and parses accordingly.

Multipart Forms (File Uploads)

// Single file upload
app.Post("/upload/avatar", func(c *fiber.Ctx) error {
    // Get the uploaded file
    file, err := c.FormFile("avatar")
    if err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "No file uploaded",
        })
    }

    // Validate file size (max 5 MB)
    if file.Size > 5*1024*1024 {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "File too large — max 5 MB",
        })
    }

    // Validate file type
    contentType := file.Header.Get("Content-Type")
    if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Only JPEG, PNG, and WebP images are allowed",
        })
    }

    // Save the file
    filename := fmt.Sprintf("%s_%s", uuid.New().String(), file.Filename)
    savePath := fmt.Sprintf("./uploads/%s", filename)
    
    if err := c.SaveFile(file, savePath); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to save file",
        })
    }

    return c.JSON(fiber.Map{
        "message":  "File uploaded successfully",
        "filename": filename,
        "size":     file.Size,
    })
})

Multiple File Upload

app.Post("/upload/gallery", func(c *fiber.Ctx) error {
    // Get the multipart form
    form, err := c.MultipartForm()
    if err != nil {
        return c.Status(400).JSON(fiber.Map{"error": "Invalid form data"})
    }

    // Get all files from the "photos" field
    files := form.File["photos"]

    if len(files) == 0 {
        return c.Status(400).JSON(fiber.Map{"error": "No files uploaded"})
    }

    if len(files) > 10 {
        return c.Status(400).JSON(fiber.Map{"error": "Maximum 10 files allowed"})
    }

    uploaded := []string{}

    for _, file := range files {
        filename := fmt.Sprintf("%s_%s", uuid.New().String(), file.Filename)
        savePath := fmt.Sprintf("./uploads/gallery/%s", filename)

        if err := c.SaveFile(file, savePath); err != nil {
            continue // Skip failed files
        }
        uploaded = append(uploaded, filename)
    }

    // Also read regular form fields alongside files
    albumName := c.FormValue("album_name")
    description := c.FormValue("description")

    return c.JSON(fiber.Map{
        "album":       albumName,
        "description": description,
        "uploaded":    len(uploaded),
        "files":       uploaded,
    })
})

Locals: Passing Data Through Middleware

Locals is a request-scoped storage — data stored in Locals is available only for the current request and is automatically cleaned up when the response is sent.

Basic Locals Usage

// Middleware sets data
func AuthMiddleware(c *fiber.Ctx) error {
    token := c.Get("Authorization")
    
    user, err := validateToken(token)
    if err != nil {
        return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
    }
    
    // Store user in Locals — available to all downstream handlers
    c.Locals("user", user)
    c.Locals("userID", user.ID)
    c.Locals("role", user.Role)
    
    return c.Next()
}

// Handler reads data
app.Get("/profile", AuthMiddleware, func(c *fiber.Ctx) error {
    // Retrieve from Locals
    user := c.Locals("user").(User) // Type assertion required
    
    return c.JSON(fiber.Map{
        "name":  user.Name,
        "email": user.Email,
        "role":  user.Role,
    })
})

Practical Locals Patterns

// Request ID middleware — adds a unique ID to every request
func RequestIDMiddleware(c *fiber.Ctx) error {
    requestID := c.Get("X-Request-ID")
    if requestID == "" {
        requestID = uuid.New().String()
    }
    
    c.Locals("requestID", requestID)
    c.Set("X-Request-ID", requestID)
    
    return c.Next()
}

// Timing middleware — measures request duration
func TimingMiddleware(c *fiber.Ctx) error {
    start := time.Now()
    c.Locals("startTime", start)
    
    // Continue to next handler
    err := c.Next()
    
    // After handler completes
    duration := time.Since(start)
    c.Set("X-Response-Time", duration.String())
    
    return err
}

// Tenant middleware — multi-tenancy context
func TenantMiddleware(c *fiber.Ctx) error {
    // Extract tenant from subdomain
    host := c.Hostname() // e.g., "acme.myapp.com"
    parts := strings.Split(host, ".")
    
    if len(parts) < 3 {
        return c.Status(400).JSON(fiber.Map{
            "error": "Invalid tenant",
        })
    }
    
    tenantSlug := parts[0]
    tenant, err := findTenant(tenantSlug)
    if err != nil {
        return c.Status(404).JSON(fiber.Map{
            "error": "Tenant not found",
        })
    }
    
    c.Locals("tenant", tenant)
    c.Locals("tenantID", tenant.ID)
    
    return c.Next()
}

// Handler using tenant context
app.Get("/api/products", TenantMiddleware, func(c *fiber.Ctx) error {
    tenantID := c.Locals("tenantID").(uint)
    products := getProductsByTenant(tenantID)
    return c.JSON(products)
})

Type-Safe Locals Helper

// Create type-safe helper functions to avoid type assertions everywhere
func GetCurrentUser(c *fiber.Ctx) (*User, bool) {
    user, ok := c.Locals("user").(*User)
    return user, ok
}

func GetTenantID(c *fiber.Ctx) (uint, bool) {
    id, ok := c.Locals("tenantID").(uint)
    return id, ok
}

// Usage in handlers
app.Get("/dashboard", AuthMiddleware, func(c *fiber.Ctx) error {
    user, ok := GetCurrentUser(c)
    if !ok {
        return c.Status(500).JSON(fiber.Map{"error": "User context missing"})
    }
    
    return c.JSON(fiber.Map{"welcome": user.Name})
})

The Next() Function

c.Next() passes control to the next handler in the chain. It's what makes middleware possible — a middleware processes the request, calls c.Next() to pass control to the next middleware or final handler, and can optionally do post-processing after c.Next() returns.

Middleware Chain Flow

// Middleware 1: Logging
func LoggerMiddleware(c *fiber.Ctx) error {
    fmt.Printf("[%s] %s %s\n", time.Now().Format("15:04:05"), c.Method(), c.Path())
    
    // Pass to next middleware/handler
    err := c.Next()
    
    // After handler completes — log the response status
    fmt.Printf("[%s] %s %s → %d\n", 
        time.Now().Format("15:04:05"), c.Method(), c.Path(), c.Response().StatusCode())
    
    return err
}

// Middleware 2: Auth check
func RequireAuth(c *fiber.Ctx) error {
    token := c.Get("Authorization")
    if token == "" {
        // DON'T call c.Next() — stop the chain
        return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
    }
    
    // Token exists — continue to the next handler
    return c.Next()
}

// Final handler
app.Get("/api/data", LoggerMiddleware, RequireAuth, func(c *fiber.Ctx) error {
    return c.JSON(fiber.Map{"data": "secret stuff"})
})

// Flow:
// 1. LoggerMiddleware → logs request → calls c.Next()
// 2. RequireAuth → checks token → calls c.Next() (or returns 401)
// 3. Final handler → returns response
// 4. Back to LoggerMiddleware (after c.Next()) → logs response status

Conditional Next

// Role-based access control
func RequireRole(roles ...string) fiber.Handler {
    return func(c *fiber.Ctx) error {
        user, ok := GetCurrentUser(c)
        if !ok {
            return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
        }

        // Check if user's role is in the allowed roles
        allowed := false
        for _, role := range roles {
            if user.Role == role {
                allowed = true
                break
            }
        }

        if !allowed {
            // Don't call Next() — block access
            return c.Status(403).JSON(fiber.Map{
                "error": "Forbidden — requires role: " + strings.Join(roles, " or "),
            })
        }

        // Role matches — continue
        return c.Next()
    }
}

// Usage
app.Delete("/api/users/:id", AuthMiddleware, RequireRole("admin"), deleteUser)
app.Get("/api/reports", AuthMiddleware, RequireRole("admin", "manager"), getReports)

Putting It All Together

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

    // Global middleware — runs on every request
    app.Use(RequestIDMiddleware)
    app.Use(TimingMiddleware)
    app.Use(LoggerMiddleware)

    // Public routes — no auth needed
    app.Post("/login", loginHandler)
    app.Post("/register", registerHandler)

    // Protected API routes
    api := app.Group("/api/v1", AuthMiddleware)
    
    // User routes
    api.Get("/profile", getProfile)
    api.Put("/profile", updateProfile)
    api.Post("/profile/avatar", uploadAvatar) // Form file upload
    
    // Admin routes — require admin role
    admin := api.Group("/admin", RequireRole("admin"))
    admin.Get("/users", listUsers)
    admin.Delete("/users/:id", deleteUser)

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

What's Next

You now understand Fiber's form handling, Locals for request-scoped data, and Next for middleware chaining. In the next tutorial, we'll explore Fiber's response methods — Send, SendStatus, SendFile, and Status — the complete toolkit for crafting HTTP responses.

GoFiberTutorialFormsMiddleware