Gleam Web Development
Gleam has a growing web development ecosystem covering both server-side HTTP applications and browser-based frontends. The core tools — Wisp for web frameworks, Mist for HTTP servers, and Lustre for UI — give you everything needed to build full-stack applications entirely in Gleam.
The Web Stack
Gleam Full-Stack Architecture
──────────────────────────────────────────────────
Browser (JS target)
└── Lustre — UI framework (Elm architecture)
Server (Erlang/BEAM target)
├── Wisp — HTTP framework (routing, middleware)
├── Mist — HTTP server (TCP, WebSocket)
└── gleam_http — HTTP types (Request, Response)
Shared (both targets)
└── Pure Gleam logic: validation, types, business rules
Installing the Web Packages
gleam add wisp mist gleam_http gleam_jsonBuilding a Server with Wisp
Wisp handles routing, middleware, request parsing, and response building:
import wisp.{type Request, type Response}
import gleam/http.{Get, Post}
import gleam/json
import mist
pub fn router(req: Request) -> Response {
case wisp.path_segments(req) {
[] ->
home_handler(req)
["api", "users"] ->
users_handler(req)
["api", "users", id] ->
user_handler(req, id)
_ ->
wisp.not_found()
}
}
fn home_handler(req: Request) -> Response {
case req.method {
Get -> wisp.html_response("Welcome to Gleam!
", 200)
_ -> wisp.method_not_allowed([Get])
}
}
Handling JSON Requests
import gleam/json
import gleam/dynamic
import wisp
type CreateUserBody {
CreateUserBody(name: String, email: String)
}
fn decode_user_body(req: Request) -> Result(CreateUserBody, Nil) {
use body <- wisp.require_json(req)
let decoder = dynamic.decode2(
CreateUserBody,
dynamic.field("name", dynamic.string),
dynamic.field("email", dynamic.string)
)
json.decode_dynamic(body, decoder)
|> result.map_error(fn(_) { Nil })
}
fn create_user_handler(req: Request) -> Response {
case decode_user_body(req) {
Error(_) -> wisp.unprocessable_entity()
Ok(body) -> {
// Save to database, return created user
let response_json = json.object([
#("status", json.string("created")),
#("name", json.string(body.name))
])
wisp.json_response(json.to_string(response_json), 201)
}
}
}
Starting the HTTP Server with Mist
import mist
import wisp
import gleam/erlang/process
pub fn main() {
let assert Ok(_) =
wisp.mist_handler(router, wisp.default_secret_key())
|> mist.new
|> mist.port(8080)
|> mist.start_http
io.println("Server running on http://localhost:8080")
process.sleep_forever()
}
Request Flow
──────────────────────────────────────────────────
Client → HTTP Request → Mist (TCP)
│
▼
Wisp (router)
│
path_segments match
│
handler function
│
wisp.Response
│
▼
Mist → Client
Middleware
Wisp middleware wraps request handling with cross-cutting concerns:
pub fn router(req: Request) -> Response {
use req <- wisp.handle_head(req) // handle HEAD requests
use _ <- wisp.log_request(req) // log every request
use req <- wisp.require_content_type(req, "application/json")
case wisp.path_segments(req) {
["api", ..] -> api_router(req)
_ -> wisp.not_found()
}
}
Building a Frontend with Lustre
Lustre uses the Model-View-Update (MVU) pattern for browser UIs:
import lustre
import lustre/element/html.{div, h1, button, p, input}
import lustre/event
import lustre/attribute.{class, value, on_input}
type Model {
Model(name: String, submitted: Bool)
}
type Msg {
NameChanged(String)
FormSubmitted
}
fn init(_) -> Model {
Model(name: "", submitted: False)
}
fn update(model: Model, msg: Msg) -> Model {
case msg {
NameChanged(name) -> Model(..model, name: name)
FormSubmitted -> Model(..model, submitted: True)
}
}
fn view(model: Model) {
case model.submitted {
True -> div([], [h1([], [lustre/element.text("Hello, " <> model.name <> "!")])])
False ->
div([class("form")], [
input([value(model.name), on_input(NameChanged)], []),
button([event.on_click(FormSubmitted)], [lustre/element.text("Submit")])
])
}
}
pub fn main() {
let app = lustre.simple(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
}
Lustre MVU Cycle
──────────────────────────────────────────────────
Model (state)
/ \
view() update()
\ /
User Action → Msg
Database Access
The gleam_pgo package provides PostgreSQL access:
import gleam/pgo
pub fn get_users(db: pgo.Connection) -> Result(List(User), pgo.QueryError) {
pgo.query(
"SELECT id, name, email FROM users",
on: db,
with: [],
returning: dynamic.decode3(
User,
dynamic.element(0, dynamic.int),
dynamic.element(1, dynamic.string),
dynamic.element(2, dynamic.string)
)
)
}
REST API Design Pattern
REST Router Structure
──────────────────────────────────────────────────
GET /api/products → list_products
POST /api/products → create_product
GET /api/products/:id → get_product
PUT /api/products/:id → update_product
DELETE /api/products/:id → delete_product
pub fn api_router(req: Request) -> Response {
case #(req.method, wisp.path_segments(req)) {
#(Get, ["api","products"]) -> list_products(req)
#(Post, ["api","products"]) -> create_product(req)
#(Get, ["api","products", id]) -> get_product(req, id)
#(Put, ["api","products", id]) -> update_product(req, id)
#(Delete, ["api","products", id]) -> delete_product(req, id)
_ -> wisp.not_found()
}
}
Key Points
Web Development Essentials
──────────────────────────────────────────────────
Server:
1. Wisp handles routing and request/response building
2. Mist provides the HTTP/TCP server layer
3. gleam_http defines Request/Response types
4. gleam_json handles JSON encode/decode
Frontend:
5. Lustre provides MVU architecture for browser UIs
6. Compile with --target javascript
7. Mount with lustre.start(app, "#selector", flags)
Shared:
8. Pure Gleam logic works on both targets
9. gleam_pgo for PostgreSQL, other packages for other DBs
Gleam's web stack is opinionated and coherent. Wisp and Mist handle the server layer. Lustre handles the browser layer. Type-safe request parsing, structured routing, and the Result-based error model carry through every layer of the stack — giving you the same reliability guarantees on the web that Gleam provides everywhere else.
