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.

Project structure

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 \
  && gleam new server \
  && gleam new shared

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 complex 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 file-based database.

The shared package

Let’s start by defining the shared types and functions that will be used by both the client and server applications. This way, we can ensure that our data structures remain consistent across the stack.

cd shared
gleam add gleam_json

In src/shared/groceries.gleam, let’s define our core types. The _to_json and _decoder functions are largely auto-generated by using the respective code actions:

import gleam/dynamic/decode
import gleam/json

pub type GroceryItem {
  GroceryItem(name: String, quantity: Int)
}

fn grocery_item_decoder() -> decode.Decoder(GroceryItem) {
  use name <- decode.field("name", decode.string)
  use quantity <- decode.field("quantity", decode.int)
  decode.success(GroceryItem(name:, quantity:))
}

pub fn grocery_list_decoder() -> decode.Decoder(List(GroceryItem)) {
  decode.list(grocery_item_decoder())
}

fn grocery_item_to_json(grocery_item: GroceryItem) -> json.Json {
  let GroceryItem(name:, quantity:) = grocery_item
  json.object([#("name", json.string(name)), #("quantity", json.int(quantity))])
}

pub fn grocery_list_to_json(items: List(GroceryItem)) -> json.Json {
  json.array(items, grocery_item_to_json)
}

The client application

The client application will be a Lustre SPA that can communicate with our backend API. Let’s set up the necessary dependencies and scaffolding:

cd ../client
gleam add lustre rsvp gleam_json gleam_http plinth
gleam add lustre_dev_tools --dev

We also need to add our shared package as a local dependency. In gleam.toml, add:

[dependencies]
shared = { path = "../shared" }

Since the client will be a JavaScript SPA, we also set that as the target such that we can use client-specific code:

name = "client"
version = "1.0.0"
target = "javascript"

# ...

After editing the gleam.toml file, we need to tell gleam about this change to make sure our dependencies are all up-to-date:

gleam deps update

Now, let’s implement our client application in src/client.gleam:

import gleam/http/response.{type Response}
import gleam/int
import gleam/list
import gleam/option.{type Option}
import gleam/result
import lustre
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event
import rsvp
import shared/groceries.{type GroceryItem, GroceryItem}

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

  Nil
}

// MODEL -----------------------------------------------------------------------

type Model {
  Model(
    items: List(GroceryItem),
    new_item: String,
    saving: Bool,
    error: Option(String),
  )
}

fn init(items: List(GroceryItem)) -> #(Model, Effect(Msg)) {
  let model =
    Model(items: items, new_item: "", saving: False, error: option.None)

  #(model, effect.none())
}

// UPDATE ----------------------------------------------------------------------

type Msg {
  ServerSavedList(Result(Response(String), rsvp.Error))
  UserAddedItem
  UserTypedNewItem(String)
  UserSavedList
  UserUpdatedQuantity(index: Int, quantity: Int)
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    ServerSavedList(Ok(_)) -> #(
      Model(..model, saving: False, error: option.None),
      effect.none(),
    )

    ServerSavedList(Error(_)) -> #(
      Model(..model, saving: False, error: option.Some("Failed to save list")),
      effect.none(),
    )

    UserAddedItem -> {
      case model.new_item {
        "" -> #(model, effect.none())
        name -> {
          let item = GroceryItem(name: name, quantity: 1)
          let updated_items = list.append(model.items, [item])

          #(Model(..model, items: updated_items, new_item: ""), effect.none())
        }
      }
    }

    UserTypedNewItem(text) -> #(Model(..model, new_item: text), effect.none())

    UserSavedList -> #(Model(..model, saving: True), save_list(model.items))

    UserUpdatedQuantity(index:, quantity:) -> {
      let updated_items =
        list.index_map(model.items, fn(item, item_index) {
          case item_index == index {
            True -> GroceryItem(..item, quantity:)
            False -> item
          }
        })

      #(Model(..model, items: updated_items), effect.none())
    }
  }
}

fn save_list(items: List(GroceryItem)) -> Effect(Msg) {
  let body = groceries.grocery_list_to_json(items)
  let url = "/api/groceries"

  rsvp.post(url, body, rsvp.expect_ok_response(ServerSavedList))
}

