F# / Giraffe + HTMX with Giraffe.ViewEngine

Date: 2023-12-25 | create | tech | fsharp | giraffe | html | htmx |

Recently I've been building server-side rendered sites with F#, Giraffe, and HTMX. I've even gone so far as to dive into the world of HTML DSLs with Giraffe.ViewEngine which I've found to have surprisingly good devx and performance (and which currently runs this site).

In this post we're going to put them all together to explore how to build apps with F# / Giraffe, using Giraffe.ViewEngine to render HTML, and HTMX to give it superpowers.

Q: How can we build sites with F# / Giraffe, Giraffe.ViewEngine, and HTMX?

Answer

In this post we'll explore an example app that renders a table of random values and uses HTMX to power pagination.

F# / Giraffe + HTMX

This app has two Targets it renders:

  • MainPage - The full page
  • SentinelTable - The table and pagination Target / Interactive Island which can be rendered independently with HTMX

F# / Giraffe + HTMX - Example App

The example app consists of:

This example app is overkill for this site but this is how I like to build things and is close to my F# project boilerplate - CloudSeed. We will not go into all these technologies so I've listed various guides if you'd like to learn more.

As always, the full project source code is available in my HAMY LABS Example Project Repo and available to HAMINIONs subscribers.

Installing Dependencies

We need to install / access a few dependencies for our purposes of building an HTMX UI w Giraffe.

For our F# / Giraffe app we need:

  • Giraffe - Our webserver. You could choose a different webserver, but some of the code here (like getting data off the request) is Giraffe-specific.
  • Giraffe.ViewEngine - Standalone HTML DSL
  • Giraffe.ViewEngine.Htmx - Extensions for Giraffe.ViewEngine that add HTMX helpers

For HTMX, we can simply add the script tag on our rendered HTML so it loads in on the client.

<script src="https://unpkg.com/htmx.org@1.9.9"></script>

Routing to Full vs Partial Targets

With our dependencies installed, we can move onto building the app.

The first thing we need to do is figure out routing. Routing is important because a normal HTML app only has a single render target per page (the whole page) but HTMX allows us to have multiple. So we need a way to tell if the request wants the entire page or just a single part of it.

The way I've been thinking about this is Targets as Interactive Islands. A page may have a few sections / widgets on the page that we may want to fetch dynamically - each of these is a Target / Interactive Island. So far I've found success keeping them pretty large and self-contained cause it's simpler that way.

For routing, we're going to use the HX-Request and HX-Target headers to see if this is an HTMX Request and if it wants a specific Target. If so, we'll try to parse this into an Enum we know about so that we can utilize F#'s type system and match operators to route to the appropriate Target with type-safety.

First let's start with the Enums declaring the Targets on our page. Note that StrEnum is a workaround I built to get String-Backed Enum-like behavior in F# since it's not supported natively.

type MainPageTargets =
| FullPage = 0
| SentinelTable = 1

let mainPageTargetsStrEnum = StrEnum<MainPageTargets>(
    fun e ->
        match e with 
        | MainPageTargets.FullPage -> "full-page"
        | MainPageTargets.SentinelTable -> "sentinel-table"
)

Next let's look at how we're parsing this from the Giraffe request.

  • Get the HTTPContext (this is what stores request data in Giraffe)
  • Look at the HX-Request and HX-Target headers
  • If both exist, try to parse the StrEnum<MainPageTargets> to see what Target it wants - if any
