Routing in Tesserae

This guide covers the built-in Router helper for SPA-style navigation. The router is intentionally lightweight: it listens for URL changes, matches routes against registered patterns, and calls your handlers with parsed parameters.

Key concepts

  • Hash-based paths: Routing reads from window.location.hash (e.g. #/view/details).
  • Route registration: Map a unique identifier and a path to a callback with Router.Register.
  • Initialization: Call Router.Initialize() once, then Router.Refresh(...) after registering routes to build the match list.

A routing demo

A page-level router maps URLs to views: register routes once at startup, keep a reference to a view container, and have each route replace the container's contents with the page it produced.

The browser-window mockup below registers three routes — home, view/:id, and search — and shows what the router resolves as you click the links. The address bar is the live window.location.hash; the area below it is the view the matched route produced.

Each route handler clears the shared Content container and adds the page it produced, so the view follows the route rather than a specific component instance — no observable, just direct rendering. (If you prefer reactive updates, you can instead hold the current view in an Observable and render it with DeferSync.)

Registering routes

Register routes once, when your app starts — not inside a component's Render(). Router.Register maps an identifier and a path pattern to a callback. A path segment prefixed with : is a parameter and is captured into the handler's Parameters.

// `content` is a long-lived container; each route replaces its contents.
Router.Register("home", _ => { content.Clear(); content.Add(HomePage()); });
Router.Register("view/:id", p => { content.Clear(); content.Add(DocumentPage(p["id"])); });

Router.Initialize();
// Match the URL the app loaded on, without changing it.
Router.Refresh(onDone: Router.ForceMatchCurrent);

Route strings are normalized for you: Register trims a leading # and ensures a single leading /, so "view/:id" and "#/view/:id" register the same route.

On a real page, change the route with Push or Replace:

  • Router.Push(path) pushes a new history entry and updates the URL — the new URL appears in the browser's back-button history.
  • Router.Replace(path) updates the URL in place, without adding a history entry.
  • Router.Navigate(path, reload: true) re-runs the matcher against the current URL and re-activates the matching route. Router.ForceMatchCurrent() is shorthand for Navigate(window.location.hash, reload: true).
Button("Open document 42").OnClick((s, e) => Router.Push("#/view/42"));

Push and Replace change the URL only; they do not, on their own, re-run the matched route's callback (see Remarks).

Route parameters and query strings

Route parameters come from two places. Path segments prefixed with : (such as :id) are captured positionally. Query-string pairs (?key=value) appended to the hash are parsed into the same Parameters collection — you don't declare them in the route, just read whichever keys you expect.

Router.Register("search", p =>
{
    var term = p.ContainsKey("term") ? p["term"] : "";
    var page = p.ContainsKey("page") ? p["page"] : "1";
    ShowResults(term, page);
});

// Navigating to this hash calls the handler with term="computer", page="2":
//   #/search?term=computer&page=2
  • Router.OnBeforeNavigate(...) lets you block navigation (return false to cancel).
  • Router.OnNavigated(...) lets you respond after navigation completes.
  • Router.OnNotMatched(...) is invoked when no route matches the new URL.

Remarks

Router.Push and Router.Replace change the URL but do not, on their own, re-run the matched route's callback. A route callback runs when the URL is matched: at startup (the Refresh(onDone: ForceMatchCurrent) call), on browser back/forward navigation, and whenever you call Router.ForceMatchCurrent(). So after a programmatic Push/Replace, either call ForceMatchCurrent() to re-run the matching route, or update your view state directly in the click handler — pick one approach and keep it consistent.

Router is a process-wide singleton and keeps the first registration for a given identifier. Register your routes once, at startup, and have the handlers render into a long-lived container (or bind an Observable), so the view follows the route rather than a specific component instance.

The code shown above is the real-app version. The live preview actually runs a slightly different program — it navigates by setting window.location.hash and calling Router.ForceMatchCurrent() instead of Router.Push, because the documentation preview is a sandboxed about:srcdoc iframe where the History API (history.pushState/replaceState) is unavailable and throws a SecurityError. Your own pages aren't sandboxed, so navigate with Router.Push/Router.Replace as shown.

Recommendations

  • Register routes once, at application startup — not inside a component's Render(). Render each route into a long-lived view container (clear it and add the new page), or bind an Observable with DeferSync if you prefer reactive updates.
  • Register routes first, then call Initialize, Refresh, and ForceMatchCurrent so the initial URL is matched without changing it.
  • Use Push/Replace to navigate and keep a clean history stack. They only change the URL — call ForceMatchCurrent() afterwards (or update your view directly) to reflect the new route.
© 2026 Curiosity. All rights reserved.