Native routing for modern Go applications, plus a drop-in compatibility layer for migrating from
gorilla/mux.
njia (Swahili: path, way) is an HTTP router built around three principles:
- Fast request matching
- Zero dependencies
- Easy migration from
gorilla/mux
Whether you're building a REST API, reverse proxy, API gateway, or platform, Njia provides a modern router that is fast, introspectable, and safe to use in production.
The main module has zero require entries and imports only the Go standard library. gorilla/mux is used exclusively in a separate test module (internal/difftest) to verify behavioral compatibility.
go get github.com/jkaninda/njiaFor gorilla/mux compatibility:
go get github.com/jkaninda/njia/muxcompatpackage main
import (
"log"
"net/http"
"github.com/jkaninda/njia"
)
func main() {
r := njia.New()
r.GET("/healthz", healthHandler)
r.GET("/users/{id}", getUser)
r.GET("/files/{rest...}", serveFile)
api := r.Group(
"/api/v1",
authMiddleware,
rateLimitMiddleware,
)
api.POST("/orders", createOrder)
log.Fatal(http.ListenAndServe(":8080", r))
}Read a parameter without building a map:
func getUser(w http.ResponseWriter, r *http.Request) {
id := njia.Param(r, "id") // no map, no allocation
...
}GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS are shorthands for
Handle, which takes any method:
r.Handle("REPORT", "/calendars/{id}", reportHandler)
r.HandleFunc("PURGE", "/cache/{key...}", purge)A route registered for GET also answers HEAD, and the Allow header of a
405 lists HEAD alongside GET accordingly.
ANY registers one handler for every method, including verbs no RFC names:
r.ANY("/api/{rest...}", proxy)This is what a reverse proxy needs. A gateway forwards whatever verb the client
sent — WebDAV's PROPFIND, a vendor's custom verb — and lets the backend decide
what it accepts; enumerating methods at registration time would make the router
reject a request the proxy would have been happy to forward.
An explicitly registered method always wins over the wildcard, so one route can serve a verb specially and proxy the rest:
r.GET("/files/{path...}", readFromCache) // GET comes from here
r.ANY("/files/{path...}", proxy) // everything else from hereA path served by ANY never answers 405, because there is no method it rejects.
The * sentinel is a registration detail and never appears in an Allow
header.
api.POST("/orders", createOrder, njia.WithName("createOrder"))| Option | Effect |
|---|---|
WithName(name) |
Names the route; must be unique. Retrieve with Router.Route(name). |
WithMeta(key, value) |
Attaches an arbitrary annotation, surfaced by Routes(). |
WithHost(patterns...) |
Restricts this route to host patterns, overriding its group's. |
WithMiddleware(mw...) |
Wraps only this route, inside any group middleware. |
WithPriority(n) |
Orders this route ahead of specificity; lower first. See Match order. |
Registration returns an error instead of panicking — see Errors.
{id} matches any single non-empty segment. {rest...} absorbs the remainder
of the path including slashes, and must be the last segment. A placeholder
carries a name and nothing else.
err := r.GET("/users/{id:int}", getUser)
// njia: route GET "/users/{id:int}": njia: malformed route pattern:
// "{id:int}" constrains "id", which this router does not support; write {id}A pattern that looks like it filters values can never silently match
everything. Validate in the handler, where a bad value produces a useful 400
rather than falling through to a 404. If you need matching itself to depend on
the value, muxcompat accepts gorilla's
{id:[0-9]+}.
| Call | Returns | Allocates |
|---|---|---|
Param(r, "id") |
The value, or "". |
no |
ParamAt(r, i) |
(name, value, ok) in pattern order. |
no |
NumParams(r) |
How many were captured. | no |
AppendParams(r, dst) |
Appends every parameter to dst. |
no, given capacity |
ParamMap(r) |
map[string]string, the shape gorilla's Vars returned. |
yes |
RouteOf(r) |
The *Route that matched. |
no |
SetParams(req, params...) attaches parameters to a request, for tests that
invoke a handler directly rather than through the router.
Parameters are captured into a fixed-size array carried by the request context and spill to the heap only past that size. A static route declares no parameters and so writes nothing to the context at all.
Routes are matched most-specific-first: a static segment beats a wildcard, which beats a catch-all, resolved position by position from the left. Matching backtracks, so a static branch that dead-ends never hides a wildcard that would have matched.
Host specificity is considered after path specificity — see Host matching.
Specificity is the right default — given /api/v1/{rest...} and
/api/{rest...}, the longer prefix is almost always what should serve
/api/v1/x. A gateway assembling routes from user configuration sometimes needs
the opposite, and specificity alone cannot express it:
r.ANY("/api/{rest...}", maintenance, njia.WithPriority(-1))
r.ANY("/api/v1/{rest...}", backend)
// GET /api/v1/x -> maintenance, despite being the less specific patternPriority is compared before specificity, lower first. DefaultPriority is
0, so a negative value pulls a route ahead of everything unmarked and a
positive one pushes it behind.
Two details worth knowing:
- Routes sharing a path pattern share the lowest priority any of them asked for, because ordering picks a pattern before it picks a method.
- Priorities disable the lookup fast paths for the whole table, since those answer with the most specific match without consulting other candidates. Leaving priority unset everywhere — the default — costs nothing.
A group prefixes patterns and wraps handlers, and nests:
api := r.Group("/api/v1", authMiddleware, rateLimitMiddleware)
v1u := api.Group("/users", auditMiddleware)
v1u.GET("/{id}", getUser) // GET /api/v1/users/{id}
api.Prefix() // "/api/v1"
api.Hosts() // host patterns, if restrictedOrder, outermost first: router middleware, then each enclosing group's
middleware from outer to inner, then the route's own WithMiddleware, then the
handler. This is tested, not merely documented.
Middleware is resolved when the table is compiled, not when a route is
registered, so where a Use call sits among the registrations does not change
what it covers. A route registered before it is wrapped just like one
registered after, and so is a child group created before it:
api := r.Group("/api")
v1 := api.Group("/v1") // created before the Use
api.Use(auth) // covers v1 as well
v2 := api.Group("/v2") // and v2
v1.GET("/orders", list) // authenticated
v2.GET("/orders", list) // authenticatedRouter.Use and Group.Use behave the same way; the only difference is scope.
This matches gorilla/mux, whose middleware ran at match time and so applied
whatever the registration order — code moved across keeps working, and moving a
Use call up or down a file can never silently drop authentication.
To wrap only some routes, say so structurally rather than by ordering:
api := r.Group("/api")
api.GET("/public", public) // no auth
secure := api.Group("/admin", auth) // scope is visible here
secure.GET("/settings", settings)or attach it to a single route with WithMiddleware.
Mount hands every request at or below a prefix to one handler, for every
method — another router, a file server, a debug endpoint:
r.Mount("/debug/pprof", pprofHandler)
r.Mount("/static", http.FileServer(http.Dir("public")))
r.Mount("/legacy", oldRouter)Both the prefix and its subtree are covered, so /static and
/static/css/app.css both reach the handler, and matching is segment-bounded:
mounting /api does not capture /apiary.
The prefix is not stripped. A proxy needs the path as it arrived, and a handler that wants it removed can say so, which reads better than a routing rule that silently rewrites:
r.Mount("/static", http.StripPrefix("/static", fs))A more specific route still wins, which is how an exception is carved out of a mount:
r.Mount("/api", proxy)
r.GET("/api/health", localHealth) // served locally, not proxiedThe remainder is captured under MountParam. Because that name is fixed,
mounting and separately registering a differently named catch-all at the same
position conflict — r.Mount("/admin", h) then r.GET("/admin/{files...}", x)
returns ErrParamConflict. A plain {id} parameter there is fine.
Every field is off by default. Set them on a router from New(), which is the
only supported way to construct one — the zero Router has no route table and
panics on registration.
r := njia.New()
r.NotFound = http.HandlerFunc(myNotFound)
r.MethodNotAllowed = http.HandlerFunc(my405)
r.CleanPath = true
r.RedirectTrailingSlash = true
r.RouteInContext = true| Field | Effect when set |
|---|---|
NotFound |
Serves unmatched requests. Default: http.NotFoundHandler. |
MethodNotAllowed |
Serves a path hit with the wrong method. Default: 405 plus an Allow header. |
CleanPath |
Redirects a non-canonical path to its cleaned form with 301 — /a//b → /a/b. |
RedirectTrailingSlash |
Redirects /x/ to /x, or /x to /x/, when only the other is registered. Without it, the other form is a 404. |
RouteInContext |
Makes RouteOf work for static routes too, at one allocation per request. |
RouteInContext exists because a static route otherwise attaches nothing to the
request. Leave it off unless handlers actually need the matched route.
gw := r.Host("api.example.com", "*.api.example.com")
gw.GET("/orders/{id}", getOrder)
r.Host("{tenant}.app.example.com").GET("/dashboard", dashboard) // njia.Param(req, "tenant")
r.GET("/healthz", health) // every hostMost specific first:
| Pattern | Matches |
|---|---|
api.example.com:8443 |
exactly this host on this port |
api.example.com |
exactly this host, any port |
{sub}.example.com |
exactly one leading label, captured as sub |
*.example.com |
one or more leading labels |
{sub...}.example.com |
one or more leading labels, captured |
{host...} |
any host, captured whole |
* |
any host — also spelled njia.AnyHost |
Matching is case-insensitive and ignores a trailing dot, so API.Example.COM.
and api.example.com are the same name. A pattern that names a port only
matches requests carrying that port; one that does not, ignores the port
entirely.
WithHost(...) restricts a single route and overrides its group.
ValidateHost checks a pattern without registering anything.
A host parameter is reported by Routes() before any path parameter, marked
InHost with a Position of -1, and is read with njia.Param like any
other.
Path specificity is decided first, host specificity second, registration order
last. A global /healthz therefore stays reachable underneath a per-host
catch-all proxy route — which is exactly how a gateway needs it:
r.GET("/healthz", health) // wins on every host
r.Host("okapi.example.com").GET("/{rest...}", proxyOkapi) // everything elseWithin one path pattern, hosts are consulted from most to least specific, and a variant that does not serve the request's method falls through to a less specific one.
- A path that exists but not on the requested host is a 404.
- A path that exists on that host but not for that method is a 405, and the
Allowheader only lists methods that host actually serves.
Exact hosts are indexed by name, so a gateway with a thousand virtual hosts costs one map lookup, not a thousand comparisons. Tables that use no host constraint at all never read the request's host — the feature costs them nothing.
Registration returns errors and never panics:
if err := r.GET("/users/{id", handler); err != nil {
// njia: route GET "/users/{id": njia: malformed route pattern: ...
}This matters for gateways that build routes from user-supplied configuration: a
bad entry is rejected, not fatal. Paired with Swap, a whole
table is validated off to the side and installed only if it is sound, so a typo
in someone's YAML can never take the process down.
Routes fixed in code can be checked in one place rather than at every call, with
Builder.Err() after registering, or by letting a bad table fail the Swap.
Every failure is a typed sentinel wrapped in a *RouteError that names the
offending method and pattern.
| Sentinel | Cause |
|---|---|
ErrBadPattern |
Malformed template. |
ErrNoLeadingSlash |
Pattern does not start with /. |
ErrDuplicateRoute |
Same method and pattern registered twice. |
ErrDuplicateName |
Two routes given the same name. |
ErrParamConflict |
Conflicting parameter names at one position. |
ErrCatchAllPosition |
{rest...} is not the last segment. |
ErrNoHandler |
Route registered without a handler. |
ErrEmptyMethod |
Route registered without a method. |
ErrBadHost |
Malformed host pattern. |
Exposes Method, Pattern and Err, and implements Unwrap:
if errors.Is(err, njia.ErrDuplicateRoute) {
...
}
var rerr *njia.RouteError
if errors.As(err, &rerr) {
log.Printf("bad route %s %s: %v", rerr.Method, rerr.Pattern, rerr.Err)
}Builder accumulates errors so a gateway can report every problem in a
configuration file rather than only the first — see Hot reload.
ValidateHost checks a host pattern without registering anything, so a bad
configuration entry can be rejected before a table is built.
for _, ri := range r.Routes() {
fmt.Println(ri.Method, ri.PathTemplate, ri.Params, ri.Meta)
}
route := r.Route("createOrder") // by name
fmt.Println(r.String()) // whole table, for start-up logsRouteInfo carries Name, Method, PathTemplate as written, Hosts,
Params, the Handler before middleware, and any Meta annotations. Each
ParamInfo gives Name, Position, CatchAll and InHost.
An OpenAPI generator needs nothing else — in particular it never has to
reconstruct a template from a compiled regular expression. Value types are not
part of the pattern, so a generator carries schema information in WithMeta.
A *Route obtained from Route(name) or RouteOf(req) exposes the same
information through Method(), Pattern(), Hosts(), Name(), Handler(),
Params() and Meta(key).
route, ok := r.Lookup(req) // no allocation, no parameters
var buf [8]njia.PathParam
route, params, ok := r.LookupInto(req, buf[:0]) // no allocation, with parametersUseful for authorization checks, metrics labelled by route template, and anything that needs to know which route would serve a request without serving it.
err := r.Swap(func(b *njia.Builder) error {
for _, route := range configFromYAML() {
if err := b.Handle(route.Method, route.Path, route.Handler); err != nil {
return err
}
}
return nil
})The new table is built and fully validated off to the side. On any error the running table is untouched. On success it is installed with a single atomic pointer store; in-flight requests finish against the old table and there is no lock anywhere on the request path.
A Builder can also be built and inspected on its own, which lets a gateway
report every problem in a configuration file rather than only the first:
b := njia.NewBuilder()
for _, route := range configFromYAML() {
_ = b.Handle(route.Method, route.Path, route.Handler)
}
if errs := b.Errs(); len(errs) > 0 {
return fmt.Errorf("%d bad routes: %w", len(errs), b.Err())
}Builder carries the same registration surface as Router — Use, Group,
Host, Handle, HandleFunc and the method shorthands.
gorilla/mux was archived in December 2022 and has been effectively dormant
since. muxcompat lets a project move off it with an import rewrite:
-import "github.com/gorilla/mux"
+import mux "github.com/jkaninda/njia/muxcompat"Nothing else changes. The package reproduces gorilla's exported API and its
observable behavior — route ordering, strict-slash redirects, path cleaning,
MatchErr propagation, subrouter matcher inheritance, reverse URL building,
Walk, CORSMethodMiddleware.
Querieswith an odd number of arguments records an error and returns nil, so chaining onto it panics. Reproduced, because callers may depend on it.- A host template without a port has the request's port stripped at the first colon; a host template with a port does not.
Methods()with no arguments matches nothing.Queries("k", "")matches the key with any value.- A capturing group inside a variable pattern panics at registration.
Where njia deliberately differs, it is only by being more robust: a handful of
inputs make gorilla fault at runtime (nil pointer dereference, slice bounds out of range) and njia serves them instead. The differential harness treats a
gorilla runtime fault as a gorilla bug and only requires that njia does not
fault differently.
muxcompat is a bridge. It stays published for anyone migrating off gorilla,
but new features go into the native API. Nothing in muxcompat imports the
root njia package; the two surfaces evolve independently on top of shared
internal/ packages.
Behavior is never written from memory or from documentation prose. Every gorilla behavior njia reproduces was first observed by running real gorilla.
internal/difftestdrives both engines with identical route tables and identical requests, then compares the matched route, captured variables, response status, redirect location, response body, match error, per-route build errors and panic behavior.internal/difftest/vendoredis gorilla's own test suite, adapted to targetmuxcompat. It carries gorilla's BSD-3-Clause header; only test cases and fixtures were taken, never implementation code.OMITTED.mdrecords the handful of white-box tests that could not be expressed through the exported API.- A property-based generator builds random route tables and request paths covering static paths, wildcards, regular expression constraints, prefixes, host templates, methods, queries, headers, schemes, subrouters, overlapping routes, percent-encoded and empty and dot segments, very long paths and unicode. CI runs 200,000 generated cases per commit.
- Real route tables extracted from Okapi and Goma Gateway are replayed against both engines under five router configurations. This is the migration acceptance gate.
- The lookup index is proved inert:
muxcompatcan be forced onto the plain ordered scan, and a test drives every table both ways and requires the two to agree on every observable field. - Host routing is checked against a reference model: a deliberately naive
resolver that scans every route and sorts, run against 400 generated route
tables over every combination of 11 hosts, 13 paths and 5 methods — about
286,000 comparisons. It is what caught the specificity bug that let
/api/{rest...}shadow/api. - Allocation counts are asserted, not hoped for. Dedicated tests require a static match to serve with zero allocations and the tree lookup to capture parameters without allocating, so a regression fails the build rather than quietly showing up in a benchmark later.
The native router matches with a segment-indexed prefix tree and a direct map
lookup for fully static patterns. muxcompat splits its table: routes that are
static segments plus plain {name} wildcards with at most a method filter go
into the tree, everything else stays on an ordered scan, and registration
sequence numbers are compared across the two so gorilla's
first-registered-wins ordering is preserved exactly.
internal/difftest/bench compares gorilla/mux, the standard library
ServeMux, chi and both njia surfaces at 10, 100 and 1000 routes, across static
hits, parameter hits, deep nested hits, 404 misses, 405 mismatches,
virtual-host routing and table registration.
Run the grid yourself:
cd internal/difftest
go test -run '^$' -bench . -benchmem -count=6 ./bench/...
go run golang.org/x/perf/cmd/benchstat@latest -col /size <output>BenchmarkGateway_* measures the two proxy features against the same table
registered without them. These are native-only and sit outside the cross-engine
grid, because gorilla has no equivalent of either and a comparison against an
engine doing something different says nothing.
go test -run '^$' -bench 'BenchmarkGateway_' -benchmem -count=6 ./bench/...Unlike the grid, both sides of each comparison come from one binary, so the alignment noise described below does not apply and the deltas are readable directly. Two properties are worth confirming on your own hardware.
-count=6andbenchstatare not optional ceremony. A single pass is noisy enough that machine drift reads as a real regression.- A delta under roughly 6% is not attributable. When comparing two njia
revisions, read the
gorilla,stdlibandchirows first. Their code is identical between revisions, yet they still move by several percent, because two different njia binaries shift code alignment around unrelated functions. That floor is a property of the binaries, not of the machine, so no amount of repetition or interleaving removes it. Promote a delta to real only if it clears the band and is corroborated — theLookup/*rows, which measure matching withoutServeHTTP, are a good independent check on theRouter_*rows.
The grid also contains TestGridSanity and TestHostGridSanity, which assert
that every engine really returns 200/404/405 for the scenarios it is
benchmarked on, so no router can look fast by quietly 404ing.
Apache-2.0. Test cases and fixtures adapted from gorilla/mux are BSD-3-Clause
and retain their original copyright header; see NOTICE.