Packages

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"

Try it on the client-side routing page

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.