Fiber's optional route parameters have no defaults — the pattern that does work
Fiber supports trailing optional route parameters, but not default values. A small group middleware plus a registration-order check covers the common /users and /users/3 case.
04 Jan 2026, 23:52 UTC

The problem: one handler, two URLs
You want GET /users to serve the first page and GET /users/4 to serve the fourth. One handler should cover both, and the missing segment should quietly become 1. Plenty of routers offer a default-value syntax for exactly this. Fiber does not, and hunting for one wastes time.
Fiber gives you two building blocks instead: a trailing optional parameter and an ordered middleware chain. A default is something you implement in about ten lines of Go. The part that actually bites is route registration order.
What Fiber's router does and does not provide
Fiber route paths use :name for a required segment and :name? for an optional one. The optional form is the closest thing to a default: when the segment is absent, the parameter resolves to an empty string rather than to a value declared in the route pattern.
There is no :name=1 syntax and no per-route fallback option. Any default lives in your handler or middleware. Two constraints follow.
First, optional parameters are documented as trailing in the v2 line — /users/:page? is fine, but a required segment after an optional one is ambiguous. Second, the value arrives as a string, so parsing and validation are still your job.
A worked example: optional page parameter with group middleware
Run this from a Go module that already depends on github.com/gofiber/fiber/v2. The middleware resolves the page once for every route in the group, so handlers stay free of parsing code. The snippet is illustrative — run the test further down to confirm it on your version.
package main
import (
"strconv"
"github.com/gofiber/fiber/v2"
)
const defaultPage = 1
// parsePage resolves the optional :page parameter and stores the result in
// c.Locals("page"). An absent or invalid value falls back to defaultPage.
func parsePage(c *fiber.Ctx) error {
page := defaultPage
if raw := c.Params("page"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page must be a positive integer")
}
page = n
}
c.Locals("page", page)
return c.Next()
}
func main() {
app := fiber.New()
users := app.Group("/users", parsePage)
users.Get("/:page?", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"page": c.Locals("page")})
})
app.Listen(":3000")
}
c.Next() hands control to the next handler in the chain; anything written after that call runs on the way back out, which is how response post-processing is normally layered. c.JSON marshals through Go's standard JSON conventions, so struct tags on your response types behave as usual.
Route order is the sharp edge
Fiber's documentation warns that route order matters, and the practical consequence is that a parameterized route registered first can capture requests intended for a static route added later:
app.Get("/users/:id", showUser) // registered first
app.Get("/users/me", currentUser) // /users/me may never reach here
Register static paths before parameterized ones, or move them under a distinct prefix. This is an ordering rule, not a performance trick — the fix is declaration order, not caching.
Limitations and version assumptions
- Optional parameters are trailing. A path such as
/users/:page?/postsis ambiguous; use a query string for the page instead. - The default is Go code, not router configuration. Each optional parameter needs its own parse-and-validate step, which is the cost of the pattern.
- Deeply nested groups add matching work and make the effective middleware order harder to read. Keep groups shallow — one prefix with shared middleware is usually enough.
- The example targets the
fiber/v2import path. Fiber v3 uses a different module path and revised routing syntax, so re-check the optional-parameter rules against the version in yourgo.modbefore copying the code.
How to check the behavior on your version
Fiber exposes app.Test, which runs a request through the full router and middleware chain without opening a socket. That makes both open questions cheap to answer: does the default apply, and which handler wins?
func TestOptionalPageParam(t *testing.T) {
app := fiber.New()
users := app.Group("/users", parsePage)
users.Get("/:page?", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"page": c.Locals("page")})
})
for _, tc := range []struct {
path string
want int
}{
{"/users", 1},
{"/users/4", 4},
} {
resp, err := app.Test(httptest.NewRequest(http.MethodGet, tc.path, nil))
if err != nil {
t.Fatalf("%s: %v", tc.path, err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: status %d", tc.path, resp.StatusCode)
}
var got struct {
Page int `json:"page"`
}
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("%s: decode: %v", tc.path, err)
}
if got.Page != tc.want {
t.Errorf("%s: got page %d, want %d", tc.path, got.Page, tc.want)
}
}
}
Required imports: encoding/json, net/http, net/http/httptest, testing, and the Fiber module.
To probe the ordering question, register /users/:id and /users/me in that order, request /users/me, and assert which body comes back. If the parameterized handler answers, reverse the registration and re-run. If the middleware variant cannot see the parameter on your Fiber version, move the parsing into the handler — the route pattern and the default logic stay the same.
What to do next
Treat optional parameters as input, not configuration: choose the default in Go, validate the parsed value, and return a 400 for garbage instead of silently falling back. Register static routes before parameterized ones. Then keep the two-case test above in your suite, so a future route addition cannot quietly change which handler serves /users.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.