Building APIs with Fiber
Fiber (github.com/gofiber/fiber/v2) is a Go web framework inspired by Express.js, built on top of fasthttp rather than the standard library's net/http. That choice trades some ecosystem compatibility (middleware built for net/http doesn't plug directly into Fiber) for very low overhead and a request/response API that will feel immediately familiar to anyone coming from Node.js. This chapter rebuilds the same task API a third time, in Fiber.
Installing Fiber
go get github.com/gofiber/fiber/v2
The App and Basic Routes
package main
import (
"github.com/gofiber/fiber/v2"
)
func main() {
app := fiber.New()
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
app.Listen(":8080")
}
Every Fiber handler has the signature func(c *fiber.Ctx) error — returning an error from a handler lets Fiber's centralized error handler turn it into a proper HTTP response, instead of every handler managing its own failure path.
Path and Query Parameters
app.Get("/products/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
category := c.Query("category", "")
return c.JSON(fiber.Map{"id": id, "category": category})
})
c.Query("key", "default") takes an optional default value, so you don't need a separate presence check for the common case of an optional filter.
Parsing the Request Body
c.BodyParser decodes JSON, XML, or form data into a struct based on the request's Content-Type — one method handles all three:
type CreateTaskRequest struct {
Text string `json:"text"`
}
func createTask(c *fiber.Ctx) error {
var req CreateTaskRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid body",
})
}
if req.Text == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "text is required",
})
}
task := store.Create(req.Text)
return c.Status(fiber.StatusCreated).JSON(task)
}
A Complete Task API in Fiber
type Task struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
func setupApp(store *Store) *fiber.App {
app := fiber.New()
api := app.Group("/api/v1")
api.Post("/tasks", func(c *fiber.Ctx) error {
var req CreateTaskRequest
if err := c.BodyParser(&req); err != nil || req.Text == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "text is required",
})
}
return c.Status(fiber.StatusCreated).JSON(store.Create(req.Text))
})
api.Get("/tasks", func(c *fiber.Ctx) error {
return c.JSON(store.List())
})
api.Get("/tasks/:id", func(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid id",
})
}
task, err := store.Get(id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "task not found",
})
}
return c.JSON(task)
})
api.Delete("/tasks/:id", func(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid id",
})
}
if err := store.Delete(id); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "task not found",
})
}
return c.SendStatus(fiber.StatusNoContent)
})
return app
}
Request:
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json
{"text": "Learn Fiber"}
Response:
HTTP/1.1 201 Created
Content-Type: application/json
{"id":1,"text":"Learn Fiber","done":false}
Middleware
Fiber ships an official middleware collection under github.com/gofiber/fiber/v2/middleware/... for logging, recovery, CORS, rate limiting, and more:
import (
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
)
app := fiber.New()
app.Use(logger.New())
app.Use(recover.New())
Custom middleware follows the same func(c *fiber.Ctx) error shape, calling c.Next() to continue the chain:
func requireAPIKey(validKey string) fiber.Handler {
return func(c *fiber.Ctx) error {
if c.Get("X-API-Key") != validKey {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "invalid api key",
})
}
return c.Next()
}
}
api := app.Group("/api/v1", requireAPIKey(key))
*fiber.Ctx does not wrap http.ResponseWriter or *http.Request — it's backed by fasthttp's own request/response types, reused across requests from a pool for performance. That means standard-library middleware, and packages that expect http.Handler, generally can't be dropped straight into a Fiber app; you need Fiber-specific equivalents. Keep this in mind before mixing Fiber with net/http-based libraries.Graceful Shutdown
go func() {
if err := app.Listen(":8080"); err != nil {
log.Fatal(err)
}
}()
ctx, stop := signal.NotifyContext(
context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()
<-ctx.Done()
app.ShutdownWithTimeout(10 * time.Second)
Testing a Fiber Handler
Fiber apps expose a Test method built specifically for driving a request through the app in tests, without binding a real port:
func TestGetTask(t *testing.T) {
app := setupApp(seededStore())
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/1", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
app.Test runs the request through the full routing and middleware stack in-process and returns a standard *http.Response, so the rest of the assertion — reading resp.Body, decoding JSON — looks exactly like testing any other HTTP client call.
Fiber vs Gin
Both frameworks solve the same problem with a similar shape — routes, groups, middleware, JSON helpers — but the underlying engine differs:
- Gin builds on
net/http, so it's compatible with the entire standard-library-based middleware ecosystem, and its*gin.Contextis a thin layer over familiar types. - Fiber builds on
fasthttp, which avoids some ofnet/http's allocation overhead and can be measurably faster under high load, at the cost of its own middleware ecosystem and reduced interoperability withnet/http-based code (likenet/http/pprof, or most gRPC-gateway tooling).
For most teams, the deciding factor isn't raw throughput — both comfortably handle the request volumes most APIs ever see — but which ecosystem of middleware and documentation you'd rather live in. Gin's larger, net/http-compatible ecosystem tends to be the safer default; Fiber earns its place when you know performance at extreme scale is the binding constraint, or when the team already prefers its Express-like API from prior Node.js experience.
A practical middle ground worth knowing: Fiber can run behind net/http via an adapter package if you later need to embed it inside a larger net/http-based application, though at that point it's worth asking whether Gin (which never needed the adapter in the first place) would have been the simpler choice from the start.
Frequently Asked Questions
Why does every Fiber handler in this chapter return an error, when the Gin handlers in the previous chapter didn't?
That's Fiber's central error-handling design: instead of every handler managing its own failure response inline, returning an error lets Fiber's app-wide error handler decide how to turn it into an HTTP response. In the handlers shown here we mostly build the response ourselves and return nil on success, but the moment a deeper call — a database query, a downstream request — returns an error, you can simply return err and let the centralized handler take over.
I tried to plug a standard net/http middleware into my Fiber app and it didn't work — why?
Because *fiber.Ctx isn't built on http.ResponseWriter/*http.Request at all — it wraps fasthttp's own pooled request and response types, as the <Warning> earlier in this chapter calls out. Anything written against the http.Handler interface, including most third-party middleware, needs a Fiber-specific equivalent (or the net/http adapter package) rather than dropping in directly.
What's the difference between c.BodyParser here and ShouldBindJSON from the Gin chapter?
They serve the same role — decoding the request body into a struct — but BodyParser is content-type aware out of the box, inspecting the Content-Type header to decode JSON, XML, or form data with a single method. Gin's ShouldBindJSON is JSON-specific by name, though Gin does offer its own family of ShouldBind* variants for other formats if you need them.
Do I need to call fiber.New() fresh in every test, or can I reuse the app from main?
setupApp(store) in this chapter already separates app construction from main, specifically so tests can build a fresh *fiber.App wired to a fresh, seeded *Store without ever binding a real port. That's what makes app.Test(req) possible — it drives a request through the exact same routing and middleware stack production would use, entirely in-process.
Given fasthttp is faster, why doesn't this book just use Fiber everywhere instead of Gin?
Because raw throughput is rarely the deciding factor for most APIs — both frameworks comfortably handle the request volumes typical services see, as the comparison above lays out. Gin's compatibility with the much larger net/http middleware ecosystem is usually worth more in practice than the performance edge Fiber offers, which is why Gin tends to be the safer default and Fiber earns its place only when extreme scale or an Express-like API is specifically what you need.
Next, we'll shift from JSON APIs to serving HTML pages, templates, and static assets — for the parts of your application that render a page rather than return data.