Blogging with F#

In this post I will show you how I created this blog using F#, Notion, htmx, and Tailwind CSS.

TL;DR: Dive into the code and run locally.
git clone https://github.com/ameier38/andrewmeier.dev.git
cd app
docker-compose up -d app
Navigate to http://localhost:5000

Background

I created this blog in order to
  1. Learn how to build a web site,
  2. Learn new things by teaching myself topics through the process of writing tutorials, and
  3. Help others learn by sharing the posts.
It has gone through a few iterations, first using Jekyll, then Fable + React + Airtable, and now just an ASP.NET web server that serves plain ol’ HTML with the content stored in Notion. It would obviously be way less complicated to just use a static site generator with GitHub pages but this is a great learning experience for building larger web applications. Also, once it is up and running it is great to be able to just open the Notion app on my phone and write when I have some downtime.

Tools

  • F#: Functional programming language for writing succinct, robust and performant code. I chose to write the server in F# because it is my favorite language and I could leverage the existing .NET ecosystem of tools.
  • ASP.NET Core: Web framework for .NET core.
  • htmx: Library which extends HTML to help build dynamic server side applications. I switched from using React to htmx because it greatly simplifies building the application.
  • hyperscript: Front end scripting language which is a companion library to htmx. This library helps build dynamic views with succinct code directly in HTML.
  • Tailwind CSS: Utility first CSS framework which makes it easy to build great looking apps. Tailwind UI has a lot of great components for inspiration.
  • Notion: Content management tool. It has apps for the desktop, web, and mobile so you can write from anywhere. I am using the Notion SDK for .NET in order to access the content from the Notion API.

Setup

I use git and GitHub for version control so the first thing I did was create a new repo and clone it locally.
gh repo create andrewmeier.dev
cd andrewmeier.dev
gh is the GitHub CLI
Next I created a directory for the application code and created the .NET Solution file.
mkdir app
cd app
dotnet new sln -n andrewmeier.dev
The solution file is not technically necessary but it is useful for working with IDEs such as Rider and Visual Studio
Then I created three projects:
dotnet new console -lang F# -n Build -o src/Build
dotnet new console -lang F# -n Server -o src/Server
dotnet new console -lang F# -n Tests -o src/Tests
  • Build: FAKE project used to define build tasks. This is not necessary to build and run the application but it is convenient to have all the build tasks defined in one spot.
  • Server: ASP.NET project for the web server. This is the main project for the application.
  • Tests: UI tests using canopy.

Development

Now for the fun part. I started with the Server project which will end up with the following structure.
app/src/Server/src
├── Infrastructure.fs
├── Config.fs
├── PostClient.fs
├── ViewEngine.fs
├── PostController.fs
└── Program.fs
In F# the files must be defined in order of dependency.
First I added Infrastructure.fs which has utility functions and extensions. I am using htmx so I added a few utilities to the Controller class to work with some of the request and response headers. I will show how these work later.
namespace global

open System
open System.IO

module Env =

    let variable (key:string) (defaultValue:string) =
        match Environment.GetEnvironmentVariable(key) with
        | s when String.IsNullOrEmpty(s) -> defaultValue
        | s -> s

		// Used for secret values so we can mount in containers when running in Kubernetes
    let secret secretName secretKey defaultEnv defaultValue =
        let secretsDir = variable "SECRETS_DIR" "/var/secrets" 
        let secretPath = Path.Combine(secretsDir, secretName, secretKey)
        if File.Exists(secretPath) then
            File.ReadAllText(secretPath).Trim()
        else
            variable defaultEnv defaultValue
            
[<AutoOpen>]
module ControllerExtensions =
    open Microsoft.AspNetCore.Mvc
    open Microsoft.Extensions.Primitives
    open System.Net.Mime
    
		// You can add extension methods to types with this syntax
    type Controller with
        member this.TryGetRequestHeader(key:string) =
            match this.Request.Headers.TryGetValue(key) with
            | true, value -> Some value
            | false, _ -> None
            
        member this.SetResponseHeader(key:string, value:string) =
            this.Response.Headers.Add(key, StringValues value)

        member this.IsHtmx =
            match this.TryGetRequestHeader("HX-Request") with
            | Some _ -> true
            | None -> false
            
        member this.HxPush(url:string) =
            this.SetResponseHeader("HX-Push", url)
            
        member this.Html(html:string) =
            this.Content(html, MediaTypeNames.Text.Html)