let getTargetFromContext
    (targetsStrEnum : StrEnum<'a>)
    (ctx : HttpContext)
    : 'a option
    =

    let hxRequest = ctx.TryGetRequestHeader "HX-Request"

    if Option.isNone hxRequest 
    then None 
    else

    ctx.TryGetRequestHeader "HX-Target"
    |> Option.bind (
        fun targetString -> targetsStrEnum.GetEnum(targetString)
    )

We utilize this helper in the HTTPHandler (kind of like a Controller in other web frameworks) which wrangles request data into type-safe props for our application code.

let mainPageHttpHandler 
    (sentinelServiceTree : SentinelServiceTree) 
    = 
        fun (next : HttpFunc) (ctx : HttpContext) -> 
                async {
                    let page = getPageNumberFromContext ctx
                    let target = 
                        getTargetFromContext 
                            mainPageTargetsStrEnum
                            ctx

                    return! 
                        renderMainPageAsync
                            sentinelServiceTree
                            {
                                Page = page
                                SentinelCount = 10
                                Target = 
                                    match target with 
                                    | Some t -> t 
                                    | None -> MainPageTargets.FullPage
                            }
                }

Rendering Targets

Now that we know which Target the request is trying to render we can route that infos to return the appropriate HTML.

In this MainPage renderer we are:

  • Creating new example data
  • Fetching paginated example data from our db
  • Rendering both the SentinelTable and FullPage Targets
  • Selectively returning the Target based on the request props we parsed
type MainPageProps = 
    {
        Target : MainPageTargets
        Page : int
        SentinelCount : int
    }

let renderMainPageAsync 
    (serviceTree : SentinelServiceTree) 
    (props : MainPageProps) 
    : Async<XmlNode>
    = 
    async {
        do!
            seq {0 .. 5}
            |> Seq.map (fun _ -> 
                sendCreateSentinelCommandAsync serviceTree)
            |> Async.Sequential 
            |> Async.Ignore
        
        let! sentinelResult = 
                sendGetSentinelsQueryAsync 
                    serviceTree 
                    { 
                        Page = props.Page
                        Count = props.SentinelCount 
                    }

        let sentinels = 
            match sentinelResult with
            | Ok s -> 
                s 
                |> Seq.toList
            | Error s -> raise (System.SystemException("Failed to get Sentinels"))

        let sentinelTable = 
            renderSentinelTableComponent 
                {
                    HasNext = true
                    Sentinels = sentinels
                    Page = props.Page
                }

        let template = 
            html [] [
                head [] [
                    script [
                        _src "https://unpkg.com/htmx.org@1.9.9"
                    ] []
                    meta [
                        _charset "UTF-8"
                    ]
                    title [] [ encodedText "Sentinels Example" ]
                ]
                body [] [
                    h1 [] [ str "Sentinels Table" ]
                    sentinelTable
                ]
            ]

        return 
            match props.Target with 
            | MainPageTargets.SentinelTable -> sentinelTable
            | MainPageTargets.FullPage -> template
    }

Now let's look at the SentinelTable Target and see how we're using HTMX to give it superpowers.

  • SentinelTable - Renders the Target. Note that it sticks its ID on itself so we can uniquely identify it.
  • SentinelTablePagination - Renders the Previous / Next buttons, utilizing the hx-target attribute to identify itself as the Target to be replaced.
let renderSentinelTablePagination 
    (props : 
    {| 
        HasNext : bool 
        Page : int
    |})
    : XmlNode 
    =
    let sentinelTableId = mainPageTargetsStrEnum.GetString(MainPageTargets.SentinelTable)

    // sirhamy: Cannot use Giraffe.ViewEngine.Htmx currently on dotnet 7
    // Issue details: https://github.com/bit-badger/Giraffe.Htmx/issues/8

    div [] [
        (
            if props.Page < 1
            then emptyText
            else 
                button [
                    // _hxGet $"/sentinels?page={props.Page - 1}"
                    // _hxTarget $"#{sentinelTableId}"
                    // _hxPushUrl "true"
                    attr "hx-get" $"/sentinels?page={props.Page - 1}"
                    attr "hx-target" $"#{sentinelTableId}"
                    attr "hx-push-url" "true"
                ] [
                    str "Previous"
                ]
        );
        (
            if not props.HasNext
            then emptyText
            else 
                button [
                    // _hxGet $"/sentinels?page={props.Page + 1}"
                    // _hxTarget $"#{sentinelTableId}"
                    // _hxPushUrl "true"
                    attr "hx-get" $"/sentinels?page={props.Page + 1}"
                    attr "hx-target" $"#{sentinelTableId}"
                    attr "hx-push-url" "true"
                ] [
                    str "Next"
                ]
        )
    ]

let renderSentinelTableComponent
    (props : SentinelTableComponentProps)
    : XmlNode
    = 
    let sentinelTableId = mainPageTargetsStrEnum.GetString(MainPageTargets.SentinelTable)

    div [
        _id sentinelTableId
    ] [
        table [] [
            tr [] [
                th [] [ str "ID" ]
                th [] [ str "Data" ]
            ]
            yield! (
                props.Sentinels
                |> List.map (
                    fun s -> 
                        tr [] [
                            td [] [ str s.id ]
                            td [] [ str s.data.name ]
                        ]
                )
            )
        ] 
        div [] [
            renderSentinelTablePagination 
                {|
                    HasNext = props.HasNext
                    Page = props.Page
                |}
        ]
        div [] [
            str $"Page: { props.Page }"
        ]
    ]

Next

That's it! An F# / Giraffe app rendered with Giraffe.ViewEngine-generated HTML and empowered with HTMX.

I've been having a good time building with server-side rendered UIs and will prob be making some larger projects in the coming weeks.

If you liked this post you might be interested in:

Looking to build your next app with F#? Consider CloudSeed - my F# project boilerplate.

Want more like this?

The best / easiest way to support my work is by subscribing for future updates and sharing with your network.