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_json

Building 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.

Leave a Comment

Your email address will not be published. Required fields are marked *