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.
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!
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).
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 with constant props
.view;
Router::view is Suprnova's analogue of Laravel's Route::view($uri, $view, $data). Laravel renders a Blade template; Suprnova renders an
Inertia component, because the framework's templating system is Inertia,
not Blade.
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
