Routing is how Suprnova turns an inbound HTTP request into a handler call.
You declare your routes in src/routes.rs using the routes! macro (or
build a Router by hand), then Server::from_config takes that router
and runs it for the life of the process. Same shape as Laravel's
routes/web.php, with Rust types instead of facades.
// src/routes.rs
use ;
use cratecontrollers;
routes!
The macro expands to pub fn register() -> Router { ... }. Call it from
your bootstrap and hand the result to the server.
HTTP verbs
One macro per verb. All seven take a path-then-handler pair and return a
builder you can chain .name(...) and .middleware(...) onto.
| Macro | Method | Use for |
|---|---|---|
get! |
GET | Read endpoints, static pages |
post! |
POST | Create resources |
put! |
PUT | Full replacement updates |
patch! |
PATCH | Partial updates (RFC 5789) |
delete! |
DELETE | Destroy |
head! |
HEAD | Headers-only probes (HEAD falls back to the GET registry per RFC 9110 § 9.3.2 when not explicitly registered) |
options! |
OPTIONS | Capability discovery, Accept-Patch. CORS preflight is answered by CorsMiddleware before the router, so you usually don't need this one |
use ;
routes!
Every verb macro checks at compile time that the path starts with / -
a missing leading slash fails the build, not a request.
Multi-method and any!
any! registers one handler against all seven common verbs. Use it for
webhook receivers and other endpoints that need to accept whatever HTTP
sends.
use ;
routes!
When you only want a subset of verbs sharing one handler, reach for the
builder API and Router::methods:
use Router;
use Method;
let router = new
.methods
.name
.middleware;
.name(...) and .middleware(...) fan across every verb the route was
registered against, so reverse-lookup yields the same URL whichever
method the caller looks up.
WebSocket routes
ws! registers a long-lived upgrade handler. The macro is part of the
same routes! body - covered in detail by WebSockets.
Route parameters
Dynamic segments use curly braces ({id}). For familiarity Suprnova also
accepts Express/Rails-style colons (:id) and normalises them to braces
before handing the pattern to matchit.
routes!
The colon is only treated as a parameter opener at the start of a path
segment, so literal colons mid-segment survive untouched
(/files/note:draft stays a literal route, not /files/{draft}).
Read parameters off the request inside a handler:
use ;
pub async
For typed extraction without the unwrap_or dance, see route model
binding below or #[handler] in Controllers.
Optional parameters
End a parameter name with ? to make the segment optional. /posts/{id?}
matches /posts and /posts/42:
routes!
On the short form the request has no id, and req.param("id") returns
Err(ParamError). Read it with req.param("id").ok() when you want an
Option. The colon spelling takes the ? too: /posts/:id?.
- Optional parameters fill from the left. In
/archive/{year?}/{month?}the router matches/archive,/archive/2026and/archive/2026/05. It never matches a month without a year. - Only optional parameters may follow an optional one.
/posts/{id?}/commentsis refused when you register it, because the router could not tell a request that leavesidout from one that fills it. So is an optional parameter that is only part of a segment (/a/pre-{x?}), and a pattern with an optional parameter and an empty segment, such as a trailing slash. - Middleware, name and constraints apply to every form. The route's
middleware runs on
/postsas it does on/posts/42. - A second route for one form of an optional route is refused.
/posts/{id?}and a separate/postsroute, or a separate/posts/{id}route, collide when you register them. - A WebSocket route takes optional parameters as well.
route(...)leaves an optional segment out when it has no value.route("archive", &[])returns/archive. A value for a later parameter with none for an earlier one is a missing parameter:try_routereturnsRouteUrlError::MissingParams.
Parameter constraints
A constraint holds a parameter to the values it may take. The router checks
it after the path has matched. A value the constraint refuses is a 404, as
if the route had not matched, and neither the route's middleware nor its
handler runs. Chain a constraint onto a route:
use ;
routes!
The same methods are on the Router builder, where they apply to the route
you just registered:
use Router;
let router = new
.get.where_number
.get
.where_pattern
.where_in;
| Method | The parameter must be |
|---|---|
where_number(param) |
One or more ASCII digits. |
where_alpha(param) |
One or more ASCII letters. |
where_alpha_numeric(param) |
One or more ASCII letters and digits. |
where_uuid(param) |
A UUID in its hyphenated form, in either case. |
where_ulid(param) |
A ULID: 26 characters of Crockford base 32. |
where_in(param, values) |
One of values, compared exactly. |
where_pattern(param, expression) |
A value that the regular expression matches from its first character to its last. |
There is no where! macro. The constraints are methods on the route.
- A pattern has to match the whole value.
where_pattern("id", "[0-9]+")refuses12a, as it does in Laravel. \dand\wmatch every script. They match the digits and letters of all scripts, where Laravel's match ASCII alone. Write[0-9]for the ASCII digits, or usewhere_number.- A constraint skips a parameter the request left out. An optional parameter is checked only when it is there.
- A constraint holds for the method you set it on. A constraint on a
GETroute does not apply to aPOSTroute with the same pattern. Anany!route and a route with several methods hold every method they cover. - A group's constraint may name a parameter of the group's prefix. In
group!("/teams/{team}", { get!("/members", h).where_number("team") }),teamis a parameter of the prefix. - A constraint on a parameter the route does not have stops the boot.
It would never be checked, and the route would look guarded while it is
not.
where_patternalso panics on an expression that is not a regular expression.
Every route builder also has .constrain(param, ParamConstraint), which the
where_* methods call. A Router route has .try_constrain(param, constraint), which returns Result<_, FrameworkError> where constrain
panics. ParamConstraint::pattern(expression) returns a Result for an
expression that is not a regular expression, and ParamConstraint::one_of
builds the list form.
Route model binding
When a handler parameter is a SeaORM *::Model type, #[handler]
extracts the matching path parameter, parses it as the primary-key type,
and fetches the row from the database. A missing row yields 404; a
parameter the PK type can't parse yields 400.
use ;
use crateusers;
// Route: GET /users/{user}
pub async
The parameter name (user) is what #[handler] looks up in the matched
route's params - so the placeholder must match (/users/{user}, not
/users/{id}).
Multiple models in one signature work the same way; mix them with form
requests, primitives, or Request:
// Route: PUT /posts/{post}/comments/{comment}
pub async
Requirements
Binding is automatic for any SeaORM model whose Entity implements
suprnova::database::EntityExt and whose primary-key type implements
FromStr. EntityExt's blanket-friendly add-on traits give you
Entity::find_by_pk(id), ::all(), ::first(), and friends; route
model binding is just find_by_pk driven by the path parameter.
// src/models/users.rs (the legacy SeaORM-style layout)
pub use *;
use *;
// Enables route model binding (and the Laravel-shaped reader surface).
If your model is declared with the #[suprnova::model] macro (the
Eloquent surface in Eloquent), you reach for it directly:
User::find_by_pk(id).await?. Route model binding via #[handler] still
expects the *::Model shape - pass the SeaORM model type, not the
wrapper struct.
Binding is identity, not authorization
Route model binding answers "does this row exist?" - it does not
answer "is the current user allowed to see this row?". A bare bound
handler lets any authenticated user view any post by guessing
/posts/N. Authorize against the bound model using Gate::authorize or
the #[policy] macro - see Authorization.
Opting out
Don't use the *::Model parameter type. Extract the ID and query
manually:
use ;
use crateusers;
use EntityExt;
pub async
Named routes
Names give you stable identifiers for URL generation. Attach one with
.name(...):
routes!
Names follow the Laravel convention <resource>.<action> -
users.show, posts.destroy, admin.dashboard. Look them up with the
top-level route(name, &[...]) helper:
use route;
let home = route;
// Some("/")
let profile = route;
// Some("/users/123")
route returns Option<String> and percent-encodes parameter values
into path-safe form (so ("slug", "a/b") becomes /posts/a%2Fb -
matchit-safe and round-trips through req.param("slug")). For redirect
targets and email links use the strict sibling suprnova::routing::try_route,
which returns Result<String, RouteUrlError> and refuses to emit a URL
containing an unfilled {placeholder} segment. See
URL Generation for the full URL surface (signed URLs,
absolute URLs, Redirect::route).
Route names are globally unique and process-global. Registering the same
name to two different paths panics at boot - silent shadowing was a
security-shaped bug because redirects would route to whichever
registration happened to win. Use RouteBuilder::try_name (or
suprnova::routing::try_register_route_name) for the fallible variant.
Per-route middleware
Chain .middleware(M) on any route builder:
use ;
use crate;
routes!
.middleware_named("auth") adds the middleware that a registered alias
stands for. .middleware_named("throttle:60,1") gives the alias its
arguments, and a name can stand for a group of middleware. See
Middleware.
Route-local middleware runs after any global middleware
(Server::with_middleware) and any group middleware that wraps the
route. The middleware map is keyed by (method, path), so attaching
auth to POST /api/posts never bleeds onto a public GET /api/posts
on the same path. For the middleware contract and writing your own, see
Middleware.
Route groups
group! factors out a shared path prefix and/or shared middleware:
use ;
use crate;
routes!
A group prefix is concatenated with each route path. A route at /
inside a group resolves to the group prefix exactly
(group!("/users", { get!("/", index) }) → GET /users).
Group name prefix
.name(prefix) puts a prefix in front of the name of every route in the
group. Mirrors Laravel's Route::name('admin.')->group(...). Each route
then names itself by the part the group leaves out:
routes!
The route is known by its full name, route("admin.users.index", &[]), and
not by index. Three rules:
- The prefix is used as you write it. End it with the separator your names use, here the dot.
- A group inside adds its own prefix after this one. With
.name("admin.")outside and.name("users.")inside, a route namedindexisadmin.users.index. A group with no prefix of its own passes the outer one on. - A route without a name stays without one. The prefix names nothing by itself.
The name prefix is on group!. The Router::group(...) builder has no
name prefix.
Group controller
A group can name the module its handlers live in. Write controller = after
the path prefix, and a route names its handler by function alone:
routes!
This is the same as writing controllers::admin::users::index in each
route. The path prefix stays the first argument of the macro. Two rules:
- The controller applies to a handler written as one bare name. A
handler written as a path,
get!("/health", controllers::health::check), is used as written. That lets a route reach a handler in another module. - A group inside names its own controller. A nested
group!is added as written, so it takes its owncontroller =, or none.
.name(...) and .middleware(...) chain onto the group in the same way
with or without controller =.
Nested groups
Groups nest to any depth. Prefixes concatenate; middleware inherits from parent to child:
routes!
| Route | Effective path | Middleware chain |
|---|---|---|
/api/health |
/api/health |
AuthMiddleware |
/api/v1/users |
/api/v1/users |
AuthMiddleware |
/api/v1/admin/stats |
/api/v1/admin/stats |
AuthMiddleware → AdminMiddleware |
For a single route inside a nested group, the execution order is
outermost middleware first: parent group → child group → route-local.
Per-route .middleware(...) runs innermost.
Fallback route
fallback! registers a handler that runs when no other route matches.
Use it for custom 404 pages.
use ;
routes!
// src/controllers/errors.rs
use ;
pub async
Fallback supports its own middleware chain (fallback!(handler).middleware(M)).
If no fallback is registered, the framework returns a plain-text
404 Not Found.
Resource routing
For a standard 7-action REST surface, implement ResourceController and
register the resource through the Router builder. Laravel parity for
Route::resource() and Route::apiResource().
use ;
use Pin;
use Future;
;
let router: Router = new
.resource
.into;
Methods you don't override return 404. Use api_resource to drop
create and edit - the two routes that exist only to render forms.
Default routes and names
| Verb | Path | Trait method | Name |
|---|---|---|---|
| GET | /posts |
index |
posts.index |
| GET | /posts/create |
create |
posts.create |
| POST | /posts |
store |
posts.store |
| GET | /posts/{post} |
show |
posts.show |
| GET | /posts/{post}/edit |
edit |
posts.edit |
| PUT | /posts/{post} |
update |
posts.update |
| DELETE | /posts/{post} |
destroy |
posts.destroy |
The path parameter defaults to the singular of the resource name -
posts → {post}, categories → {category}. Irregular plurals get
the literal last segment; override with .parameter(...).
Restricting and renaming
use ;
new
.resource
.only // pin to two verbs
.names // rename a default
.parameter // {post} → {post_id}
.into;
Rust-side aliases that read better in some call sites: .keep(...) for
.only(...), .drop(...) for .except(...), .rename(...) for
.names(...).
Bulk registration
new
.resources
.api_resources;
Authorizing the whole resource
authorize_resource::<U, R>() attaches the conventional ability check to
every generated route as per-route middleware - Laravel's
authorizeResource parity. Without it, a resource surface is ungated
unless every controller body remembers to call Gate::authorize; a single
forgotten destroy ships an ungated delete.
use ;
// Abilities are keyed on (ability, user type, resource marker type).
;
;
;
;
let router: Router = new
.resource
.
.into;
The action → ability mapping mirrors Laravel:
| Action(s) | Ability |
|---|---|
index, show |
view |
create, store |
create |
edit, update |
update |
destroy |
delete |
PATCH shares the update action, so it is gated identically to PUT. A
denied ability short-circuits with 403 before the handler runs, and an
unauthenticated request fails closed. The resource marker R only needs
Default - the gate discriminates on its type, the way Laravel
discriminates on the model class. See the authorization chapter
for defining the abilities themselves.
Router-level redirects and views
Three sugar methods on Router cover route declarations that don't need
a handler function:
use Router;
use json;
let router = new
// Static redirect: GET /old-pricing → 302 /pricing
.redirect
// 301 sibling
.permanent_redirect
// Inertia static page: GET /about renders the About component
.inertia
.name;
Router::inertia is Suprnova's Route::inertia($uri, $component, $props). It registers GET; a HEAD request falls through to it and
has its body stripped at the server boundary, so there's nothing extra
to register. It returns a RouteBuilder, so .name(...) and
.middleware(...) chain off it like any other route.
Props must be a JSON object, or null for none. Anything else -
an array, a string - is a registration error, not a silently empty prop
bag. try_inertia is the fallible form.
Router::view is the same method under its older name; it returns
Router rather than RouteBuilder, so a route declared with it can't
be named. Prefer inertia.
Why Suprnova diverges
Laravel's Route::view renders a Blade template; Suprnova renders an
Inertia component, because the framework's templating system is Inertia,
not Blade. One consequence: the component name is a runtime string here,
so it doesn't get the compile-time page-component check that the
inertia_response! macro performs. Write the handler out with
inertia_response! when you want a typo in a component name to fail the
build rather than the request.
For redirect responses (not route declarations) - Redirect::route,
Redirect::back, Redirect::intended, signed redirects - see
URL Generation and Responses.
Signed URLs
HMAC-signed routes are routing-adjacent (you mint a URL against a named route, then verify the signature on the inbound request). They're covered in full by URL Generation; the short version:
use url;
let reset = signed_route?;
// /password/reset/42?signature=...
let expires_at = now.timestamp + 3600;
let verify = temporary_signed_route?;
// /verify/email/42?expires=1748803600&signature=...
Verify inside a handler with url::has_valid_signature(&request) (boolean)
or url::signature_verdict(&request) (the three-way
Valid/Expired/Invalid split, so you can render a "request a fresh
link" page instead of a generic 403).
Fallible registration
Route registration runs once at boot, so a duplicate or malformed route
is treated as a programmer error: the plain helpers (Router::get,
post, put, delete, ws, RouteBuilder::name, the
GroupBuilder → Router From conversion) panic to fail loudly at
startup. That's the right default for routes declared in source.
When patterns or names come from a fallible source - dynamic config, a
plugin system, a test that deliberately registers conflicting routes -
use the try_* siblings. They return Result<_, FrameworkError>
(naming the offending method, path, or conflicting name) instead of
panicking:
| Panicking | Fallible sibling | Returns |
|---|---|---|
Router::get / post / put / patch / delete / head / options |
try_get / try_post / try_put / try_patch / try_delete / try_head / try_options |
Result<RouteBuilder, FrameworkError> |
Router::ws (and every ws_* variant) |
try_ws (and every try_ws_*) |
Result<Router, FrameworkError> |
RouteBuilder::name |
try_name |
Result<Router, FrameworkError> |
GroupBuilder → Router via .into() |
GroupBuilder::try_finalize |
Result<Router, FrameworkError> |
ResourceRoutes::register |
try_register |
Result<Router, FrameworkError> |
use ;
// `path` comes from dynamic config; a malformed or duplicate pattern
// is recoverable, not a startup panic.
A duplicate group route is recoverable the same way - because From
cannot be fallible, the fallible counterpart of .into() is the
inherent try_finalize method:
let router: Router = new
.group
.try_finalize?;
The panicking helpers stay as ergonomic escape hatches; the try_*
siblings are purely additive.
Why Suprnova diverges
Dual path-parameter syntax. Laravel uses {param}; Express uses
:param. Suprnova accepts both and normalises :param to {param}
before the path reaches matchit. Both styles compose with everything
else - groups, model binding, signed URLs. The reason isn't
indecisiveness; it's that we can't predict which background you bring,
and routing syntax is too high-frequency a friction point to make people
relearn.
Two co-equal APIs: macro and builder. Laravel ships one DSL
(Route::get(...)). Suprnova ships the declarative routes! { ... }
macro AND the chainable Router::new().get(...).name(...) builder.
They produce identical registrations. The macro reads better for
top-level route tables; the builder reads better when you're composing
routers dynamically (plugins, generated routes, tests). Pick whichever
fits the call site - there's no canonical answer because both shapes
are first-class.
Boot-time panics, not silent shadowing. A duplicate route name or
pattern collision panics at startup. Laravel's array-keyed registries
silently let the later registration win, which is fine when your routes
file is the only registrar but unsafe once plugins or generated routes
enter the picture. try_* siblings are the escape hatch when fallibility
is what you actually want.
Next
- Controllers -
#[handler], form requests, returning JSON/Inertia - Middleware - the
Middlewaretrait, ordering, building your own - URL Generation - named-route URLs, signed URLs, redirects,
RouteUrlError - Authorization - gates and policies for bound models
- WebSockets -
ws!, theWebSocketHandlertrait, per-route config
