Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Routes, parameters, and navigation

Before You Start

Start with a URL whose routed LiveView mounts and renders, and a named route declaration that decodes it. The Quick start shows the minimal router and layout wiring assumed here.

Name Route Declarations

A route declaration is both an inbound decoder and, when every transformation is reversible, an outbound location builder. Keep important declarations as named values so mounting, links, and navigation effects cannot drift onto different paths or query names:

scala
object Routes:
  val search =
    (live / "search").queryOptional[String]("q")

val routes = Live.router(
  Routes.search -> SearchLiveView()
)

Path codecs decode path segments. query, queryOptional, and schema-derived query codecs decode query values. A routed view receives the final value through LiveView.Routed rather than reading raw request strings.

Extending a route prefix with / preserves its installed modifiers, including layouts, the root layout, connected-turn guards, and connected-resource initializers. You do not have to finish the path before installing them. Prefix layouts retain their installation-time path parameters: the path codec's combiner projects those parameters from the extended destination rather than exposing the larger parameter tuple to the earlier layout.

Map Parameters Into Domain Types

Use mapParams when the codec-facing shape is not the shape the application should use. Supply both directions:

scala
final case class SearchParams(query: Option[SearchTerm])

val search =
  (live / "search")
    .queryOptional[String]("q")
    .mapParams(raw => SearchParams(raw.flatMap(SearchTerm.from)))(
      params => params.query.map(_.value)
    )

The reverse function preserves outbound location construction. If a transformation is genuinely irreversible, use mapParamsDecodeOnly; the resulting builder remains mountable but cannot call location. That compile-time restriction prevents a route from claiming it can safely reconstruct information that it discarded.

Mount And React To Parameters

mount(params, ctx) receives typed parameters for the disconnected render and again for the fresh connected lifecycle. handleParams runs after mount and after each successful live patch:

scala
final class SearchLiveView
    extends LiveView.Routed.Eventless[Model, Option[String]]:

  def mount(params: Option[String], ctx: MountContext) =
    ZIO.succeed(search(params))

  override def handleParams(model: Model, params: Option[String], url: URL, ctx: ParamsContext) =
    ZIO.succeed(search(params))

Choose LiveView.Routed.Eventless when the view reacts to typed route parameters but renders no server-handled browser messages. Choose LiveView.Eventless for the same compile-time restriction on an unrouted view. Both remove the message type and handleMessage rather than asking you to invent an impossible message; switch to the ordinary LiveView or LiveView.Routed form when rendered bindings need to produce application messages.

Keep parameter-derived state in one function so disconnected render, connected mount, browser back and forward, and patches agree. Use handleParams to canonicalize a successfully decoded URL only when the canonicalization cannot loop.

The documentation examples catalog is a real routed view. Its topic query parameter controls a URL-addressable filter, and every topic link builds a typed location before issuing a patch.

Authorize Route Mounts

Use LiveRouteMountAspect to load and authorize a decoded destination before its LiveView is constructed. It can consume identity established by the named live session and emits fresh route context or a LiveRouteMountFailure. Cookies and original HTTP headers instead belong at the document or session-aspect boundary.

A live patch keeps the current lifecycle mounted and calls handleParams; it does not rerun the route aspect. If patched parameters can select another protected resource, reauthorize in handleParams and at the sensitive operation, or use navigation so the destination reruns route admission.

See the canonical session-versus-route comparison and route-context pipeline. The combined example shows typed destination parameters consuming admitted CurrentUser context.

For resource registration rather than authorization or context loading, use withConnectedResources on the route or named live-session builder. Initializers run after all admission/context checks and before connected page mount, with session callbacks before route callbacks and declaration order within each level. They are additive, do not change context or the page model, and fail through Task as mount failures, not controlled authorization rejections. See route and session resources.

Build Locations From Route Declarations

Call location(params) to create a LiveLocation:

scala
val destination = Routes.search.location(Some("LiveView"))
// destination.href == "/search?q=LiveView"

location is concise for total codecs and domain invariants. Use locationEither when encoding can legitimately fail and the caller should recover. LiveLocation is nominal and cannot be created from an arbitrary string, so changing the route declaration forces callers back through its encoder.

Add an in-page fragment after building the typed path and query:

scala
val heading = Routes.search.location(Some("LiveView")).withFragment("results")
// heading.href == "/search?q=LiveView#results"

Pass percent-encoded URI-fragment syntax without the leading #; the method validates but does not encode decoded text, so a space must already be %20. Use withFragment when an invalid application-owned fragment is a programming error. Use withFragmentEither when a fragment comes from a recoverable boundary; it returns Left(LiveLocation.EncodeError.Fragment(...)) for invalid syntax rather than throwing LiveLocation.EncodingException.

The typed documentation navigation example uses the site's actual search route declaration. Its complete executable source is:

