Browse documentation

Routing

Routes, Matchers, and Navigation

Build a small linked review app, then trace which contract owns URL input, history, selected work, and page readiness.

A review app has a queue and an issue page. Opening an issue should produce a shareable URL. Clicking another issue should update the selected page. Back should return to the queue, and stopping the application should release its history listener and rendering subscriptions.

These requirements involve three contracts. Route describes valid URL input. Navigation owns history and the decision to commit a destination. Matcher selects and owns live work for the committed URL. A renderer consumes that work. Keeping those responsibilities visible makes routing usable in tests, server requests, commands, and UI without forcing all of them into one component lifecycle.

Run the smallest linked application

Place this browser entry in a page containing a dedicated <div id="review-app"></div>. It uses plain templates because no component-local state needs setup. The application’s Layer owns both the renderer and the browser router; the exported stop function is for the host that owns this mount.

import * as Router from "@typed/router"
import { Effect, Fiber, Layer } from "effect"
import { Fx } from "@typed/fx"
import { DomRenderTemplate, html, render } from "@typed/template"
import { Link } from "@typed/ui/Link"

const Queue = Router.Parse("/issues")
const Issue = Router.Join(Queue, Router.Int("issueId"))

const pages = Router.match(Queue, html`<main><h1>Review queue</h1>
    ${Link({ href: "/issues/42", content: "Review issue 42" })}
  </main>`)
  .match(Issue, (params) => html`<main>
    <h1>Issue ${Fx.map(params, ({ issueId }) => issueId)}</h1>
    ${Link({ href: "/issues/43", content: "Next issue" })}
  </main>`)
  .match(Router.Parse("/not-found"), html`<main><h1>Page not found</h1></main>`)
  .layout(({ content }) => html`
    <nav aria-label="Primary">${Link({ href: "/issues", content: "Queue" })}</nav>
    ${content}
  `)

const host = document.getElementById("review-app")
if (host === null) throw new Error("Missing review-app host")

const application = pages.redirectTo("/not-found").pipe(
  render(host),
  Fx.drainLayer,
  Layer.provide([DomRenderTemplate.using(host.ownerDocument), Router.BrowserRouter(window)]),
)

const fiber = Effect.runFork(Layer.launch(application))
export const stop = () => Effect.runPromise(Fiber.interrupt(fiber))

Open the page at /issues on a host configured to serve this browser entry for its application URLs. The example intentionally shows the decoded issue ID rather than pretending to fetch an issue. The later Matcher lesson adds a concrete service and an executable test for loading when that ID changes.

Router.Int gives the handler a number. Link keeps a real href and routes eligible clicks through Navigation. The layout wraps selected content and can remain compatible across inner selection. The template observes the parameter ref, so moving from issue 42 to 43 changes the heading without a second imperative URL listener.

Fx.drainLayer runs rendering in the application Layer’s Scope. Layer.provide supplies its rendering and routing dependencies within one Layer graph, and Layer.launch keeps that lifetime open. Prefer this composition for application setup: one owning Scope coordinates acquisition and shutdown, and the graph can share dependency builds. Separate Fx.provide boundaries create separate scopes and builds rather than one connected setup. The stop function interrupts the launch Fiber, closing the live render and provided browser-history resources. Merely retaining pages does not run the application. A host that mounts this feature temporarily must call its disposal operation when that owner ends.

Trace a click through the contracts

Clicking “Next issue” asks Navigation to move to /issues/43. Before-navigation handlers can block, cancel, or redirect that proposal. When the destination commits, CurrentPath publishes pathname plus search. Matcher looks up a path shape, decodes the parameters, checks candidate guards, and updates the selected work.

The Issue handler receives live parameters. It can retain compatible local setup while its parameter ref changes. The layout receives live inner content. If a different handler is selected, the old handler’s Scope is replaced and owned work is finalized. State that must survive a handler change belongs above that handler’s owner; see Matcher lifetime.

ContractQuestion it answersNext lesson
RouteWhich path/query values are valid, and what are their decoded types?Typed URL inputs
NavigationWhat is committed, what is pending, and how should history change?History and unsaved work
MatcherWhich candidate runs, which services/layouts stay mounted, and what updates?Live route selection
CurrentRouteWhere is this child structurally mounted?Nested Matcher composition

CurrentRoute is a structural mount tree, not a replacement for currentEntry or a current parameter record. Nested routing shares Navigation; it does not need another browser-history instance.

Check the journey

Open issue 42, follow the link to 43, then use Back. The selected output should follow the URL. Call stop and verify that later navigation does not update this mount.

Choose the next lesson from the table above: URL input, history policy, or selected work.

Keep route-not-found recovery separate from a failed page request. Matcher recovery describes those boundaries.

For server requests, the HTTP recipe shows request-local provision with ServerRouter.