First Steps with Gleam: Building a Simple Web App (Rest API with PostgreSQL database)
A beginner-friendly walkthrough of creating a web app with Gleam, REST APIs, and PostgreSQL.
Not long ago, I started my Gleam learning journey and have been enjoying it a lot so far. In this article, I’d like to share what I’ve learned about web development with Gleam.
We’ll build a REST web application step by step, backed by a PostgreSQL database and complemented with a small HTML interface. Along the way, we’ll use the following packages:
The entire code for this blog post could be found here: https://github.com/andfadeev/learn_gleam_todo
Setting up the development environment
Setting up the development environment on macOS is simple. I was using homebrew but it’s also possible with version managers like asdf or mise.
brew install gleam
brew install erlang
brew install rebar3Let’s confirm that everything is ready and good to go:
❯ gleam --version
gleam 1.13.0
❯ erl --version
Erlang/OTP 28
❯ rebar3 --version
rebar 3.25.1 on Erlang/OTP 28 Erts 16.1.2Since I’m currently using my wife’s MacBook Air with only 8 GB of RAM, system resources are limited, and my usual IDE (JetBrains IntelliJ IDEA) is painfully slow. As a result, I decided to use Zed (https://zed.dev) as the editor for this project.
Zed is extremely fast, and I haven’t experienced any delays or slowdowns on this machine. It’s also extensible, with support for language servers and Tree-sitter.
When you open a Gleam file, Zed automatically detects it and prompts you to install the appropriate extension. After that, it connects to the Gleam Language Server, so the entire setup was essentially automatic.
Gleam language
Gleam is a statically typed, functional programming language focused on simplicity, correctness, and developer experience. It features a strong type system with powerful type inference, immutable data, and an expressive standard library, and it compiles to both the Erlang VM (BEAM) and JavaScript.
I wanted to become more familiar with the BEAM ecosystem and was looking for a language to start with. Over the past decade, I’ve mostly worked with Clojure, which is dynamically typed. While Elixir is more mature, I was looking for a different experience—specifically one with stronger typing, which I’ve started to miss.
Here are some of the language features I’d like to highlight:
Immutability
A concise yet powerful standard library
An emphasis on one clear way of doing things
No nulls and forced error handling
Built-in
ResultandOptiontypes
The goal of this blog is to provide a step-by-step guide to building a web application with a database backend. Because of that, I won’t go deep into the Gleam language itself. The best way to get familiar with Gleam is to follow the official language tour.
Gleam CLI tool
Once Gleam is installed on your machine, you gain access to the gleam CLI tool. This tool will be our main entry point for all common tasks — creating a new project, building and running it, managing dependencies, formatting code, and running tests.
You can see the list of commands by running:
gleam helpLet’s start with creating a new project:
gleam new learn_gleam_todoNow we can move to the newly created folder and run tests and the project itself:
cd learn_gleam_todo
gleam test
1 passed, no failures
gleam run
Hello from learn_gleam_todo!The CLI tool is also used to add dependencies (that are recorded in the gleam.toml file). Let’s add the dependency for the wisp framework (https://github.com/gleam-wisp/wisp) that we are going to use later:
gleam add wisp
cat gleam.toml
name = "learn_gleam_todo"
version = "1.0.0"
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
wisp = ">= 2.1.1 and < 3.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"Later, it could be used to keep our dependencies up-to-date by running:
gleam updateCreate a web server
In this section, we will create a web server and a REST API for our Todo application.
Let’s add dependencies that will be needed to get started:
gleam add wisp gleam_erlang mist envoyLet’s finally write some Gleam code and edit the src/learn_gleam_todo.gleam file:
import envoy
import gleam/erlang/process
import gleam/result
import mist
import wisp.{type Request, type Response}
import wisp/wisp_mist
fn middleware(req: Request, handler: fn(Request) -> Response) -> Response {
use <- wisp.log_request(req)
use <- wisp.rescue_crashes
use req <- wisp.handle_head(req)
use req <- wisp.csrf_known_header_protection(req)
handler(req)
}
fn handler(req: Request) -> Response {
use _ <- middleware(req)
wisp.ok()
|> wisp.string_body("Hellow from Wisp & Gleam")
}
pub fn main() -> Nil {
wisp.configure_logger()
let secret =
result.unwrap(envoy.get("SECRET_KEY_BASE"), "wisp_secret_fallback")
let assert Ok(_) =
wisp_mist.handler(handler, secret)
|> mist.new
|> mist.port(8080)
|> mist.start
process.sleep_forever()
}A lot is going on here, so let’s break it down. In the main function, we create a web server and configure it to listen on port 8080. Wisp requires a unique secret, so we use the envoy package to read it from an environment variable, with a fallback to a hardcoded value. If you prefer, you can instead require the environment variable to be set and let the application fail fast if it’s missing.
Our handler is just a function that accepts wisp.Request and returns wisp.Response. It will catch all the routes and all HTTP methods for now and will always return 200 with a text response.
We also used a subset of available middleware to have basic logging and error handling.
Now let’s run our application and validate that it’s working. I’m going to use curl but you can just open the URL in the browser:
gleam run
Listening on http://127.0.0.1:8080
curl http://127.0.0.1:8080
Hellow from Wisp & GleamYou can find the result of this step in this commit: https://github.com/andfadeev/learn_gleam_todo/commit/4e4dc6ce896dc6bda46f8cfb8641922df5132a65
Adding REST CRUD API
In this section, we will add some routes to our web handler, so we will need:
GET /todos — to list all todo items
POST /todos — to create a new todo item
GET /todos/:id — get todo item by id
DELETE /todos/:id — delete todo item by id
Interestingly, there are no abstractions or special syntax to define the route table. We will use Gleam functions and pattern matching to define the logic we need.
Let’s first add a couple of additional dependencies that we will need further:
gleam add gleam_http gleam_jsonNow it’s time to define routes, not that we are just using pattern matching on the URL segment first and on the HTTP method (utilising some helper functions provided by wisp):
fn delete_todo_handler(_id: String) {
wisp.no_content()
}
fn get_todo_handler(id: String) {
wisp.string_body(wisp.ok(), "Todo item " <> id)
}
fn todo_handler(req: Request, id: String) -> Response {
case req.method {
http.Get -> get_todo_handler(id)
http.Delete -> delete_todo_handler(id)
_ -> wisp.method_not_allowed([http.Get, http.Delete])
}
}
fn get_todos_hander() {
wisp.string_body(wisp.ok(), "Todo items")
}
fn post_todos_hander() {
wisp.created()
}
fn todos_handler(req: Request) -> Response {
case req.method {
http.Get -> get_todos_hander()
http.Post -> post_todos_hander()
_ -> wisp.method_not_allowed([http.Get, http.Post])
}
}
fn handler(req: Request) -> Response {
use req <- middleware(req)
case wisp.path_segments(req) {
["todos"] -> todos_handler(req)
["todos", id] -> todo_handler(req, id)
_ -> wisp.not_found()
}
}Time to restart the application and do some testing:
gleam run
Listening on http://127.0.0.1:8080
curl http://127.0.0.1:8080/todos
Todo items
curl -X POST http://127.0.0.1:8080/todos
Created
curl http://127.0.0.1:8080/todos/1
Todo item 1
curl -i -X DELETE http://127.0.0.1:8080/todos/1
HTTP/1.1 204 No ContentCode changes could be found in this commit: https://github.com/andfadeev/learn_gleam_todo/commit/1bceaabf134befc0e7ba9013e82e983d30b5f01a
Adding JSON support
Now let’s add a model for your todo items, support JSON in responses and requests. Again, we will need some extra packages:
gleam add gleam_time youidSo we are going to create the type for our todo items, JSON encoder and generate some random items for the GET /todos response:
type TodoItem {
TodoItem(
id: uuid.Uuid,
title: String,
description: option.Option(String),
status: String,
created_at: timestamp.Timestamp,
updated_at: timestamp.Timestamp,
)
}
fn timestamp_to_json(ts: timestamp.Timestamp) {
json.string(timestamp.to_rfc3339(ts, calendar.utc_offset))
}
fn todo_item_to_json(item: TodoItem) {
json.object([
#("id", json.string(uuid.to_string(item.id))),
#("title", json.string(item.title)),
#("description", json.string(option.unwrap(item.description, ""))),
#("status", json.string(item.status)),
#("created_at", timestamp_to_json(item.created_at)),
#("updated_at", timestamp_to_json(item.updated_at)),
])
}
fn get_todos_hander() {
let todo_items = [
TodoItem(
uuid.v4(),
"todoitem1",
option.Some("description 1"),
"pending",
timestamp.from_unix_seconds(1_766_689_000),
timestamp.from_unix_seconds(1_766_689_000),
),
TodoItem(
uuid.v4(),
"todoitem2",
option.None,
"completed",
timestamp.from_unix_seconds(1_766_689_000),
timestamp.from_unix_seconds(1_766_689_000),
),
]
json.array(todo_items, todo_item_to_json)
|> json.to_string
|> wisp.json_response(200)
}So now everything should be in place, and we can test the endpoint. I’m going to use jq for pretty printing in the terminal:
gleam run
curl http://127.0.0.1:8080/todos | jq
[
{
"id": "2d2238c3-631b-49d9-962b-6aa251a5a34f",
"title": "todoitem1",
"description": "description 1",
"status": "pending",
"created_at": "2025-12-25T18:56:40Z",
"updated_at": "2025-12-25T18:56:40Z"
},
{
"id": "ec68c70f-0214-4fe6-84a0-b84535e682b0",
"title": "todoitem2",
"description": "",
"status": "completed",
"created_at": "2025-12-25T18:56:40Z",
"updated_at": "2025-12-25T18:56:40Z"
}
]Commit for this change could be found by this link: https://github.com/andfadeev/learn_gleam_todo/commit/5fccd22bc1694efb3f1af9e02c90c94a68c2351a
Handling JSON request body
Finally, we need to support parsing the incoming request body for the POST /todos route:
import gleam/dynamic/decode
fn post_todos_handler(req: Request) {
use json <- wisp.require_json(req)
let result = {
let decoder = {
use title <- decode.field("title", decode.string)
use description <- decode.optional_field("description", "", decode.string)
decode.success(#(title, description))
}
use #(title, description) <- result.try(decode.run(json, decoder))
let todo_item =
TodoItem(
uuid.v4(),
title,
option.Some(description),
"pending",
timestamp.from_unix_seconds(1_766_689_000),
timestamp.from_unix_seconds(1_766_689_000),
)
Ok(
todo_item_to_json(todo_item)
|> json.to_string
|> wisp.json_response(200),
)
}
case result {
Ok(resp) -> resp
Error(_) -> wisp.unprocessable_content()
}
}Let’s test that the POST endpoint is working now:
curl -X POST http://127.0.0.1:8080/todos \
-H "Content-Type: application/json" \
-d '{
"title": "mytodo",
"description": "something here"
}' | jq
{
"id": "0390b077-4c9e-4cd9-982a-672250f4d707",
"title": "mytodo",
"description": "something here",
"status": "pending",
"created_at": "2025-12-25T18:56:40Z",
"updated_at": "2025-12-25T18:56:40Z"
}Code change could be found here: https://github.com/andfadeev/learn_gleam_todo/commit/40fdef1445aeeaa3d08787e1e97b6a739947f2ea
Adding a database layer
In this section, we are going to add a PostgreSQL database backend to store our todo items.
Let’s define a simple Docker Compose file to have a way to start the database. We will also point it to an initialisation script so we will have a database table created automatically:
Create docker-compose.yml in the root of the project:
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: gleam
POSTGRES_PASSWORD: gleam
POSTGRES_DB: gleamdb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app_db"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:And also the init.sql in the root as well:
CREATE TABLE todo_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);Now we can start the database by running:
docker compose up -d
psql -h localhost -p 5432 -U gleam -d gleamdb
Password: gleam
gleamdb=# select * from todo_items ;
id | title | description | status | created_at | updated_at
----+-------+-------------+--------+------------+------------
(0 rows)We are going to use squirrel library (https://github.com/giacomocavalieri/squirrel) to generate type-safe Gleam code from plain SQL queries. Let’s create a bunch of SQL files in the src/sql folder:
❯ tree src/sql
src/sql
├── delete_todo_item.sql
├── find_todo_item.sql
├── find_todo_items.sql
└── insert_todo_item.sql
1 directory, 4 filesEach file will contain an SQL query:
-- delete_todo_item.sql
delete from todo_items
where id = $1;
-- find_todo_item.sql
select * from todo_items
where id = $1;
-- find_todo_items.sql
select * from todo_items;
-- insert_todo_item.sql
insert into todo_items (title, description, status)
values ($1, $2, $3)
returning *;Now let’s add a new package, note that we are using --dev as it will be only used for code generation:
gleam add pog
gleam add squirrel --devWe also need to expose an env var so we can run a code generation command that will actually read our database table definition to generate type-safe Gleam code:
export DATABASE_URL=postgres://gleam:gleam@localhost:5432/gleamdb
❯ gleam run -m squirrel
🐿️ Generated 4 queries!You can explore a new Gleam file src/sql.gleam with generated code, for example:
/// A row you get from running the `find_todo_item` query
/// defined in `./src/sql/find_todo_item.sql`.
///
/// > 🐿️ This type definition was generated automatically using v4.6.0 of the
/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
///
pub type FindTodoItemRow {
FindTodoItemRow(
id: Uuid,
title: String,
description: Option(String),
status: String,
created_at: Timestamp,
updated_at: Timestamp,
)
}
/// Runs the `find_todo_item` query
/// defined in `./src/sql/find_todo_item.sql`.
///
/// > 🐿️ This function was generated automatically using v4.6.0 of
/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
///
pub fn find_todo_item(
db: pog.Connection,
arg_1: Uuid,
) -> Result(pog.Returned(FindTodoItemRow), pog.QueryError) {
let decoder = {
use id <- decode.field(0, uuid_decoder())
use title <- decode.field(1, decode.string)
use description <- decode.field(2, decode.optional(decode.string))
use status <- decode.field(3, decode.string)
use created_at <- decode.field(4, pog.timestamp_decoder())
use updated_at <- decode.field(5, pog.timestamp_decoder())
decode.success(FindTodoItemRow(
id:,
title:,
description:,
status:,
created_at:,
updated_at:,
))
}
"select * from todo_items
where id = $1;
"
|> pog.query
|> pog.parameter(pog.text(uuid.to_string(arg_1)))
|> pog.returning(decoder)
|> pog.execute(db)
}Now we can create a connection pool and execute some queries:
import pog
import sql
pub fn main() -> Nil {
let db_pool_name = process.new_name("db_pool")
let assert Ok(database_url) = envoy.get("DATABASE_URL")
let assert Ok(pog_config) = pog.url_config(db_pool_name, database_url)
let assert Ok(_) =
pog_config
|> pog.pool_size(10)
|> pog.start
let con = pog.named_connection(db_pool_name)
case sql.find_todo_items(con) {
Ok(todo_items) -> {
echo "Got todo items"
echo todo_items
echo "OK"
}
Error(_) -> {
echo "Failed to get todo items"
}
}
}Let’s test, don’t forget that we now need the DATABASE_URL env var:
export DATABASE_URL=postgres://gleam:gleam@localhost:5432/gleamdb
gleam run
"Got todo items"
Returned(0, [])
"OK"Code change could be found here: https://github.com/andfadeev/learn_gleam_todo/commit/143fdc20a477cf0727ae0446772136f13db07582
Integrating the database into the web application
So we created the connection pool in the main function, but we need to have access to the database connection from handlers. The way of doing it is to create a new Context type that will hold a database connection and pass it to handlers:
pub type Context {
Context(db: pog.Connection)
}
fn handler(req: Request, ctx: Context) -> Response {
use req <- middleware(req)
case wisp.path_segments(req) {
["todos"] -> todos_handler(req, ctx)
["todos", id] -> todo_handler(req, ctx, id)
_ -> wisp.not_found()
}
}
pub fn main() -> Nil {
// rest of the main fn
let con = pog.named_connection(db_pool_name)
let context = Context(con)
let handler = handler(_, context)
let assert Ok(_) =
wisp_mist.handler(handler, secret)
|> mist.new
|> mist.port(8080)
|> mist.start
process.sleep_forever()
}Note that we will also need to pass the new context object to all the handlers, see full change here: https://github.com/andfadeev/learn_gleam_todo/commit/1f4f578a3f48af39597c7b3b98c7b933c7aca8e2
Connecting the dots
Now that we have database access and our REST handlers in place, it’s time to make things real. We’ll start persisting data by inserting records into the database in the POST request and using proper queries in the other handlers.
We’ll work with database types, map them to our custom TodoItem type, and JSON-encode the results. We’ll also make sure that the id passed in the request path is a valid UUID:
fn delete_todo_handler(ctx: Context, id: String) {
case uuid.from_string(id) {
Ok(id) -> {
case sql.delete_todo_item(ctx.db, id) {
Ok(_) -> wisp.no_content()
Error(_) -> wisp.internal_server_error()
}
}
Error(_) -> wisp.bad_request("Invalid id")
}
}
fn get_todo_handler(ctx: Context, id: String) {
case uuid.from_string(id) {
Ok(id) -> {
case sql.find_todo_item(ctx.db, id) {
Ok(todo_item) -> {
case todo_item.rows {
[] -> wisp.not_found()
[row] -> {
let todo_item =
TodoItem(
row.id,
row.title,
row.description,
row.status,
row.created_at,
row.updated_at,
)
todo_item_to_json(todo_item)
|> json.to_string
|> wisp.json_response(200)
}
_ -> {
wisp.internal_server_error()
}
}
}
Error(_) -> wisp.internal_server_error()
}
}
Error(_) -> wisp.bad_request("Invalid id")
}
}
fn get_todos_hander(ctx: Context) {
case sql.find_todo_items(ctx.db) {
Ok(todo_items) -> {
todo_items.rows
|> list.map(fn(row: sql.FindTodoItemsRow) {
TodoItem(
row.id,
row.title,
row.description,
row.status,
row.created_at,
row.updated_at,
)
})
|> json.array(todo_item_to_json)
|> json.to_string
|> wisp.json_response(200)
}
Error(_) -> {
wisp.internal_server_error()
}
}
}
fn post_todos_handler(req: Request, ctx: Context) {
use json <- wisp.require_json(req)
let result = {
let decoder = {
use title <- decode.field("title", decode.string)
use description <- decode.optional_field("description", "", decode.string)
decode.success(#(title, description))
}
use #(title, description) <- result.try(decode.run(json, decoder))
case sql.insert_todo_item(ctx.db, title, description, "pending") {
Ok(r) -> {
case r.rows {
[row] -> {
TodoItem(
row.id,
row.title,
row.description,
row.status,
row.created_at,
row.updated_at,
)
|> todo_item_to_json()
|> json.to_string
|> wisp.json_response(200)
|> Ok()
}
_ -> Ok(wisp.internal_server_error())
}
}
Error(_) -> {
Ok(wisp.internal_server_error())
}
}
}
case result {
Ok(resp) -> resp
Error(_) -> wisp.unprocessable_content()
}
}Of course, error handling and error messaging could be better, but you got the idea. Full change for this section you can find in this commit: https://github.com/andfadeev/learn_gleam_todo/commit/5b06e5280d8450ff210afd176039c5733a1611bd
Lustre
Finally, let’s cover the UI. Gleam could be compiled to JavaScript, and there is a library to build complex frontend applications (Elm-inspired): https://github.com/lustre-labs/lustre
In this example, we will just use it as an HTML DSL, so we will be able to create HTML views without templates, but directly with Gleam code. A bit more verbose compared to Clojure Hiccup, but it’s type-safe on the other hand.
gleam add lustreThe goal is to create an index handler for the GET / and show the list of todo items, but as an HTML page:
import lustre/attribute as attr
import lustre/element
import lustre/element/html
import gleam/int
fn todo_item_component(item: TodoItem) {
html.div([attr.class("rounded border mt-4 p-4")], [
html.h2([], [html.text(item.title)]),
html.p([], [html.text(option.unwrap(item.description, ""))]),
])
}
fn get_index_handler(req: Request, context: Context) -> Response {
use <- wisp.require_method(req, http.Get)
case sql.find_todo_items(context.db) {
Ok(todo_items) -> {
let todo_items_html =
todo_items.rows
|> list.map(fn(i: sql.FindTodoItemsRow) {
TodoItem(
i.id,
i.title,
i.description,
i.status,
i.created_at,
i.updated_at,
)
})
|> list.map(todo_item_component)
let html =
html.html([], [
html.head([], [
html.title([], "Gleam todo items"),
html.script([attr.src("https://cdn.tailwindcss.com")], ""),
]),
html.body([attr.class("max-w-2xl mx-auto")], [
html.h1([attr.class("text-red-800 font-bold")], [
html.text("Todo: " <> int.to_string(list.length(todo_items_html))),
]),
html.div([attr.class("text-blue-800")], todo_items_html),
]),
])
wisp.ok()
|> wisp.html_body(element.to_document_string(html))
}
Error(_) -> {
wisp.internal_server_error()
}
}
}
fn handler(req: Request, ctx: Context) -> Response {
use req <- middleware(req)
case wisp.path_segments(req) {
[] -> get_index_handler(req, ctx)
["todos"] -> todos_handler(req, ctx)
["todos", id] -> todo_handler(req, ctx, id)
_ -> wisp.not_found()
}
}You can open the index page in your browser and see the result:
curl http://127.0.0.1:8080
<!doctype html>
<html>
<head>
<title>Gleam todo items</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="max-w-2xl mx-auto">
<h1 class="text-red-800 font-bold">Todo: 3</h1>
<div class="text-blue-800">
<div class="rounded border mt-4 p-4">
<h2>mytodo</h2>
<p>something here</p>
</div>
<div class="rounded border mt-4 p-4">
<h2>mytodo</h2>
<p>something here</p>
</div>
<div class="rounded border mt-4 p-4">
<h2>mytodo</h2>
<p>something here</p>
</div>
</div>
</body>
</html>Full commit for this change could be found by this link: https://github.com/andfadeev/learn_gleam_todo/commit/d00461ea30516fba5321d1b441b1bd98fb4a0d84
I think that’s it, the whole example could be found by this link: https://github.com/andfadeev/learn_gleam_todo
Final thoughts
I’ve had a blast learning Gleam so far, and I’ll definitely continue exploring it. That said, I’ll need to work on a larger project to better understand where the rough edges are and what might be missing.
While Gleam has been gaining more traction recently, it’s still early days. I wouldn’t fully bet on it yet, but it’s absolutely worth learning if you want to broaden your horizons and explore a different part of the BEAM ecosystem.
A few thoughts on areas for improvement—please note that I’m still a Gleam beginner, so I may be missing existing or emerging solutions:
Proper live reload support for web development
Stronger documentation
Ecosystem maturity (need to explore using external Elixir or Erlang libraries)