Next I added Config.fs which includes all the configuration for the application. There are other built in ways to load the configuration for ASP.NET but I tend to prefer doing it this way as it is more explicit and easier to do transformations.
module Server.Config

open System

[<RequireQualifiedAccess>]
type AppEnv =
    | Prod
    | Dev

// Configures the host and port on which the server will listen
type ServerConfig =
    { Url:string }
    static member Load() =
        let host = Env.variable "SERVER_HOST" "0.0.0.0"
        let port = Env.variable "SERVER_PORT" "5000" |> int
        { Url = $"http://{host}:{port}" }

// Configuration needed to connect to Notion   
type NotionConfig =
    { DatabaseId:string
      Token:string }
    static member Load() =
        { DatabaseId = Env.variable "NOTION_DATABASE_ID" ""
          Token = Env.secret "notion" "token" "NOTION_TOKEN" "" }

type Config =
    { AppEnv: AppEnv
      Debug: bool
      ServerConfig: ServerConfig
      NotionConfig:NotionConfig }
    static member Load() =
        { AppEnv = match Env.variable "APP_ENV" "prod" with "prod" -> AppEnv.Prod | _ -> AppEnv.Dev
          Debug = Env.variable "DEBUG" "true" |> Boolean.Parse
          ServerConfig = ServerConfig.Load()
          NotionConfig = NotionConfig.Load() }
Next I created a service to connect to Notion in PostClient.fs. Notion recently released their API and there is a community .NET SDK which makes it easy to get typed responses. In Notion, I created a database called ‘Blog Posts’ which has custom properties that I can retrieve from the API. Below is an example for this blog post.
In Notion, a ‘database’ will have a set of ‘pages’ and each page will have a set of ‘blocks’. I can query the ‘Blog Posts’ database by its database id, filtering by properties such as ‘status’. This will return a list of blog post pages and their properties. I can then get all the content for a blog post page by retrieving all the blocks. I defined a model Post which includes the page id and it’s properties, and a model PostDetail with the Post and all the IBlock objects that I get back from the SDK. Then I defined an interface IPostClient and an implementation LivePostClient. I omitted some code for brevity.
type Post =
    { id: string
      permalink: string
      title: string
      summary: string
      icon: string
      iconAlt: string
      cover: string
      tags: string[]
      createdAt: DateTime
      updatedAt: DateTime }
    
type PostDetail =
    { post: Post
      content: IBlock[] }
    
type IPostClient =
    abstract List: unit -> Task<Post[]>
    abstract GetById: pageId:string -> Task<PostDetail option>
    abstract GetByPermalink: permalink:string -> Task<PostDetail option>

