Routing

The Nimbus router is the backbone of your application. It maps incoming HTTP requests to handler functions based on three components: the HTTP method (GET, POST, etc.), a URI pattern (the path, possibly with dynamic segments), and a handler that processes the request and returns a response. Middleware can wrap any route or group to run logic before or after the handler executes.

Under the hood, Nimbus uses go-chi/chi/v5 for high-performance, radix-tree-based route matching that is fully compatible with net/http.

Basic example

Routes are registered on app.Router. Every handler receives a *http.Context and returns an error. A nil return signals success.

// Static routes
app.Router.Get("/", func(c *http.Context) error {
    return c.View("home", nil)
})

app.Router.Get("/about", func(c *http.Context) error {
    return c.String(200, "About page")
})

// Dynamic route with a parameter
app.Router.Get("/users/:id", func(c *http.Context) error {
    id := c.Param("id")
    return c.JSON(200, map[string]string{"user_id": id})
})

// POST route that reads request data
app.Router.Post("/users", func(c *http.Context) error {
    name := c.FormValue("name")
    email := c.FormValue("email")
    // ... save user to database ...
    return c.JSON(201, map[string]string{
        "name":  name,
        "email": email,
    })
})

Using a controller as route handler

Instead of inline functions, you can organize handlers into controller structs. This keeps route definitions clean and groups related logic together.

// app/controllers/users_controller.go
package controllers

import "github.com/CodeSyncr/nimbus/context"

type UsersController struct{}

func (uc *UsersController) List(c *http.Context) error {
    users := []map[string]string{
        {"id": "1", "name": "Alice"},
        {"id": "2", "name": "Bob"},
    }
    return c.JSON(200, users)
}

func (uc *UsersController) Show(c *http.Context) error {
    id := c.Param("id")
    return c.JSON(200, map[string]string{"id": id})
}

Register the controller methods as route handlers:

uc := &controllers.UsersController{}

app.Router.Get("/users", uc.List)
app.Router.Get("/users/:id", uc.Show)

Route params

Dynamic segments in a route path let you capture values from the URL. Access them with c.Param("name").

Basic route params

Prefix a segment with : to make it dynamic. The captured value is available by name.

app.Router.Get("/posts/:slug", func(c *http.Context) error {
    slug := c.Param("slug") // e.g. "hello-world"
    return c.View("post", map[string]any{"slug": slug})
})

Multiple params

A route can contain several dynamic segments. Each one is captured independently.

app.Router.Get("/posts/:postId/comments/:commentId", func(c *http.Context) error {
    postId := c.Param("postId")
    commentId := c.Param("commentId")
    return c.JSON(200, map[string]string{
        "post":    postId,
        "comment": commentId,
    })
})
// GET /posts/5/comments/42 โ†’ {"post":"5","comment":"42"}

Wildcard params

