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/mewill match before/users/:idwhen the request path is/users/me, provided it was registered first. - Trailing slashes are normalized. The router automatically strips trailing slashes so
/users/and/usersresolve 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.