type LivePostClient(config:NotionConfig, cache:IMemoryCache) =
    let clientOpts = ClientOptions(AuthToken=config.Token)
    let client = NotionClientFactory.Create(clientOpts)
    
    // Get the page id from the permalink and cache the result
    let getPageId(permalink:string) = task {
        match cache.TryGetValue(permalink) with
        | true, value ->
            let pageId = unbox<string> value
            return Some pageId
        | _ ->
            let filter = TextFilter("permalink", permalink)
            let queryParams = DatabasesQueryParameters(Filter=filter)
            let! res = client.Databases.QueryAsync(config.DatabaseId, queryParams)
            match Seq.tryHead res.Results with
            | Some page ->
                // The cache entry will take up 1/1000 entries.
                // The entries limit is defined in Program.fs when adding the cache.
                let cacheEntryOpts = MemoryCacheEntryOptions(Size=1L)
                let pageId = cache.Set(permalink, page.Id, cacheEntryOpts)
                return Some pageId
            | None ->
                return None
    }
    
    let getPublishedPage (pageId:string) = task {
        let! page = client.Pages.RetrieveAsync(pageId)
        let status = page.Properties |> Props.getSelect "status" ""
        if status = "Published" then
            return Some page
        else
            return None
    }
    
    let listPublishedPages () = task {
        let mutable hasMore = true
        let mutable cursor = null
        let posts = ResizeArray()
        // Filter for pages where the 'status' select field is 'Published'
        let filter = SelectFilter("status", "Published")
        let queryParams = DatabasesQueryParameters(StartCursor=cursor, Filter=filter)
        while hasMore do
            let! res = client.Databases.QueryAsync(config.DatabaseId, queryParams)
            hasMore <- res.HasMore
            cursor <- res.NextCursor
            for page in res.Results do
                posts.Add(page)
        return posts |> Seq.map Post.fromDto |> Seq.toArray
    }
    
    let listBlocks (pageId:string) = task {
        let blocks = ResizeArray()
        let mutable hasMore = true
        let mutable cursor = null
        while hasMore do
            let parameters = BlocksRetrieveChildrenParameters(StartCursor=cursor)
            let! res = client.Blocks.RetrieveChildrenAsync(pageId, parameters)
            hasMore <- res.HasMore
            cursor <- res.NextCursor
            blocks.AddRange(res.Results)
        return blocks.ToArray()
    }
    
    let getPostById (pageId:string) = task {
        match! getPublishedPage pageId with
        | Some page ->
            let post = Post.fromDto page
            let! blocks = listBlocks pageId
            let postDetail = { post = post; content = blocks  }
            return Some postDetail
        | None ->
            return None
    }
    
    let getPostByPermalink (permalink:string) = task {
        match! getPageId permalink with
        | Some pageId ->
            return! getPostById pageId
        | None ->
            return None
    }
    
    interface IPostClient with
        member _.List() = listPublishedPages()
        
        member _.GetById(pageId) = getPostById pageId
        
        member _.GetByPermalink(permalink) = getPostByPermalink(permalink)
Next, I added ViewEngine.fs which is my custom HTML view engine. I used both Giraffe.ViewEngine and Feliz.ViewEngine previously but ended up liking a mix of the syntaxes, so I copied bits from each for my own implementation. There is not that much code and it ends up working nicely since I wanted to customize some of the elements. The code below is basically the same for both libraries.
module Server.ViewEngine

// ---------------------------
// Default HTML elements
// ---------------------------

[<AutoOpen>]
module HtmlElements =

    // ---------------------------
    // Definition of different HTML content
    //
    // For more info check:
    // - https://developer.mozilla.org/en-US/docs/Web/HTML/Element
    // - https://www.w3.org/TR/html5/syntax.html#void-elements
    // ---------------------------

    type HtmlAttribute =
        | KeyValue of string * string   // e.g., div [ _class "text-xl" ] -> <div class="text-xl"></div>
        | Boolean of string             // e.g., button [ _disabled ] -> <button disabled></button>
        | Children of HtmlElement list  // e.g., div [ p "Hello" ] -> <div><p>Hello</p></div>
        | EmptyAttribute                // No op
        
    and HtmlElement =
        | Element of string * HtmlAttribute list        // e.g., <h1>Hello</h1>
        | VoidElement of string * HtmlAttribute list    // e.g., <br/>
        | TextElement of string                         // Text content
        | EmptyElement                                  // No op


// ---------------------------
// Internal ViewBuilder
// ---------------------------

module private ViewBuilder =
    open System.Text
    
    let inline (+=) (sb:StringBuilder) (s:string) = sb.Append(s)
    let inline (+!) (sb:StringBuilder) (s:string) = sb.Append(s) |> ignore
    
    let encode v = System.Net.WebUtility.HtmlEncode v
    
    let rec buildElement (el:HtmlElement) (sb:StringBuilder) =
        match el with
        | Element (tag, attributes) ->
            sb += "<" +! tag
            let children = ResizeArray()
            for attr in attributes do
                match attr with
                | KeyValue (key, value) -> sb += " " += key += "=\"" += value +! "\""
                | Boolean key -> sb += " " +! key
                | Children elements -> children.AddRange(elements)
                | EmptyAttribute -> ()
            sb +! ">"
            for child in children do buildElement child sb
            sb += "</" += tag +! ">"
        | VoidElement (tag, attributes) ->
            sb += "<" +! tag
            for attr in attributes do
                match attr with
                | KeyValue (key, value) -> sb += " " += key += "=\"" += value +! "\""
                | Boolean key -> sb += " " +! key
                | Children _ -> failwith "void elements cannot have children"
                | EmptyAttribute -> ()
            sb +! ">"
        | TextElement text -> sb +! (encode text)
        | EmptyElement -> ()