A trailing /* captures the rest of the path. This is useful for catch-all routes like file serving or SPA fallbacks.

app.Router.Get("/files/*", func(c *http.Context) error {
    // GET /files/images/photo.jpg โ†’ filepath = "images/photo.jpg"
    filepath := c.Param("*")
    return c.String(200, "Serving: "+filepath)
})

HTTP methods

Nimbus provides a dedicated method for each standard HTTP verb. All methods accept a path and a HandlerFunc, and return a *Route for chaining.

Method Router call Typical purpose
GET router.Get(path, handler) Retrieve a resource or render a page
POST router.Post(path, handler) Create a new resource
PUT router.Put(path, handler) Replace a resource entirely
PATCH router.Patch(path, handler) Partially update a resource
DELETE router.Delete(path, handler) Remove a resource
app.Router.Get("/posts", listPosts)
app.Router.Post("/posts", createPost)
app.Router.Put("/posts/:id", replacePost)
app.Router.Patch("/posts/:id", updatePost)
app.Router.Delete("/posts/:id", deletePost)

Matching any HTTP method

Use router.Any() to register a handler for all standard methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS).

app.Router.Any("/healthcheck", func(c *http.Context) error {
    return c.JSON(200, map[string]string{"status": "ok"})
})

Custom method combinations

Use router.Route() to match a specific set of HTTP methods.

app.Router.Route("/contact", []string{"GET", "POST"}, func(c *http.Context) error {
    if c.Request().Method == "POST" {
        // process form submission
        return c.Redirect(302, "/contact/thanks")
    }
    return c.View("contact", nil)
})

Route middleware

Middleware wraps handlers to run logic before and/or after the main handler executes. The signature is func(HandlerFunc) HandlerFunc.

Global middleware

Call router.Use() to apply middleware to every route in the application. This is typically done during app startup.

app.Router.Use(middleware.Logger(), middleware.Recover())

Group middleware

Pass middleware when creating a group. Only routes within that group are affected. You can also add more middleware later with group.Use().

// Middleware applied at group creation
admin := app.Router.Group("/admin", authMiddleware, roleMiddleware)
admin.Get("/dashboard", dashboardHandler)

// Add more middleware after creation
admin.Use(auditLogMiddleware)
admin.Get("/settings", settingsHandler)

Writing custom middleware

A middleware function takes the next handler in the chain and returns a new handler that wraps it.

func TimingMiddleware() router.Middleware {
    return func(next router.HandlerFunc) router.HandlerFunc {
        return func(c *http.Context) error {
            start := time.Now()
            err := next(c)
            duration := time.Since(start)
            log.Printf("%s %s took %v", c.Request().Method, c.Request().URL.Path, duration)
            return err
        }
    }
}

app.Router.Use(TimingMiddleware())

Named routes

Naming a route lets you generate its URL by name instead of hard-coding paths. Use the .As() method to assign a name.

app.Router.Get("/users", listUsers).As("users.index")
app.Router.Get("/users/:id", showUser).As("users.show")
app.Router.Post("/users", createUser).As("users.store")

Generating URLs from named routes

Call router.URL() with the route name and key-value pairs for any dynamic segments. This is especially useful for redirects and link generation in templates.

// Generate URLs by route name
url := app.Router.URL("users.index")          // "/users"
url := app.Router.URL("users.show", "id", "42") // "/users/42"

// Use in a redirect
app.Router.Post("/users", func(c *http.Context) error {
    // ... create user with id 7 ...
    url := app.Router.URL("users.show", "id", "7")
    return c.Redirect(302, url)
})

Grouping routes

Groups let you share a path prefix and middleware across multiple routes. This reduces repetition and makes your route definitions easier to read.

Prefix groups

api := app.Router.Group("/api")

api.Get("/posts", listPosts)     // GET  /api/posts
api.Post("/posts", createPost)   // POST /api/posts
api.Get("/posts/:id", showPost)  // GET  /api/posts/:id

Groups with middleware

// All routes in this group require authentication
auth := app.Router.Group("/account", authMiddleware)
auth.Get("/profile", profileHandler)    // GET  /account/profile
auth.Put("/profile", updateProfile)     // PUT  /account/profile
auth.Get("/settings", settingsHandler)  // GET  /account/settings

Nesting groups

You can create multiple groups to model a nested URL structure. Each group maintains its own prefix and middleware stack.

// Public API routes
api := app.Router.Group("/api/v1")
api.Get("/posts", listPosts)

// Admin API routes (adds auth on top of the /api/v1 prefix concept)
admin := app.Router.Group("/api/v1/admin", authMiddleware, adminMiddleware)
admin.Get("/users", adminListUsers)   // GET /api/v1/admin/users
admin.Delete("/users/:id", adminDeleteUser) // DELETE /api/v1/admin/users/:id

Resource routes

Resource routing is one of the most powerful features in Nimbus. A single call to router.Resource() generates all seven RESTful routes for a resource, following convention over configuration.

Registering a resource

app.Router.Resource("posts", &PostsController{})

This single line generates the following seven routes:

HTTP method Path Controller action Named route
GET /posts Index posts.index
GET /posts/create Create posts.create
POST /posts Store posts.store
GET /posts/:id Show posts.show
GET /posts/:id/edit Edit posts.edit
PUT | PATCH /posts/:id Update posts.update
DELETE /posts/:id Destroy posts.destroy

Every resource route is automatically named using the pattern resourceName.action. The Update action responds to both PUT and PATCH.

The ResourceController interface

Your controller must implement the ResourceController interface, which defines all seven actions:

type ResourceController interface {
    Index(c *http.Context) error   // GET    /resource
    Create(c *http.Context) error  // GET    /resource/create
    Store(c *http.Context) error   // POST   /resource
    Show(c *http.Context) error    // GET    /resource/:id
    Edit(c *http.Context) error    // GET    /resource/:id/edit
    Update(c *http.Context) error  // PUT|PATCH /resource/:id
    Destroy(c *http.Context) error // DELETE /resource/:id
}

ApiOnly

For JSON APIs, the Create and Edit actions (which render forms) are unnecessary. Use router.ApiOnly() to skip them.

app.Router.Resource("posts", &PostsController{}, router.ApiOnly())
// Registers: index, store, show, update, destroy
// Skips:     create, edit

Only and Except

Fine-tune which actions are registered with router.Only() and router.Except().

// Register only index and show
app.Router.Resource("posts", ctrl, router.Only("index", "show"))

// Register everything except destroy
app.Router.Resource("posts", ctrl, router.Except("destroy"))

Valid action names: index, create, store, show, edit, update, destroy.

Resource inside a group

Resources can be registered on a group. The group prefix is applied to all generated paths, and group middleware wraps every action.

api := app.Router.Group("/api", authMiddleware)
api.Resource("posts", &PostsController{}, router.ApiOnly())

// Generated routes:
// GET    /api/posts          โ†’ posts.index
// POST   /api/posts          โ†’ posts.store
// GET    /api/posts/:id      โ†’ posts.show
// PUT    /api/posts/:id      โ†’ posts.update
// PATCH  /api/posts/:id      โ†’ posts.update_patch
// DELETE /api/posts/:id      โ†’ posts.destroy

Full controller example

Here is a complete controller implementing the ResourceController interface:

package controllers

import (
    "fmt"
    "github.com/CodeSyncr/nimbus/context"
)

type PostsController struct{}

func (pc *PostsController) Index(c *http.Context) error {
    return c.JSON(200, map[string]string{"action": "list all posts"})
}

func (pc *PostsController) Create(c *http.Context) error {
    return c.View("posts/create", nil)
}

func (pc *PostsController) Store(c *http.Context) error {
    title := c.FormValue("title")
    // ... save to database ...
    return c.Redirect(302, fmt.Sprintf("/posts/%s", "new-id"))
}

func (pc *PostsController) Show(c *http.Context) error {
    id := c.Param("id")
    return c.JSON(200, map[string]string{"action": "show post", "id": id})
}

func (pc *PostsController) Edit(c *http.Context) error {
    id := c.Param("id")
    return c.View("posts/edit", map[string]any{"id": id})
}

func (pc *PostsController) Update(c *http.Context) error {
    id := c.Param("id")
    title := c.FormValue("title")
    // ... update in database ...
    return c.JSON(200, map[string]string{"id": id, "title": title})
}

func (pc *PostsController) Destroy(c *http.Context) error {
    id := c.Param("id")
    // ... delete from database ...
    return c.JSON(200, map[string]string{"deleted": id})
}

Register with one line:

app.Router.Resource("posts", &controllers.PostsController{})

How route matching works

Understanding how the router resolves an incoming request to a handler is important for avoiding surprises.

  • Registration order matters. Routes are evaluated in the order they are registered. The first matching route wins.
  • Static segments take priority over dynamic ones. A route for /users/me will match before /users/:id when the request path is /users/me, provided it was registered first.
  • Trailing slashes are normalized. The router automatically strips trailing slashes so /users/ and /users resolve to the same route. You do not need to register both variants.
  • Method must match. A GET request will never match a route registered only for POST, even if the path is identical.
// Register the static route first so it takes priority
app.Router.Get("/users/me", currentUserHandler)
app.Router.Get("/users/:id", showUserHandler)

// GET /users/me   โ†’ currentUserHandler (static match)
// GET /users/42   โ†’ showUserHandler    (dynamic match)
// GET /users/     โ†’ showUserHandler    (trailing slash stripped, matches /users with no :id)

Route model binding

Route model binding automatically resolves URL parameters to model instances. Instead of manually querying the database in every handler, define a Bindable model and the router injects it into the context.

Implement the Bindable interface

import "github.com/CodeSyncr/nimbus/router"

type User struct {
    ID    uint
    Name  string
    Email string
}

func (u *User) RouteKey() string { return "id" }

func (u *User) FindForRoute(value string) (any, error) {
    var user User
    err := db.Where("id = ?", value).First(&user).Error
    return &user, err
}

Register model bindings

app.Router.Use(router.BindModel(router.ModelBinding{
    Param:      "id",
    ContextKey: "user",
    Model:      &User{},
}))

Use in handlers

app.Router.Get("/users/:id", func(c *http.Context) error {
    user, _ := c.Get("user")
    return c.JSON(200, user.(*User))
})

If the model is not found, the binding middleware automatically returns a 404 error.

Typed param helpers

Convert route parameters to typed values without boilerplate strconv calls:

// Get param as int (returns 0 on parse error)
id := c.ParamInt("id")

// Get param as int64
id64 := c.ParamInt64("id")

Fallback routes

Register a dedicated fallback handler that runs when no other route matches. Unlike a catch-all /* route, Fallback() is explicitly reserved for 404-style handling and is registered after all other routes.

// SPA fallback โ€” serve index.html for all unmatched routes
app.Router.Fallback(func(c *http.Context) error {
    return c.View("index", nil)
})

// API fallback โ€” structured JSON error
api := app.Router.Group("/api")
api.Fallback(func(c *http.Context) error {
    return c.JSON(404, map[string]string{
        "error":   "not_found",
        "message": "The requested endpoint does not exist.",
    })
})

Handling 404

When no registered route matches the incoming request, the underlying Chi router returns a default 404 Not Found response. If you need custom 404 behavior, you can register a wildcard catch-all route at the bottom of your route definitions.

// Register all your routes first ...
app.Router.Get("/", homeHandler)
app.Router.Get("/about", aboutHandler)
app.Router.Resource("posts", &PostsController{})

// Then a catch-all for unmatched routes
app.Router.Any("/*", func(c *http.Context) error {
    return c.String(404, "Page not found")
})

For APIs, you might want to return a structured JSON error:

api := app.Router.Group("/api")
// ... register API routes ...

api.Any("/*", func(c *http.Context) error {
    return c.JSON(404, map[string]string{
        "error":   "not_found",
        "message": "The requested endpoint does not exist.",
    })
})

If a handler itself returns a non-nil error, Nimbus responds with a 500 Internal Server Error containing the error message. For more granular error handling, see the Exception Handling guide.