Build a Simple Fullstack Web App with F# + Giraffe
Date: 2024-10-08 | create | tech | fsharp | giraffe | webapp | giraffe-viewengine | fullstack |
In my last post we built a web API with F# + Giraffe that handles data and returns string representations of that data via its endpoints.
In this post we'll evolve those endpoints to return HTML instead - effectively turning our minimal web API into a fullstack web app with data, endpoints, and a frontend UI.
F# / Giraffe Web App Overview
In this post we'll be creating a minimal fullstack web app with F# + Giraffe.
- Frontend: Server-side Rendered HTML with Giraffe.ViewEngine (an HTML DSL) (learn more)
- Backend: F# / Giraffe (on ASP.NET) (learn more)
- Data: In-memory Data Repo (learn more)
The web app has 3 routes:
/
- Shows all items/detail/ID
- Shows the item with matching id (or NOT FOUND)/create
- Creates a new item
In this post we'll be focusing on how we're rendering our SSR HTML with Giraffe.ViewEngine. For deep dives into other parts of the app, check out the rest of the series:
- web API: Build a Simple Single-File Web API with F# / Giraffe
- Data: Build a Simple F# WebAPI with a Data Repository (F# + Giraffe)
Want access to this project's full source code? HAMINIONs members get access to this project's full source code (github) as well as dozens of other example projects.
Building Server-Side Rendered Frontends with F# and Giraffe.ViewEngine
Giraffe.ViewEngine is an HTML DSL. This means it builds up a representation of the HTML using F#-native datastructures and operations. This is useful because it allows you to leverage the power of F#'s type system and data operations to build your HTML which often leads to safer, more dynamic generation.
For more on Giraffe.ViewEngine, its capabilities, and why you might want to use it: Server-side HTML Rendering with F# and Giraffe.ViewEngine
The first thing we're going to do is build a base HTML layout for our site's pages. This is a common practice in frontend web apps as it allows us to build site pages faster with more consistency and less boilerplate. This will also give you an idea of how you might build and use your own templates / components in a web app.
Here we're going to use a slots approach to make this layout composable. This will make it more useful in more usecases by giving more power to the caller for how they want it to be built.
renderWithBaseLayout
let renderWithBaseLayout
(headSlot: XmlNode list)
(bodySlot: XmlNode list)
(footerSlot: XmlNode list)
: XmlNode
=
html [] [
head [] [
meta [
_charset "UTF-8";
_name "viewport";
_content "width=device-width, initial-scale=1"
];
yield! headSlot;
]
body [] [
yield! bodySlot;
]
footer [] [
yield! footerSlot;
]
]
With the base layout taking care of the HTML boilerplate, we can move onto rendering each of our pages.
Rendering the Index Page
The index page /
returns a count and list of all items in our data repository.
- Takes in a list of items
- Displays the count
- Displays the list of items
This is an example of how using a DSL allows us to leverage F#'s list operations to dynamically generate our HTML (all in a type-safe way!).
renderIndexPage
let renderIndexPage
(allItems: SimpleItem list)
: Task<XmlNode>
=
task {
let count = allItems.Length
let itemListMarkup =
div [] [
h3 [] [ str $"Total Count: {count}"]
ul [] [
yield!
allItems
|> List.map (fun i ->
li [] [ str (i.Id.ToString()) ]
)
]
]
return
renderWithBaseLayout
[]
[ itemListMarkup ]
[]
}
Rendering the Detail Page
The detail page /detail/ID
takes in a single item option. We use option here because there's always the possibility the ID is bad (and thus no corresponding item) and it's usually good practice to handle known edge cases.
- If no item -> Not found!
- If item -> Display the ID
renderDetailPageMarkup
let renderDetailPageMarkup
(item: SimpleItem option)
: XmlNode
=
let itemMarkup =
div [] [
match item with
| None -> h3 [] [ str "Not Found!" ]
| Some i -> h3 [] [ str $"Id: {i.Id.ToString()}" ]
]
renderWithBaseLayout
[]
[ itemMarkup ]
[]
Returning SSR HTML Pages to Giraffe Endpoints
So far we've built up DSL representations of our HTML pages with F# but we haven't yet turned them into HTML or returned it to the caller. Here we'll discuss how we hook up our Giraffe endpoints to these HTML components.
First - this is how we declare our endpoints:
let webApp =
[
GET [
route "/" (getAllHttpHandler)
routef "/detail/%s" (
fun id -> detailHttpHandler id
)
route "/create" (createHttpHandler)
]
]
Basically this is just mapping routes to functions that will fulfill the request:
/
->getAllHttpHandler
/detail/ID
->detailHttpHandler id
/create
->createHttpHandler
For more details on Giraffe Endpoints, check out my other posts:
In each of our handlers, we're transforming Giraffe.ViewEngine's HTML DSL into an HTML string using RenderView.AsString.htmlNode
. This gives us a data type that can be written as a response back to the caller using ctx.WriteHtmlStringAsync
.
createHttpHandler
let createHttpHandler =
handleContext( fun (ctx: HttpContext) ->
task {
let newItem = itemRepo.Create()
let markup =
renderWithBaseLayout
[]
[
h3 [] [ str "Item Created!" ]
p [] [ str $"New Item: {newItem.Id.ToString()}" ]
]
[]
return!
markup
|> RenderView.AsString.htmlNode
|> ctx.WriteHtmlStringAsync
}
)
getAllHttpHandler
let getAllHttpHandler =
handleContext( fun (ctx: HttpContext) ->
task {
let! indexPageMarkup =
renderIndexPage
(itemRepo.GetAll())
return!
indexPageMarkup
|> RenderView.AsString.htmlNode
|> ctx.WriteHtmlStringAsync
}
)
detailHttpHandler
let detailHttpHandler
(id: string)
=
handleContext( fun (ctx: HttpContext) ->
task {
let item = itemRepo.GetOne(id)
let detailPageMarkup = renderDetailPageMarkup item
return!
detailPageMarkup
|> RenderView.AsString.htmlNode
|> ctx.WriteHtmlStringAsync
}
)
Next
That's a minimal example of how you might build a fullstack webapp with F# using Giraffe.ViewEngine to build your Server-Side Rendered HTML.
Want to build a fullstack webapp with F#? CloudSeed (my F# project boilerplate) gets you up and running with a fullstack webapp using F# + Giraffe in 10 minutes so you can start working on your app, not fiddling with setup.
If you liked this post you might also like:
Want more like this?
The best / easiest way to support my work is by subscribing for future updates and sharing with your network.