// ---------------------------
// Render HTML views
// ---------------------------

[<RequireQualifiedAccess>]
module Render =
    open System.Text
    open ViewBuilder
    
		// Used by htmx for partial views
    let view (html:HtmlElement) =
        let sb = StringBuilder()
        buildElement html sb
        sb.ToString()
        
		// Used for full page loads
    let document (html:HtmlElement) =
        let sb = StringBuilder()
        sb += "<!DOCTYPE html>" +! System.Environment.NewLine
        buildElement html sb
        sb.ToString()
Then I added classes to create the HTML elements. I omitted some of the members for brevity.
type Html() =
    static member empty = EmptyElement
    static member raw (v:string) = TextElement v
    static member html (attrs:HtmlAttribute list) = Element ("html", attrs)
    static member head (attrs:HtmlAttribute list) = Element ("head", attrs)
    static member title (value:string) = Element ("title", [ Children [ TextElement value ] ])
    static member meta (attrs:HtmlAttribute list) = VoidElement ("meta", attrs)
    static member link (attrs:HtmlAttribute list) = VoidElement ("link", attrs)
    static member script (attrs:HtmlAttribute list) = Element ("script", attrs)
    static member body (attrs:HtmlAttribute list) = Element ("body", attrs)
    static member nav (attrs:HtmlAttribute list) = Element ("nav", attrs)
    static member h1 (attrs:HtmlAttribute list) = Element ("h1", attrs)
    static member h2 (attrs:HtmlAttribute list) = Element ("h2", attrs)
    static member h3 (attrs:HtmlAttribute list) = Element ("h3", attrs)
    static member div (attrs:HtmlAttribute list) = Element ("div", attrs)
    static member _id (v:string) = KeyValue ("id", v)
    static member _class (v:string) = KeyValue ("class", v)
    static member _class (v:string list) = KeyValue ("class", v |> String.concat " ")
    static member _style (v:string) = KeyValue ("style", v)
    static member _children (v:HtmlElement list) = Children v
    static member _children (v:HtmlElement) = Children [ v ]
    static member _children (v:string) = Children [ TextElement v ]

type Svg() =
    static member svg (attrs:HtmlAttribute list) = Element ("svg", attrs)
    static member path (attrs:HtmlAttribute list) = Element ("path", attrs)
    static member circle (attrs:HtmlAttribute list) = Element ("circle", attrs)
    static member _viewBox (v:string) = KeyValue ("viewBox", v)
    static member _width (v:int) = KeyValue ("width", string v)
    static member _height (v:int) = KeyValue ("height", string v)
    static member _fill (v:string) = KeyValue ("fill", v)

type Htmx() =
    static member _hxGet (v:string) = KeyValue ("hx-get", v)
    static member _hxGet (v:string option) = match v with Some v -> KeyValue ("hx-get", v) | None -> EmptyAttribute
    static member _hxPost (v:string) = KeyValue("hx-post", v)
    static member _hxPost (v:string option) = match v with Some v -> KeyValue("hx-post", v) | None -> EmptyAttribute
    static member _hxTrigger (v:string) = KeyValue ("hx-trigger", v)
    static member _hxTarget (v:string) = KeyValue ("hx-target", v)
    static member _hxTarget (v:string option) = match v with Some v -> KeyValue ("hx-target", v) | None -> EmptyAttribute
    
type Hyper() =
    static member _hyper (v:string) = KeyValue ("_", v)