// VIEW ------------------------------------------------------------------------

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

  html.div([attribute.styles(styles)], [
    html.h1([], [html.text("Grocery List")]),
    view_grocery_list(model.items),
    view_new_item(model.new_item),
    html.div([], [
      html.button(
        [event.on_click(UserSavedList), attribute.disabled(model.saving)],
        [
          html.text(case model.saving {
            True -> "Saving..."
            False -> "Save List"
          }),
        ],
      ),
    ]),
    case model.error {
      option.None -> element.none()
      option.Some(error) ->
        html.div([attribute.style("color", "red")], [html.text(error)])
    },
  ])
}

fn view_new_item(new_item: String) -> Element(Msg) {
  html.div([], [
    html.input([
      attribute.placeholder("Enter item name"),
      attribute.value(new_item),
      event.on_input(UserTypedNewItem),
    ]),
    html.button([event.on_click(UserAddedItem)], [html.text("Add")]),
  ])
}

fn view_grocery_list(items: List(GroceryItem)) -> Element(Msg) {
  case items {
    [] -> html.p([], [html.text("No items in your list yet.")])
    _ -> {
      html.ul(
        [],
        list.index_map(items, fn(item, index) {
          html.li([], [view_grocery_item(item, index)])
        }),
      )
    }
  }
}

