← LOGBOOK LOG-459
COMPLETE · SOFTWARE ·
GLEAMWISPSQLITESQLIGHTMISTWEB-DEVELOPMENTBACKENDDATABASE

Building a Gleam Web App with Wisp and SQLite

A small Gleam application routes HTTP requests through Wisp, serves them with Mist, and isolates parameterized SQLite access behind typed functions.

A Wisp application is a function from an HTTP request to an HTTP response. Mist connects that function to a network socket, while SQLite keeps persistent state in a local file. The application boundary is therefore a typed pipeline rather than a framework-owned object graph:

HTTP request → Mist → Wisp middleware → route → database function → response
                                      ↘ SQLite file ↗

Wisp supplies request, response, body, cookie, security, and middleware helpers. It does not own routing or persistence. Routes remain ordinary pattern matches, and database access remains ordinary Gleam functions returning Result.

Project and Dependencies

Create a Gleam application and add the web server, framework, Erlang runtime helpers, HTTP types, and SQLite driver:

gleam new notes_app
cd notes_app
gleam add wisp mist gleam_erlang gleam_http sqlight

The packages have separate responsibilities:

  • mist accepts HTTP connections on the BEAM.
  • wisp provides the handler and middleware layer.
  • gleam_http defines shared HTTP request, response, and method types.
  • gleam_erlang keeps the main Erlang process alive after the server starts.
  • sqlight wraps SQLite and converts database values to Gleam values.

The application can remain small without collapsing these boundaries. A useful first structure is:

src/
├── notes_app.gleam
└── notes_app/
    ├── notes.gleam
    └── web.gleam

notes.gleam owns SQL and row decoding. web.gleam owns middleware and routes. The root module initializes resources and starts the server.

Database Values Need Decoders

SQLite returns rows as dynamically represented database values. A decoder establishes the expected column order and converts a successful row into a Gleam type:

// src/notes_app/notes.gleam
import gleam/dynamic/decode
import gleam/result
import sqlight

pub type Note {
  Note(id: Int, title: String)
}

fn note_decoder() {
  use id <- decode.field(0, decode.int)
  use title <- decode.field(1, decode.string)
  decode.success(Note(id:, title:))
}

decode.field(0, ...) refers to the first selected column, not a column named 0. The decoder and the SELECT list form one contract. Reordering the SQL columns without updating the decoder produces a decoding error instead of a silently malformed Note.

SQLite itself has a small dynamic type system—integer, real, text, blob, and null. Application-specific meaning still belongs in Gleam. For example, SQLite commonly stores booleans as 0 and 1; sqlight.decode_bool handles that representation when a model needs a boolean field.

Schema Initialization

The schema can be created idempotently when the application starts:

pub fn initialise(database_path: String) -> Nil {
  use connection <- sqlight.with_connection(database_path)

  let assert Ok(Nil) =
    sqlight.exec(
      "CREATE TABLE IF NOT EXISTS notes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL
      );",
      connection,
    )

  Nil
}

with_connection opens the database, passes the connection to the callback, and closes it when the callback returns. exec is appropriate for schema statements and SQL strings containing multiple statements. Ordinary reads and writes should use query, which supports bound parameters and typed row decoding.

The let assert makes startup fail if the schema cannot be established. Continuing to accept requests without the required table would only defer the same failure into every handler.

Parameterized Reads and Writes

Listing notes reuses the model decoder:

pub fn list(database_path: String) -> Result(List(Note), sqlight.Error) {
  use connection <- sqlight.with_connection(database_path)

  sqlight.query(
    "SELECT id, title FROM notes ORDER BY id DESC",
    on: connection,
    with: [],
    expecting: note_decoder(),
  )
}

Creating a note binds the title as a SQL value rather than concatenating it into the statement:

pub fn create(
  database_path: String,
  title: String,
) -> Result(Nil, sqlight.Error) {
  use connection <- sqlight.with_connection(database_path)

  sqlight.query(
    "INSERT INTO notes (title) VALUES (?) RETURNING id",
    on: connection,
    with: [sqlight.text(title)],
    expecting: decode.field(0, decode.int),
  )
  |> result.map(fn(_created_ids) { Nil })
}

The placeholder and sqlight.text(title) keep SQL syntax separate from untrusted data. Escaping text manually is not an equivalent substitute: quoting rules are easy to get wrong, while bound values are never parsed as SQL.

Each function opens a connection for one operation. This is simple and prevents unrelated request processes from accidentally interleaving statements on the same connection. A larger application can introduce a pool or a database-owning process later, but that component must define how concurrent requests and transactions are serialized.

HTML Is an Output Boundary

The web layer turns typed notes into HTML. Database parameter binding prevents SQL injection; HTML escaping prevents the same stored title from becoming executable markup in a response. The two protections operate at different boundaries.