This allows me to create elements like the following which uses Feliz style elements with _<attribute> convention taken from Giraffe.ViewEngine. This hit the readability sweet spot for me.
div [
    _id post.permalink
    _class "relative border-b-2 border-gray-200 p-2 cursor-pointer hover:bg-gray-100"
    _hxGet $"/{post.id}"
    _hxTarget "#page"
    _children [
        div [
            _class "flex justify-between"
            _children [
                h3 [
                    _class "text-lg font-medium text-gray-800 mb-2"
                    _children post.title
                ]
                p [
                    _class "text-sm text-gray-500 leading-7"
                    _children (post.updatedAt.ToString("MM/dd/yyyy"))
                ]
            ]
        ]
        p [
            _class "text-sm text-gray-500"
            _children post.summary
        ]
        div [
            _class "loader absolute inset-0 w-full h-full bg-gray-500/25"
            _children [
                div [
                    _class "flex justify-center items-center h-full"
                    _children Spinner.circle
                ]
            ]
        ]
    ]
]
Next I added PostController.fs which defines the routes and responses using the ASP.NET Controller class. This file also includes all the code for turning a Post and PostDetail model into HTML. The controller is defined as the following.
// Specifies that this controller handles the index route (i.e., https://andrewmeier.dev/)
[<Route("")>]
type PostController(client:IPostClient) =
    inherit Controller()
    
    member private this.Render(page:HtmlElement, ?extraMetas:HtmlElement list) =
        let extraMetas = extraMetas |> Option.defaultValue List.empty
        let html =
            if this.IsHtmx then Render.view page
            else page |> Layout.main extraMetas |> Render.document
        this.Html(html)
    
    // No route specified so use the controller route
    [<HttpGet>]
    member this.Index() = task {
        let! posts = client.List()
        let page =
            posts
            |> Array.filter (fun p -> p.permalink <> "about")
            |> Page.postList
        if this.IsHtmx then this.HxPush("/")
        return this.Render(page)
    }
        
    [<Route("404")>]
    [<HttpGet>]
    member this.NotFound() =
        let page = Page.notFound
        if this.IsHtmx then this.HxPush("/404")
        this.Render(page)
        
    // Routes matching post ids, e.g., /<guid>
    [<Route("{postId:guid}")>]
    [<HttpGet>]
    member this.PostById(postId:string) = task {
        match! client.GetById(postId) with
        | Some postDetail ->
            let metas = Page.postTwitterMetas postDetail.post
            let page = Page.postDetail postDetail
            if this.IsHtmx then this.HxPush($"/{postDetail.post.permalink}")
            return this.Render(page, metas)
        | None ->
            let page = Page.notFound
            if this.IsHtmx then this.HxPush("/404")
            return this.Render(page)
    }
    
    // Routes matching permalinks, e.g., /blogging-with-fsharp
    [<Route("{permalink:regex(^[[a-z-]]+$)}")>]
    [<HttpGet>]
    member this.PostByPermalink(permalink:string) = task {
        match! client.GetByPermalink(permalink) with
        | Some postDetail ->
            let metas = Page.postTwitterMetas postDetail.post
            let page = Page.postDetail postDetail
            if this.IsHtmx then this.HxPush($"/{permalink}")
            return this.Render(page, metas)
        | None ->
            let page = Page.notFound
            if this.IsHtmx then this.HxPush("/404")
            return this.Render(page)
    }
