← Back to homepage

Building a web app with Gleam

Posted on . Updated on .

I’m aware of Gleam for a while but haven’t actually built anything with it. Something just didn’t feel right and I didn’t feel productive enough. Suddenly at the end of 2025 I gave Gleam another try and it just clicked. The type system feels natural, working in a Gleam project feels intuitive, and I have fun.

Currently I’m using wisp and Postgresql via pog to a website for people to manage their book collections. I jot down here some random notes that are hopefully useful for new Gleamlins.

Day 0a

What I learned when first started the project, in no particular order.

What I like about Gleam:

Templating engine

To render HTML on server side, we probably need a templating language. I really like Nunjucks but apparently there is no equivalent solution in Gleam.

I picked handles and tried to have it read from *.hbs template files. The advantage of this approach is templating and HTML output are always up-to-date and I don’t have to restart gleam run every time I iterate on the UI. The downside is that handles doesn’t support layouting natively (think of {% extends "base-layout.html" %}) and passing data to the view is quite annoying with its own Context type.

Using a HTML building library like nakai would probably solve all the above issues but in turn I would need to restart the dev server to see new UI changes. That’s definitely not ideal. Luckily there is radiate that allows hot code reloading in Gleam.

pub fn main() {
  // Setup radiate to hot reload code when saving
  let _ =
    radiate.new()
    |> radiate.add_dir(".") // For MacOS
    |> radiate.on_reload(fn(_, path) {
      wisp.log_info("Change in " <> path <> ", reloading!")
    })
    |> radiate.start()

  // start_server() is how you would setup a `wisp` app
  let _ = start_server()

  process.sleep_forever()
}

With this it’s now easier to build HTML response.

Why not Lustre?

Lustre is an Elm-like web app framework. While it also supports server-side rendering, I feel it’s too opinionated to be fun. Plus knowing Redux is inspired by Elm, and my not-so-good-experience with Redux made me skeptical about Lustre. What I want for now is to explore Gleam ecosystem and build a framework for my own use, so maybe I will check Lustre again in the future.

Optional function parameters

Using nakai to build HTML, it’s very often to pass an empty list [] when we don’t need to set attributes for DOM nodes.

import nakai/attr
import nakai/html as h

h.ol([], [
  h.li_text([], "one"),
  h.li_text([], "two"),
  h.li_text([], "three"),
])

This is fine but if Gleam could support optional function parameters, like in Reason.

fn ol(children: List(html.Node), attrs attrs?: List(attr.Attr)) {
  // attrs? is an optional parameter so it's automatically
  // wrapped in option.Option.
  let attrs = case attrs {
    option.Some(xs) -> xs
    option.None -> []
  }

  html.Node(attrs:, children)
}

// Usage:
h.ol([
  h.li_text("one"),
  h.li_text("two"),
  h.li_text("three"),
])

// And because it's a labelled parameter, we can pass attrs
// as the first argument.
h.ol(attrs: [
  attr.class("number-list")
], [
  h.li_text("one"),
  h.li_text("two"),
  h.li_text("three"),
])

Day 0b

Array and nullable parameters in pog

"insert into tbl (title, values)
values ($1, $2);"
|> pog.query()
|> pog.parameter(pog.nullable(pog.text, input.title))
|> pog.parameter(pog.nullable(pog.array(pog.text, _), input.values))

Before having the terse version above, I was thinking of an option.unwrap_with() function:

pub fn unwrap_with(
  opt: option.Option(a),
  fallback: b, with f: fn(a) -> b
) {
  case opt {
    option.Some(x) -> f(x)
    option.None -> fallback
  }
}

unwrap_with(Some("foo"), pog.null(), with: pog.text)

which is actually equal to:

Some("foo")
|> option.map(pog.text)
|> option.unwrap(pog.null())

¯\_(ᵕ—ᴗ—)_/¯

Day 0c

Routing with HTTP methods and URL segments

wisp has an example of routing using URL segments. It’s as follow:

case wisp.path_segments(req) {
  [] -> home_page(req)

  ["comments"] -> comments(req)

  ["comments", id] -> show_comment(req, id)

  _ -> wisp.not_found()
}

It’s very straightforward and effective. We can improve this further by also matching the request’s method.

import gleam/http

case req.method, wisp.path_segments(req) {
  http.Get, [] -> home_page(req)

  http.Get, ["comments"] -> comments(req)

  http.Post, ["comments"] -> add_comment(req)

  http.Get, ["comments", id] -> show_comment(req, id)

  _, _ -> wisp.not_found()
}

This is similar to routing solutions found in other web frameworks like express or hono.

Day 0d

There is this guard from gleam/bool where we can do “early return”.

import gleam/bool

let name = "Kamaka"
use <- guard(when: name == "", return: "Welcome!")
"Hello, " <> name

lazy_guard is another version where when parameter is a function.

I notice if and else are keywords in Gleam but its usage isn’t documented. Maybe just being reserved for the time being?