fn view_grocery_item(item: GroceryItem, index: Int) -> Element(Msg) {
  html.div([attribute.styles([#("display", "flex"), #("gap", "1em")])], [
    html.span([attribute.style("flex", "1")], [html.text(item.name)]),
    html.input([
      attribute.style("width", "4em"),
      attribute.type_("number"),
      attribute.value(int.to_string(item.quantity)),
      attribute.min("0"),
      event.on_input(fn(value) {
        result.unwrap(int.parse(value), 0)
        |> UserUpdatedQuantity(index, quantity: _)
      }),
    ]),
  ])
}

The server application

Now let’s implement our server, which will handle API requests, serve static assets, and perform server-side rendering. We will use the popular Wisp web framework as well as storail as a simple database.

cd ../server
gleam add gleam_erlang gleam_http gleam_json wisp mist lustre storail

Again, we will add our shared package as a dependency to gleam.toml:

[dependencies]
shared = { path = "../shared" }

Let’s implement the server in src/server.gleam:

import gleam/dynamic/decode
import gleam/erlang/process
import gleam/http.{Get, Post}
import gleam/json
import gleam/result
import lustre/attribute
import lustre/element
import lustre/element/html
import mist
import storail
import wisp.{type Request, type Response}
import wisp/wisp_mist

import shared/groceries.{type GroceryItem}

pub fn main() {
  wisp.configure_logger()
  let secret_key_base = wisp.random_string(64)

  // Set up our database
  let assert Ok(db) = setup_database()

  let assert Ok(priv_directory) = wisp.priv_directory("server")
  let static_directory = priv_directory <> "/static"

  let assert Ok(_) =
    handle_request(db, static_directory, _)
    |> wisp_mist.handler(secret_key_base)
    |> mist.new
    |> mist.port(3000)
    |> mist.start_http

  process.sleep_forever()
}

// REQUEST HANDLERS ------------------------------------------------------------

fn app_middleware(
  req: Request,
  static_directory: String,
  next: fn(Request) -> Response,
) -> Response {
  let req = wisp.method_override(req)
  use <- wisp.log_request(req)
  use <- wisp.rescue_crashes
  use req <- wisp.handle_head(req)
  use <- wisp.serve_static(req, under: "/static", from: static_directory)

  next(req)
}

fn handle_request(
  db: storail.Collection(List(GroceryItem)),
  static_directory: String,
  req: Request,
) -> Response {
  use req <- app_middleware(req, static_directory)

  case req.method, wisp.path_segments(req) {
    // API endpoint for saving grocery lists
    Post, ["api", "groceries"] -> handle_save_groceries(db, req)

    // Everything else gets our HTML with hydration data
    Get, _ -> serve_index(db)

    // Fallback for other methods/paths
    _, _ -> wisp.not_found()
  }
}

fn serve_index(db: storail.Collection(List(GroceryItem))) -> Response {
  let html =
    html.html([], [
      html.head([], [
        html.title([], "Grocery List"),
        html.script(
          [attribute.type_("module"), attribute.src("/static/client.mjs")],
          "",
        ),
      ]),
      html.body([], [html.div([attribute.id("app")], [])]),
    ])

  html
  |> element.to_document_string_tree
  |> wisp.html_response(200)
}

fn handle_save_groceries(
  db: storail.Collection(List(GroceryItem)),
  req: Request,
) -> Response {
  use json <- wisp.require_json(req)

  case decode.run(json, groceries.grocery_list_decoder()) {
    Ok(items) ->
      case save_items_to_db(db, items) {
        Ok(_) -> wisp.ok()
        Error(_) -> wisp.internal_server_error()
      }
    Error(_) -> wisp.bad_request()
  }
}

// DATABASE --------------------------------------------------------------------

fn setup_database() -> Result(storail.Collection(List(GroceryItem)), Nil) {
  let config = storail.Config(storage_path: "./data")

  let items =
    storail.Collection(
      name: "grocery_list",
      to_json: groceries.grocery_list_to_json,
      decoder: groceries.grocery_list_decoder(),
      config:,
    )

  Ok(items)
}

fn grocery_list_key(
  db: storail.Collection(List(GroceryItem)),
) -> storail.Key(List(GroceryItem)) {
  // In a real application, you would probably store items as individual
  // documents, or use a database like PostgreSQL instead.
  storail.key(db, "grocery_list")
}

fn save_items_to_db(
  db: storail.Collection(List(GroceryItem)),
  items: List(GroceryItem),
) -> Result(Nil, storail.StorailError) {
  storail.write(grocery_list_key(db), items)
}

Running our app

To run our new full-stack app, we first have to bundle the client app into a JavaScript file:

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

Afterwards, we can run our server:

cd ../server
gleam run

Adding hydration

Unfortunately, even after we saved our grocery list, all our items are lost after we reload!

To fix this, we need to serialise the initial state we want to render instead in our server, and then load that state from our client, effectively pre-populating our grocery list with data from the database.

This is often called hydration in other frameworks. The previous guide on server-side rendering gives a more in-depth introduction to this process.

First, let’s extend our server to include the grocery items from our database:

// server.gleam

fn serve_index(db: storail.Collection(List(GroceryItem))) -> Response {
  // NEW: Fetch grocery items from database
  let items = fetch_items_from_db(db)

  let html =
    html.html([], [
      html.head([], [
        html.title([], "Grocery List"),
        html.script(
          [attribute.type_("module"), attribute.src("/static/client.mjs")],
          "",
        ),
      ]),
      // NEW: include a script tag with our initial grocery list
      html.script(
        [attribute.type_("application/json"), attribute.id("model")],
        json.to_string(groceries.grocery_list_to_json(items))
      ),
      html.body([], [html.div([attribute.id("app")], [])]),
    ])

  html
  |> element.to_document_string_tree
  |> wisp.html_response(200)
}


fn fetch_items_from_db(
  db: storail.Collection(List(GroceryItem)),
) -> List(GroceryItem) {
  storail.read(grocery_list_key(db))
  |> result.unwrap([])
}

Next, we can use this data to initialise our client app state:

// client.gleam
import gleam/json
import plinth/browser/document
import plinth/browser/element as plinth_element

pub fn main() {
  let initial_items =
    document.query_selector("#model")
    |> result.map(plinth_element.inner_text)
    |> result.then(fn(json) {
      json.parse(json, groceries.grocery_list_decoder())
      |> result.replace_error(Nil)
    })
    |> result.unwrap([])

  let app = lustre.application(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", initial_items)

  Nil
}

After that, your app state will persist after reloading.

Building for production

When preparing for production, you’ll want to minify your client application and ensure it’s placed somewhere the server can serve it. Minification is a process that reduces the size of your JavaScript files by removing whitespace and comments, renaming variables, and other transformations.

Lustre’s build tool can produce minified JavaScript bundles by providing the --minify flag.

cd ../client
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.

You will also need to change the script tag from /static/client.mjs to = /static/client.min.mjs to load your production bundle.

Next steps

To learn how to deploy your full-stack application, check out the full-stack deployments guide.

Search Document