Dynamic base path for an Elm SPA

While building an Elm SPA dashboard, I faced the following problem. In the local development environment, the URL to access it is http://localhost:1234, which is Parcel's default URL, and the Elm SPA gets mounted in /, so Elm navigation handles as expected any internal routes like /projects or /tasks. The problem came while deploying it into production because the base URL didn't match the root path. In other words, it looked something like https://nifty-minsky-538aab.netlify.com/private/admin/ where /private/admin/ was the base path for the application, and this path could change depending on the environment, which made Elm navigation tricky, especially while parsing URLs to get the current route. I wanted to avoid using URL fragments, so this is how I solved it.

The <base> HTML element

First of all, I needed a way to prepend the dynamic base URL to any of the internal Elm routes. After some research I found the handy <base> HTML element, which specifies the base URL to use for all relative URLs contained within a document. This means that if you set <base href="http://localhost:1234/private/admin/">, any relative link I would add like <a href="projects">Projects</a>, automatically points to http://localhost:1234/private/admin/projects, and that was exactly what I was looking for.

<!DOCTYPE html>
<html lang="en">
  <head>
    <base href="{{ BASE_URL }}">
  </head>
  <body>
    <main></main>
    <script src="./js/index.js"></script>
  </body>
</html>

Setting the href value for the current environment is easy using environment variables, depending on the technology stack you are using.

Passing the base path to the Elm application

Now that I had a way to set the base URL to all the internal links of the application, I needed a way to make Elm aware of this base path, which was pretty straightforward using flags and the baseURI property:

import { Elm } from '../src/Main.elm';

const basePath = new URL(document.baseURI).pathname;

Elm.Main.init({
  node: document.querySelector('main'),
  flags: { basePath },
});

baseURI basically returns the document's location, unless you set <base> in which case it always returns the value set. I only needed the path, therefore taking it from URL(document.baseURI).pathname and passing it to the Elm.Main.init function as a flag.

Elm routing and the base path

I always like defining the application routes as soon as possible, which helps me understand how to structure it. Moreover, in this particular case, routing was the source of the issue and the solution ifself, so let's have a look at the Route module I implemented:

-- src/Route.elm

module Route exposing
    ( Route(..)
    , fromUrl
    , toString
    )

import Url exposing (Url)
import Url.Parser as Parser exposing (Parser)


type Route
    = Home
    | Projects
    | Tasks
    | NotFound


parser : Parser (Route -> b) b
parser =
    Parser.oneOf
        [ Parser.map Home Parser.top
        , Parser.map Projects (Parser.s "projects")
        , Parser.map Tasks (Parser.s "tasks")
        ]

-- ...

This is pretty much the standard way of defining routes and their parser in Elm, and there wasn't any particular change I had to implement to make it work. However, both fromUrl and toString functions needed to be slightly different than usual:

-- src/Route.elm

-- ...


fromUrl : String -> Url -> Route
fromUrl basePath url =
    { url | path = String.replace basePath "" url.path }
        |> Parser.parse parser
        |> Maybe.withDefault NotFound


toString : Route -> String
toString route =
    case route of
        Home ->
            ""

        Projects ->
            "projects"

        Tasks ->
            "tasks"

        NotFound ->
            "not-found"

fromUrl takes a basePath and a Url parameter and returns a Route. The first parameter is the flag passed to the Elm application on its initialization, and to get the corresponding Route, we only need to remove basePath from its path and parse it as usually. Bear in mind, that this only works with URLs built using the <base> element set in the document header. Last but not least, the toString function offers a convenient way of building a relative path for a given Route.

Gluing it all together

Having the parsing of URLs solved, building the rest of the application was quite simple. Let's take a look at some of the implementation details:

-- src/Main.elm

module Main exposing (main)


import Browser exposing (Document)
import Browser.Navigation as Navigation
import Html as Html exposing (Html)
import Route exposing (Route)
import Url exposing (Url)

-- MODEL


type alias Flags =
    { basePath : String }


type alias Model =
    { flags : Flags
    , navigation : Navigation
    }


type alias Navigation =
    { key : Navigation.Key
    , route : Route
    }


init : Flags -> Url -> Navigation.Key -> ( Model, Cmd Msg )
init ({ basePath } as flags) url key =
    ( { flags = flags
      , navigation =
            { key = key
            , route = Route.fromUrl basePath url
            }
      }
    , Cmd.none
    )

-- ...

-- MAIN


main : Program Flags Model Msg
main =
    Browser.application
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        , onUrlRequest = UrlRequested
        , onUrlChange = UrlChange
        }

I usually store the flags passed to the application in the model using a custom type named Flags, which in this particular example only contains basePath. I also like to store a Navigation custom element which contains a Navigation.Key, necessary for navigating, and the current route. The init function is using the previously defined Route.fromUrl function to set the current route from the browser's URL and the basePath flag. However, it also needs to set it every time the URL changes:

-- src/Main.elm

-- ...

-- UPDATE


type Msg
    = UrlRequested Browser.UrlRequest
    | UrlChange Url


update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ flags, navigation } as model) =
    case msg of
        UrlRequested urlRequest ->
-- ...

        UrlChange url ->
            ( { model
                | navigation =
                    { navigation
                        | route = Route.fromUrl flags.basePath url
                    }
              }
            , Cmd.none
            )

And this is how I created the navigation links using the Route.toString function:

Html.div
    []
    [ Html.a
        [ Html.href <| Route.toString Route.Home ]
        [ Html.text "Home" ]
    , Html.a
        [ Html.href <| Route.toString Route.Projects ]
        [ Html.text "Projects" ]
    , Html.a
        [ Html.href <| Route.toString Route.Tasks ]
        [ Html.text "Tasks" ]
    ]

And that's it; everything worked like a charm. Being honest, I tried different approaches before getting to this solution, including custom Url parsers, which is something difficult to understand for me. Have you faced the same issue? If so, I hope this solution helps you on the next occasion, and if you have solved differently, please share it :)

Happy coding!

comments powered by Disqus