Custom Router
The Custom Router capability inserts plugin-defined routing logic before bext's built-in router. It exists for the cases the framework router deliberately does not handle: legacy URL schemes imported from other CMSes, traefik-style label-based matching, bulk redirect tables, maintenance mode.
When To Use It
- You are migrating off WordPress / Drupal / Ghost and need to honor
existing permalinks (
/?p=123,/node/42,/2024/03/hello-world/). - You need a traefik-style label system where deploys annotate routes at push time instead of at config time. - You want a 410 Gone list for URLs that no longer exist. - You want a maintenance-mode router that short-circuits every request to a holding page while a migration runs.
If your routing lives in a static config file and uses modern
/posts/:slug style, the built-in router already handles it. Reach
for a custom router when the rules are dynamic, legacy, or label-driven.
The Trait
pub trait CustomRouterPlugin: Send + Sync {
fn name(&self) -> &str;
fn order(&self) -> i32 { 0 } // lower runs first; negative allowed
fn route(&self, req: &RouterRequest) -> Result<RouteDecision, RouterError>;
}
Multiple routers may be installed simultaneously. They run in
ascending order() order; the first router to return anything other
than Pass wins. A router with order() = -1000 front-runs every
other router — useful for maintenance mode.
Key Types
| Type | Purpose |
|---|---|
RouterRequest |
ABI-flat request snapshot: method, host, path, query, headers. |
RouteDecision::Match |
{ handler_id, params } — request is resolved. |
RouteDecision::Pass |
This router declines; next one runs. |
RouteDecision::Rewrite |
{ new_path } — mutate path and restart the pipeline. |
RouteDecision::Respond |
{ status, headers, body } — short-circuit with a full response. |
RouterError |
Malformed (400), Backend (500). |
RouterRequest::header(name) does case-insensitive lookup and returns
the first match.
Decision Variants In Practice
| Variant | Example use case |
|---|---|
Match |
WordPress permalink resolved to posts.show handler. |
Pass |
Router doesn't recognise the URL; let the next one try. |
Rewrite |
Legacy /old/blog/:slug → /posts/:slug. |
Respond |
301 redirect, 410 Gone, maintenance-mode holding page. |
Rewrite restarts the entire router pipeline with the new path. The
runtime enforces a loop guard so a router that always rewrites to the
same path cannot wedge the request.
RouteDecision::Rewrite restarts the entire router pipeline from the first plugin. A chain of two routers that each rewrite the same path will loop until the runtime's loop guard kills the request. Always ensure the rewritten path does not match the same rule again.
Reference Implementation: @bext/router-legacy
@bext/router-legacy matches URL patterns with :param wildcards
and either rewrites or redirects. Rules live in config:
[[plugins]]
name = "router-legacy"
source = "@bext/router-legacy"
order = -100 # run before the built-in router
# WordPress-style permalinks → new post handler
[[plugins.rules]]
match = "/old/blog/:slug"
action = "rewrite"
target = "/posts/:slug"
# Year-based archive → section
[[plugins.rules]]
match = "/archive/:year/:month"
action = "rewrite"
target = "/posts?year=:year&month=:month"
# Deleted content → 410 Gone
[[plugins.rules]]
match = "/retired/*"
action = "respond"
status = 410
body = "This page has been retired."
The same effect in Rust:
use bext_plugin_api::router::*;
use std::collections::HashMap;
struct LegacyRouter;
impl CustomRouterPlugin for LegacyRouter {
fn name(&self) -> &str { "legacy" }
fn order(&self) -> i32 { -100 }
fn route(&self, req: &RouterRequest) -> Result<RouteDecision, RouterError> {
if let Some(slug) = req.path.strip_prefix("/old/blog/") {
return Ok(RouteDecision::Rewrite {
new_path: format!("/posts/{slug}"),
});
}
if req.path.starts_with("/retired/") {
return Ok(RouteDecision::Respond {
status: 410,
headers: vec![("Content-Type".into(), "text/plain".into())],
body: b"This page has been retired.".to_vec(),
});
}
Ok(RouteDecision::Pass)
}
}
Feature Flag
None. The Router trait and types live in bext-plugin-api and are
always available; no cargo feature gates them.
See Also
- @bext/router-legacy — pattern-based legacy URL handler.
- Routing guide — the built-in router's matching rules that run after all custom routers return Pass.
- nginx migration guide — migrating legacy nginx rewrite rules to bext custom routers.
- Frameworks routing — file-system routing for PRISM and other framework integrations.
- Capabilities overview — the full list of pluggable capabilities.