Source
scala
final class NavigationExample extends LiveView[NavigationExample.Msg, NavigationExample.Model]:
  import NavigationExample.*

  private val ariaPressed = htmlAttr("aria-pressed", BooleanAsTrueFalseStringEncoder)

  def mount(ctx: MountContext): Task[Model] =
    ZIO.succeed(Model.initial)

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.Select(query) => ZIO.succeed(Model(query))
    case Msg.Reset         => ZIO.succeed(Model.initial)

  def view(model: Signal[Model]): HtmlElement[Msg] =
    val destination = model.map(model => searchLocation(model.query))
    div(
      cls := "docs-navigation-example",
      sectionTag(
        cls                          := "docs-navigation-step docs-navigation-parameters",
        dataAttr("example-controls") := "",
        headerTag(
          span(cls := "docs-navigation-step-number", aria.hidden := true, "01"),
          div(
            h3("Choose search parameters"),
            p("Select a typed value for the route's optional q parameter.")
          )
        ),
        div(
          cls        := "docs-navigation-presets",
          aria.label := "Search query presets",
          Presets.map(query =>
            button(
              typ                           := "button",
              cls                           := "docs-navigation-preset",
              dataAttr("navigation-preset") := query.value,
              ariaPressed                   := model.map(_.query == query),
              on.click(Msg.Select(query)),
              query.label
            )
          )
        )
      ),
      sectionTag(
        cls := "docs-navigation-step docs-navigation-result",
        headerTag(
          span(cls := "docs-navigation-step-number", aria.hidden := true, "02"),
          div(
            h3("Navigate with a LiveLocation"),
            p("One checked destination, two browser-history choices.")
          )
        ),
        div(
          cls := "docs-navigation-route",
          div(
            span(cls                          := "docs-navigation-route-label", "Typed query"),
            code(dataAttr("navigation-query") := "", model.map(_.query.value))
          ),
          div(
            span(cls := "docs-navigation-route-label", "Encoded destination"),
            code(dataAttr("navigation-destination") := "", destination.map(_.href))
          )
        ),
        div(
          cls := "docs-navigation-actions",
          link.pushNavigate(
            destination,
            cls                         := "docs-navigation-primary",
            dataAttr("push-navigation") := "",
            span("Open search"),
            small("Push history")
          ),
          link.replaceNavigate(
            destination,
            cls                            := "docs-navigation-secondary",
            dataAttr("replace-navigation") := "",
            span("Open and replace"),
            small("Replace history")
          ),
          button(
            cls := "docs-navigation-reset",
            typ := "button",
            on.click(Msg.Reset),
            "Reset"
          )
        )
      )
    )
  end view
end NavigationExample

object NavigationExample:
  enum SearchPreset(val label: String, val value: String):
    case LiveView   extends SearchPreset("LiveView", "LiveView")
    case Streams    extends SearchPreset("Streams", "streams")
    case TypedForms extends SearchPreset("Typed forms", "typed forms")

  val Presets = SearchPreset.values.toVector

  final case class Model(query: SearchPreset)

  object Model:
    val initial = Model(SearchPreset.LiveView)

  enum Msg:
    case Select(query: SearchPreset)
    case Reset

  private def searchLocation(query: SearchPreset): LiveLocation =
    DocumentationApplication.SearchRouteBuilder.location(Some(query.value))

Choose Patch Or Navigate

Patches keep the current routed LiveView mounted and call handleParams with the new URL. Use them for filters, pagination, tabs, and other URL state owned by the current view:

  • pushPatch adds a browser-history entry;

  • replacePatch replaces the current entry.

Navigation changes the routed LiveView:

  • pushNavigate adds a browser-history entry;

  • replaceNavigate replaces the current entry.

The new connected root lifecycle reruns its route/session resource initializers on navigation or reconnect. A patch or ordinary message retains the current resources without rerunning those callbacks. Initializers are not inherited by nested LiveViews and never imply shared ownership across roots on one socket or within one named live session.

Rendered links expose the same four choices through link. Prefer links for destinations the user can activate directly: they retain an ordinary href for disconnected rendering, opening in a new tab, and no-JavaScript fallback. Use ctx.nav when navigation is the result of validation or another server-side transition.

Push versus replace is a history decision, not a rendering optimization. Use push when Back should return to the previous state. Use replace for canonicalization and transient intermediate URLs that should not remain in history.

Respect Route And Named Live-Session Boundaries

Live navigation is enhanced only when the destination is compatible with the current named live session and root layout. Crossing an incompatible boundary falls back to an ordinary HTTP request. The destination still needs a real route and must render correctly before JavaScript connects.

Redirects are also typed destinations, but they end the current lifecycle rather than requesting a live patch or navigation. Choose redirects for mount-time authorization, canonical HTTP responses, and completed ordinary HTTP actions.

Prefer APIs that accept LiveLocation. Runtime Navigation string methods such as ctx.nav.pushNavigateUnsafe accept raw destinations and give up route typing, refactoring, and encoding checks, but the connected runtime still rejects literal or percent-encoded control characters and schemes other than HTTP or HTTPS before recording the navigation.

Mount-admission failures are different: LiveRouteMountFailure.redirectUnsafe and LiveMountFailure.redirectUnsafe accept an unchecked URL and perform no same-origin or local-path validation. Use them only for trusted destinations. Never concatenate untrusted input into any unsafe destination; parse it and enforce an application allowlist first.

Connected route tests can explicitly follow navigation within a named live session and verify that the destination reruns its route mount aspects. This protects the mount, but connected-turn guards and authorization immediately before sensitive domain operations remain necessary when policy can change while the route stays mounted. See Exercise actions and navigation. That harness behavior is distinct from browser reconnect timing: only a real browser can prove when the JavaScript client retries after transport loss or falls back to an ordinary HTTP request at an incompatible boundary.