mpizenberg/elm-url-navigation-port/1.1.0

elm-url-navigation-port

Port-based SPA navigation for Elm's Browser.element.

Demo app using this package

Use this instead of Browser.application when you need URL routing in embedded Elm apps, micro-frontends, or any context where you want full control over history management. I would often suggest staying on Browser.element for its simplicity and flexibility, as well as its better compatibility with external libraries and browser extensions. As a bonus, this package enables pushing state objects to the browser history API, allowing for more complex navigation patterns, such as multi-step wizards not changing the url.

Note: The browser's History API only accepts same-origin URLs. This package uses AppUrl from lydell/elm-app-url to represent navigation targets, which produce relative URL strings by construction — always same-origin.

Installation

Elm side:

elm install mpizenberg/elm-url-navigation-port

JS side:

npm install elm-url-navigation-port

Setup

1. Declare ports in your Elm app

port module Main exposing (main)

import Navigation as Nav

port navCmd : Nav.CommandPort msg
port onNavEvent : Nav.EventPort msg

2. Wire them up in JavaScript

<script type="module">
  import * as Navigation from "elm-url-navigation-port";

  const app = Elm.Main.init({
    node: document.getElementById("app"),
    flags: location.href,
  });

  Navigation.init({
    navCmd: app.ports.navCmd,
    onNavEvent: app.ports.onNavEvent,
  });
</script>

3. Use in your Elm code

import AppUrl exposing (AppUrl)
import Navigation as Nav

-- Subscribe to navigation events
subscriptions : Model -> Sub Msg
subscriptions _ =
    Nav.onEvent onNavEvent GotNavigationEvent

-- Navigate
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NavigateTo appUrl ->
            ( model, Nav.pushUrl navCmd appUrl )

        GotNavigationEvent event ->
            ( { model | page = route event.appUrl }, Cmd.none )

Five navigation patterns

1. Page navigation — pushUrl

Standard SPA navigation. Creates a history entry and notifies Elm of the new URL. The browser back button works via popstate.

Nav.pushUrl navCmd (AppUrl.fromPath [ "about" ])

2. Page navigation with state — pushUrlWithState

Like pushUrl, but also attaches a state object to the history entry. Use this when you need metadata alongside a URL change (e.g. scroll position, referrer context).

Nav.pushUrlWithState navCmd
    (AppUrl.fromPath [ "product", "42" ])
    (Encode.object [ ( "scrollY", Encode.int 250 ) ])

The state arrives in event.state on back/forward navigation, just like with pushState.

3. State-based navigation — pushState

Push a state object without changing the URL. Useful for wizard steps or tab flows that should support the back button but don't need distinct URLs.

Nav.pushState navCmd
    (Encode.object [ ( "wizardStep", Encode.int 2 ) ])

On back-button press, the state object arrives in event.state. Decode it in your GotNavigationEvent handler:

GotNavigationEvent event ->
    let
        step =
            Decode.decodeValue (Decode.field "wizardStep" Decode.int) event.state
                |> Result.withDefault 1
    in
    ( { model | page = Wizard (intToStep step) }, Cmd.none )

4. History traversal — back / forward

Navigate backward or forward through the session history, like the browser's back and forward buttons. Supports jumping multiple steps at once.

Nav.back navCmd 1     -- go back one page
Nav.back navCmd 2     -- go back two pages
Nav.forward navCmd 1  -- go forward one page
Nav.forward navCmd 2  -- go forward two pages

The existing popstate listener handles the resulting navigation event automatically — no extra wiring needed.

5. Cosmetic URL update — replaceUrl

Update the URL bar without creating a history entry and without notifying Elm. The model stays the source of truth. Use this for display or shareability (e.g. fragments, counters).

Nav.replaceUrl navCmd
    { path = [ "about" ]
    , queryParameters = Dict.empty
    , fragment = Just (String.fromInt counter)
    }

Flags

Configure your server to always serve your root index.html file whatever the actual url that was provided to your server. On cloud platforms providing static servers, such as Cloudflare Pages, there is usually an option for this. Then pass location.href as a flag so Elm can route the initial page:

main : Program String Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view
        }

init : String -> ( Model, Cmd Msg )
init locationHref =
    ( { page =
            Url.fromString locationHref
                |> Maybe.map (AppUrl.fromUrl >> route)
                |> Maybe.withDefault NotFound
      }
    , Cmd.none
    )

Prevent default on internal links to avoid full page reloads:

import AppUrl exposing (AppUrl)
import Json.Decode as Decode

navLink : AppUrl -> String -> Html Msg
navLink appUrl label =
    a [ href (AppUrl.toString appUrl), onClickPreventDefault (NavigateTo appUrl) ]
        [ text label ]

onClickPreventDefault : msg -> Attribute msg
onClickPreventDefault msg =
    Html.Events.preventDefaultOn "click"
        (Decode.succeed ( msg, True ))

Example

See the example/ directory for a working demo that exercises several of these navigation patterns.

The Nav.Event type contains:

  • appUrl : AppUrl — the parsed URL (path, query parameters, fragment)
  • state : Decode.Value — the history state object, or null if none was set

The decoder fails if location.href is not a valid URL, which should never happen in normal browser navigation.