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:
- Strongly-typed
- No semicolons
- Not-so-big stdlib: Easier to read the whole stdlib docs and less being overwhelmed.
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?