There are two types of responses in the Render method. One is for partial responses and the other is for full page responses. The power of htmx is that on the client we can send a request based on some event, for instance a button click, and then update a portion of the HTML. This allows us to provide a better user experience by not having to do full page reloads but also be able to respond with plain HTML from the server. An example of this is on the home page where we have a list of posts, each defined as the following as we showed above.
div [
    _id post.permalink
    _class "relative border-b-2 border-gray-200 p-2 cursor-pointer hover:bg-gray-100"
    // i.e., when we click this div, make a GET request to /<post-id>
    _hxGet $"/{post.id}"
    // i.e., take the response from the above GET request and replace the element with id 'page'
    _hxTarget "#page"
    _children [
        div [
            _class "flex justify-between"
            _children [
                h3 [
                    _class "text-lg font-medium text-gray-800 mb-2"
                    _children post.title
                ]
                p [
                    _class "text-sm text-gray-500 leading-7"
                    _children (post.updatedAt.ToString("MM/dd/yyyy"))
                ]
            ]
        ]
        p [
            _class "text-sm text-gray-500"
            _children post.summary
        ]
        div [
            _class "loader absolute inset-0 w-full h-full bg-gray-500/25"
            _children [
                div [
                    _class "flex justify-center items-center h-full"
                    _children Spinner.circle
                ]
            ]
        ]
    ]
]
When htmx sends a request to the server, it will include the header HX-Request so we can use this check whether to return a partial view or a full page in our controller’s Render method. In the above example, when someone clicks on the post summary div element, htmx will send a GET request to the server at /<post-id> and the server will see that it is an htmx request and respond with just the partial view of the post page. Then htmx will take that response and replace the element with id=page with the response content, which is just HTML. When we send back responses for htmx, we can also provide the response header HX-Push which will tell htmx to update the url.
I am using Tailwind CSS for the styles which is defined by adding different utility classes to the elements. Tailwind has a CLI which will parse all the source files and look for these utility classes then build an optimized CSS file into wwwroot. You can see commands in the package.json file.

Lastly I added the entry point code for ASP.NET in Program.fs which configures and starts the server.
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection
open Prometheus
open Server.Config
open Server.PostClient
open Serilog
open Serilog.Events

[<EntryPoint>]
let main _ =
    // Load the config from Config.fs
    let config = Config.Load()
    // Configure the Serilog logger
    let logger = 
        LoggerConfiguration()
            .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
            .MinimumLevel.Is(if config.Debug then LogEventLevel.Debug else LogEventLevel.Information)
            .WriteTo.Console()
            .CreateLogger()
    Log.Logger <- logger
    Log.Debug("Debug mode")
    Log.Debug("{Config}", config)
    
    try
        try
            let builder = WebApplication.CreateBuilder()
            builder.Host.UseSerilog() |> ignore
            // Add a memory cache service so we can cache the permalink page ids
            builder.Services.AddMemoryCache(fun opts -> opts.SizeLimit <- 1000L) |> ignore
            // Add the Notion configuration which is a dependency of the Notion client
            builder.Services.AddSingleton<NotionConfig>(config.NotionConfig) |> ignore
            // When testing use the mock post client
            if config.AppEnv = AppEnv.Dev then
                builder.Services.AddSingleton<IPostClient,MockPostClient>() |> ignore
            // Otherwise use the live post client
            else
                builder.Services.AddSingleton<IPostClient,LivePostClient>() |> ignore
            builder.Services.AddControllers() |> ignore
            builder.Services.AddHealthChecks() |> ignore
            
            let app = builder.Build()
            // Serve static files from wwwroot folder
            app.UseStaticFiles() |> ignore
            // User Serilog request logging for cleaner logs
            app.UseSerilogRequestLogging() |> ignore
            // Add controller endpoints
            app.MapControllers() |> ignore
            // Add health check endpoints
            app.MapHealthChecks("/healthz") |> ignore
            // Add Prometheus /metrics endpoint
            app.MapMetrics() |> ignore
            // Run the server on the specified host and port
            app.Run(config.ServerConfig.Url)
            0
        with ex ->
            Log.Error(ex, "Error running server")
            1
    finally
        Log.CloseAndFlush()

Writing

Notion is great for writing blog posts because
  1. There are applications for web, desktop, and mobile so you can write anywhere.
  2. You can add metadata as properties to the blog posts
  3. There is an API which you can use to fetch the posts and content.
  4. It is free up to a certain usage
I also use Notion for all my note taking and other things such as tracking tasks so everything is in one spot. My blog posts database looks like the following.
I can easily organize all the posts and as soon as I want them published I just change the status to ‘Published’.
You can also group by the properties which is nice for viewing in a Kanban like board view.

Conclusion

There are other topics I could go through such as UI testing and deploying, but I will either save those for a different post or extend this one later. The main point I wanted to show for this post is how F# + htmx + Tailwind CSS + Notion is great for building a blog. I encourage you to check out the code on GitHub and leverage any of it for your own project.