Client-side routing
Follow the links below. The address changes and the back button works, but the page never reloads: the router matches each address against URL patterns and turns it into an F# Route value, which the component renders.
The "Go to user" button does the same from code, with routing.Navigate. Try reloading the page
on one of the routes, or editing the number in the address bar.
How it works
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"Why only this page?
The rest of this site is ordinary HTML pages with components placed in them. That suits a content site: every page loads directly, and search engines and link previews see the text without running any JavaScript.
Client-side routing suits applications, where the page stays open and state lives in memory.
It needs the server to return the app for every address it handles, not just the first one.
GitHub Pages can't be configured to do that, so this site copies this page to
404.html. Opening or reloading one of the routes above gets GitHub's 404 response,
which is this page, and the router then renders the right route.