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).
- Server-side HTML Rendering with F# and Giraffe.ViewEngine
- Simple Interactive Islands with F# and HTMX
- Benchmarks:
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.
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
The example app consists of:
- App - F# / Giraffe
- UI - HTML/X rendered with Giraffe.ViewEngine
- Giraffe.ViewEngine Guide: Server-side HTML Rendering with F# and Giraffe.ViewEngine
- HTMX Guide: Simple Interactive Islands with F# and HTMX
- Data - Entity Framework ORM and DBUp for Migrations
- Entity Framework + F# Guide: Getting Started with F# and Entity Framework
- Hosting - Docker and Docker-Compose for containerization
- Guide: Run F# / .NET in Docker
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
andHX-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:
- Simple Interactive Islands with F# and HTMX
- Build a Simple Markdown Blog with F# / Giraffe
- Build a simple F# web API with Giraffe
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.