// src/notes_app/web.gleam
import gleam/http.{Get, Post}
import gleam/list
import gleam/string
import notes_app/notes.{type Note}
import wisp.{type Request, type Response}

pub type Context {
  Context(database_path: String)
}

fn note_item(note: Note) -> String {
  "<li>" <> wisp.escape_html(note.title) <> "</li>"
}

fn page(notes: List(Note)) -> String {
  let items = notes |> list.map(note_item) |> string.concat

  "<!doctype html>
  <html lang=\"en\">
    <head><meta charset=\"utf-8\"><title>Notes</title></head>
    <body>
      <h1>Notes</h1>
      <textarea id=\"title\"></textarea>
      <button onclick=\"saveNote()\">Save</button>
      <ul>" <> items <> "</ul>
      <script>
        async function saveNote() {
          const title = document.getElementById('title').value;
          const response = await fetch('/notes', {
            method: 'POST',
            headers: {'content-type': 'text/plain; charset=utf-8'},
            body: title
          });
          if (response.ok) window.location.reload();
        }
      </script>
    </body>
  </html>"
}

This hand-built HTML keeps the first application self-contained. Once views become nested or conditional, a typed HTML library can replace string construction. The invariant remains the same: user-controlled text must be escaped at the HTML boundary.

Middleware and Routing

Wisp middleware uses Gleam’s use syntax to wrap the rest of a handler. Logging observes the request and resulting response; crash rescue converts an unexpected process failure into a 500 response.

pub fn handle_request(request: Request, context: Context) -> Response {
  use <- wisp.log_request(request)
  use <- wisp.rescue_crashes

  case #(request.method, wisp.path_segments(request)) {
    #(Get, []) -> show_notes(context)
    #(Post, ["notes"]) -> create_note(request, context)
    _ -> wisp.not_found()
  }
}

wisp.path_segments turns /notes into ["notes"]. Matching method and path together makes the routing table exhaustive: the final branch states what happens for every unsupported combination.

The read handler converts database failure into an HTTP response at the boundary:

fn show_notes(context: Context) -> Response {
  case notes.list(context.database_path) {
    Ok(notes) -> wisp.html_response(page(notes), 200)
    Error(_error) -> wisp.internal_server_error()
  }
}

The write handler reads the body once, validates it, performs the insert, and redirects after success:

fn create_note(request: Request, context: Context) -> Response {
  use body <- wisp.require_string_body(request)

  let title = string.trim(body)
  case title {
    "" -> wisp.bad_request("A title is required")
    title ->
      case notes.create(context.database_path, title) {
        Ok(Nil) -> wisp.redirect(to: "/")
        Error(_error) -> wisp.internal_server_error()
      }
  }
}

require_string_body enforces Wisp’s body-size limit and rejects invalid UTF-8. It consumes the request body, so it must not be called a second time for the same request. This minimal handler accepts the entire plain-text body as a title, matching the small fetch call in the page. A traditional browser form sends URL-encoded data, so replacing the script with a form also requires decoding application/x-www-form-urlencoded input.

The redirect uses 303 See Other. The browser follows it with a GET /, preventing a page refresh from submitting the insert again.

Starting Wisp through Mist

The root module creates the database, captures its path in a context value, adapts the Wisp handler to Mist, and keeps the main process alive:

// src/notes_app.gleam
import gleam/erlang/process
import mist
import notes_app/notes
import notes_app/web.{Context}
import wisp
import wisp/wisp_mist

pub fn main() {
  let database_path = "file:notes.sqlite3"
  notes.initialise(database_path)
  wisp.configure_logger()

  let context = Context(database_path: database_path)
  let handler = fn(request) { web.handle_request(request, context) }

  let assert Ok(_) =
    handler
    |> wisp_mist.handler(wisp.random_string(64))
    |> mist.new
    |> mist.port(8000)
    |> mist.start_http

  process.sleep_forever()
}

Run the application and open http://localhost:8000:

gleam run

The secret passed to wisp_mist.handler signs and encrypts Wisp data. A random value is adequate while no state must survive a restart. Applications using sessions or signed cookies need a stable secret loaded from the environment rather than a new value on every launch.

The First Production Boundaries

The small application already exposes the boundaries that need to grow deliberately:

  • HTML forms require correct media-type decoding and cross-site request forgery protection.
  • A long-running service needs migrations rather than only CREATE TABLE IF NOT EXISTS.
  • Concurrent traffic needs an explicit SQLite connection and transaction strategy.
  • Database errors need logging before their details are hidden from HTTP clients.
  • Stable sessions require a persistent secret and secure cookie configuration.
  • Tests can call handle_request directly with Wisp’s simulation helpers and use an in-memory SQLite database.

Wisp does not conceal these decisions. Its handler remains an ordinary function, middleware remains function composition, and sqlight returns explicit results. The architecture can become more sophisticated without changing the core pipeline established by the first route.

Sources