Note: this guide is a work in progress and is not currently complete. Content here will change and be added over time. In the meantime, you can check out the Gleam Discord server if you have any questions about full stack applications with Lustre.

06 Full stack applications

We’ve now seen how Lustre can render single-page applications in the browser, static HTML templates, and we’ve seen how hydration can be implemented. In this guide we’ll look at how to put these pieces together into a single application.

To create a full stack Web application in Gleam you will need to adopt a monorepo. Although Gleam supports multiple targets, and has conditional compilation features, the language isn’t designed to support a single codebase with different applications for different targets. Instead, we will create three separate Gleam projects:

mkdir lustre-fullstack-guide \
  && cd lustre-fullstack-guide \
  && gleam new client --name app \
  && gleam new server --name app \
  && gleam new shared --name app

We have one project for the frontend SPA, one project for the server, and a third project called shared that we will use to share types and code across the stack.

Full stack applications can get quite complex quite quickly, so this guide will focus on putting together a simple grocery list with pre-rendering and hydration, as well as a single API endpoint to save the items to a SQLite database.

The client

The application we’re building will eventually need to make requests to our backend API, which means we need an application capable of performing effects. To start, let’s scaffold out all the types and functions we need:

gleam add decipher lustre lustre_http gleam_json
gleam add lustre_dev_tools --dev
import gleam/dynamic
import gleam/int
import gleam/result
import lustre
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/event

pub fn main() {
  let app = lustre.application(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)

  Nil
}

type Model

fn init(_) -> #(Model, Effect(Msg)) {
  todo
}

type Msg

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  todo
}

fn view(model: Model) -> Element(Msg) {
  todo
}

The Model for our grocery list will be a list of tuples containing the name of some produce and a quantity to purchase. For now, we’ll initialise that with just an empty list:

type Model =
  List(#(String, Int))

fn init(_) -> #(Model, Effect(Msg)) {
  let model = []
  let effect = effect.none()

  #(model, effect)
}

Our Msg type needs to cover changes to the grocery list, the user requesting to save the list, and the response from the server:

type Msg {
  ServerSavedList(Result(Nil, String))
  UserAddedProduct(name: String)
  UserSavedList
  UserUpdatedQuantity(name: String, amount: Int)
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    ServerSavedList(_) -> todo
    UserAddedProduct(_) -> todo
    UserSavedList -> todo
    UserUpdatedQuantity(_, _) -> todo
  }
}

Finally, our view function will render the grocery list as an unordered list: each item will have an input field to update the quantity. Below the list will be an input field to add a new item, and a button to save the list:

fn view(model: Model) -> Element(Msg) {
  let styles = [
    #("max-width", "30ch"),
    #("margin", "0 auto"),
    #("display", "flex"),
    #("flex-direction", "column"),
    #("gap", "1em"),
  ]

  html.div([attribute.style(styles)], [
    view_grocery_list(model),
    view_new_item(),
    html.div([], [html.button([], [html.text("Sync")])]),
  ])
}

fn view_new_item() -> Element(Msg) {
  let handle_click = fn(event) {
    let path = ["target", "previousElementSibling", "value"]

    event
    |> decipher.at(path, dynamic.string)
    |> result.map(UserAddedProduct)
  }

  html.div([], [
    html.input([]),
    html.button([event.on("click", handle_click)], [html.text("Add")]),
  ])
}

fn view_grocery_list(model: Model) -> Element(Msg) {
  let styles = [#("display", "flex"), #("flex-direction", "column-reverse")]

  element.keyed(html.div([attribute.style(styles)], _), {
    use #(name, quantity) <- list.map(model)
    let item = view_grocery_item(name, quantity)

    #(name, item)
  })
}

fn view_grocery_item(name: String, quantity: Int) -> Element(Msg) {
  let handle_input = fn(e) {
    event.value(e)
    |> result.nil_error
    |> result.then(int.parse)
    |> result.map(UserUpdatedQuantity(name, _))
    |> result.replace_error([])
  }

  html.div([attribute.style([#("display", "flex"), #("gap", "1em")])], [
    html.span([attribute.style([#("flex", "1")])], [html.text(name)]),
    html.input([
      attribute.style([#("width", "4em")]),
      attribute.type_("number"),
      attribute.value(int.to_string(quantity)),
      attribute.min("0"),
      event.on("input", handle_input),
    ]),
  ])
}

Building for production

When we’re ready to deploy our application we will want to make sure our client app is minified. Minification is a process JavaScript build tools go through to rename variables, strip whitespace, and transform the code in other ways to make the file smaller.

Lustre’s build tools can produce minified JavaScript (and CSS, if you’re using Tailwind) bundles by providing the --minify flag:

gleam run -m lustre/dev build --minify --outdir=../server/priv/static

Typically, building a Lustre application will place the bundled JavaScript in the project’s priv/static directory. Because we’re building a full stack application we want our server to serve the JavaScript itself. The --outdir flag tells the build tool to place the output in our server’s priv directory instead.

Next, in our server we can use Wisp’s server_static middleware to automatically serve files from our server’s priv/static directory. To do that we’ll modify our handler function:

fn handler(req: wisp.Request) -> wisp.Response {
  let assert Ok(priv) = wisp.priv_directory("app")
  let static_dir = priv <> "/static"
  use <- wisp.serve_static(req, under: "/static", from: static_dir)
  ...
}
Search Document