Firelight.Router
Type-safe client-side routing powered by the browser's URL Pattern API, with routes modelled as an F# union.
Why use it
Firelight.Router parses browser URLs into your own F# discriminated union. This turns page routing into a strongly typed pattern match, and helps the compiler ensure every route is handled.
RouterController keeps your component synchronized with the browser location. It intercepts
clicks on matching links, manages back and forward history, and handles smooth scrolling for
#hash anchors. Unmatched links fall back to standard browser navigation. routing.Navigate
does the same from code.
Firelight.Router builds directly on the web standard URL Pattern API (polyfilled for browsers that don't yet support it natively).
Example
Patterns can constrain their parameters: :id(\d+) only matches digits, so
/users/abc falls through to the catch-all * route and renders
NotFound without leaving the page.
module Site.Routing.RouteExplorer
open Fable.Core
open Fable.Core.JsInterop
open Browser
open Browser.Types.URLPattern
open Firelight
open Firelight.Router
open type Firelight.Lit
importPolyfill ()
// Every page the router knows about, as a type. Matching a URL produces one of these.
type Route =
| Home
| User of id: int
| UserPost of userId: int * slug: string
| NotFound
/// Vite's `base`: "/Firelight/" on GitHub Pages.
let basePath: string = emitJsExpr () "import.meta.env.BASE_URL"
let root = basePath + "client-side-routing/"
let private group (name: string) (result: URLPatternResult) =
result.pathname.groups.[name] |> Option.defaultValue ""
// (\d+) only matches digits, so "users/abc" falls through to the catch-all.
// The catch-all keeps every address under root inside the app; links to anywhere else
// (other pages of the site) match no route, so the browser navigates to them normally.
let router =
[
root + "{index.html}?", (fun _ -> Home)
root + "users/:id(\\d+)", (fun r -> User(int (group "id" r)))
root + "users/:id(\\d+)/posts/:slug", (fun r -> UserPost(int (group "id" r), group "slug" r))
root + "*", (fun _ -> NotFound)
]
|> createRouter NotFound
let private examples =
[
""
"users/42"
"users/42/posts/hello-lit"
"users/7/posts/routing-in-fsharp"
"users/abc"
]
let private exampleLink (path: string) =
html $"""<li><a href={root + path}>/{path}</a></li>"""
[<AttachMembers>]
type RouteExplorer() as this =
inherit LightDomElement()
let routing = RouterController(this, router)
override _.render() =
let description =
match routing.route with
| Home -> "the list of examples"
| User id -> $"the profile of user {id}"
| UserPost(userId, slug) -> $"post \"{slug}\" by user {userId}"
| NotFound -> "a not-found page"
// Navigate goes to an address from code, as a click on a link to it would.
let nextUser =
match routing.route with
| User id
| UserPost(id, _) -> id + 1
| Home
| NotFound -> 1
let goToNextUser _ =
routing.Navigate(root + $"users/{nextUser}")
html
$"""
<ul class="route-links">{examples |> List.map exampleLink}</ul>
<p><button type="button" @click={goToNextUser}>Go to user {nextUser}</button></p>
<dl class="route-result">
<dt>Address</dt><dd><code>{window.location.pathname}</code></dd>
<dt>Route value</dt><dd><code>{sprintf "%A" routing.route}</code></dd>
<dt>Renders</dt><dd>{description}</dd>
</dl>"""
defineElement<RouteExplorer> "fl-route-explorer"
Navigate from code
routing.Navigate url goes to url the way a click on a link to it does. When one of the
router's routes matches, the address changes, a history entry is added and the host renders the
new route, without loading a page. When none matches, the browser loads the address, as it does
for such a link. The example's "Go to user" button calls it.
| Call | What it does |
|---|---|
routing.Navigate "/users/42" |
Adds a history entry, so Back returns |
routing.Navigate ("/users/42", replace = true) |
Replaces the current entry, so Back skips it: after a form is saved, say |
The address may be relative to the current one. Going to the current address does nothing.
Routes as Elmish messages
With Firelight.Elmish, keep the route in the model. Give the controller a
function as its third argument: it gets the route after every change (a link, Back or Forward,
Navigate) and when the host connects, so it can dispatch a message.
open Fable.Core
open Browser.Types.URLPattern
open Firelight
open Firelight.Elmish
open Firelight.Router
open type Firelight.Lit
// Before the router is created: browsers without URLPattern get the polyfill.
importPolyfill ()
type Route =
| Home
| User of id: int
| NotFound
let router =
[
"/", (fun _ -> Home)
"/users/:id(\\d+)", (fun (r: URLPatternResult) -> User(int (r.pathname.groups.["id"] |> Option.defaultValue "0")))
]
|> createRouter NotFound
type Model = { Route: Route; Visits: int }
type Msg = RouteChanged of Route
let update msg model =
match msg with
| RouteChanged route -> { Route = route; Visits = model.Visits + 1 }
[<AttachMembers>]
type App() as this =
inherit LitElement()
let loop =
ElmishController.simple this (fun () -> { Route = router.OfLocation(); Visits = 0 }) update
let routing =
RouterController(this, router, fun route -> loop.dispatch (RouteChanged route))
override _.render() =
match loop.model.Route with
| Home -> html $"""<button @click={fun _ -> routing.Navigate "/users/1"}>First user</button>"""
| User id -> html $"<p>User {id}, page {loop.model.Visits}</p>"
| NotFound -> html $"<p>Not found</p>"
update stays pure: it only records the route it's given. To change the route as the result of a
message, call routing.Navigate from the component, where the controller is, rather than from
update.
Install
dotnet add package Firelight.Router
npm install urlpattern-polyfill
Call importPolyfill () once, at the top level of your app's module, before you create a router,
as the example does. A module that creates a router as it loads needs the call itself, above that
code, because the modules it imports run first. A browser with native URLPattern skips the
download. The call compiles to a top-level await, which JavaScript allows only at the top level:
inside a function, the compiled module fails to load. There, loadPolyfill () returns a promise to
wait on instead.