04 Server-side rendering

Up until now, we have focused on Lustre’s ability as a framework for building Single Page Applications (SPAs). While Lustre’s development and feature set is primarily focused on SPA development, that doesn’t mean it can’t be used on the backend as well! In this guide we’ll set up a small mist server that renders some static HTML using Lustre.

Setting up the project

We’ll start by adding the dependencies we need and scaffolding the HTTP server. Besides Lustre and Mist, we also need gleam_erlang (to keep our application alive) and gleam_http (for types and functions to work with HTTP requests and responses):

gleam new app && cd app && gleam add gleam_erlang gleam_http lustre mist

Besides imports for mist and gleam_http modules, we also need to import some modules to render HTML with Lustre. Importantly, we don’t need anything from the main lustre module: we’re not building an application with a runtime!

import gleam/bytes_builder
import gleam/erlang/process
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import lustre/element
import lustre/element/html.{html}
import mist.{type Connection, type ResponseData}

We’ll modify Mist’s example and write a simple request handler that responds to requests to /greet/:name with a greeting message:

pub fn main() {
  let empty_body = mist.Bytes(bytes_builder.new())
  let not_found = response.set_body(response.new(404), empty_body)

  let assert Ok(_) =
    fn(req: Request(Connection)) -> Response(ResponseData) {
      case request.path_segments(req) {
        ["greet", name] -> greet(name)
        _ -> not_found
      }
    }
    |> mist.new
    |> mist.port(3000)
    |> mist.start_http

  process.sleep_forever()
}

Let’s take a peek inside that greet function:

fn greet(name: String) -> Response(ResponseData) {
  let res = response.new(200)
  let html =
    html([], [
      html.head([], [html.title([], "Greetings!")]),
      html.body([], [
        html.h1([], [html.text("Hey there, " <> name <> "!")])
      ])
    ])

  response.set_body(res,
    html
    |> element.to_document_string
    |> bytes_builder.from_string
    |> mist.Bytes
  )
}

The lustre/element module has functions for rendering Lustre elements to a string (or string builder); the to_document_string function helpfully prepends the <!DOCTYPE html> declaration to the output.

It’s important to realise that element.to_string and element.to_document_string can render any Lustre element! This means you could take the view function from your client-side SPA and render it server-side, too.

Hydration

If we know we can render our apps server-side, the next logical question is how do we handle hydration? Hydration is the process of taking the static HTML generated by the server and turning it into a fully interactive client application, ideally doing as little work as possible.

Most frameworks today support hydration or some equivalent, for example by serialising the state of each component into the HTML and then picking up where the server left off. Lustre doesn’t have a built-in hydration mechanism, but because of the way it works, it’s easy to implement one yourself!

We’ve said many times now that in Lustre, your view is just a pure function of your model. We should produce the same HTML every time we call view with the same model, no matter how many times we call it.

Let’s use that to our advantage! We know our app’s init function is responsible for producing the initial model, so all we need is a way to make sure the initial model on the client is the same as what the server used to render the page.

pub fn view(model: Int) -> Element(Msg) {
  let count = int.to_string(model)

  html.div([], [
    html.button([event.on_click(Decr)], [html.text("-")]),
    html.button([event.on_click(Incr)], [html.text("+")]),
    html.p([], [html.text("Count: " <> count)])
  ])
}

We’ve seen the counter example a thousand times over now, but it’s a good example to show off how simple hydration can be. The view function produces some HTML with events attached, but we already know Lustre can render any element to a string so that shouldn’t be a problem.

Let’s imagine our HTTP server responds with the following HTML:

import app/counter
import gleam/bytes_builder
import gleam/http/response.{type Response}
import gleam/json
import lustre/attribute
import lustre/element.{type Element}
import lustre/element/html.{html}
import mist.{type ResponseData}

fn app() -> Response(ResponseData) {
  let res = response.new(200)

  let model = 5
  let html =
    html([], [
      html.head([], [
        html.script([attribute.type_("module"), attribute.src("...")], ""),
        html.script([attribute.type_("application/json"), attribute.id("model")],
          json.int(model)
          |> json.to_string
        )
      ]),
      html.body([], [
        html.div([attribute.id("app")], [
          counter.view(model)
        ])
      ])
    ])

  response.set_body(res,
    html
    |> element.to_document_string
    |> bytes_builder.from_string
    |> mist.Bytes
  )
}

We’ve rendered the shell of our application, as well as the counter using 5 as the initial model. Importantly, we’ve included a <script> tag with the initial model encoded as JSON (it might just be an Int in this example, but it could be anything).

On the client, it’s a matter of reading that JSON and decoding it as our initial model. The plinth package provides bindings to many browser APIs, we can use that to read the JSON out of the script tag:

import gleam/dynamic
import gleam/json
import gleam/result
import lustre
import plinth/browser/document
import plinth/browser/element

pub fn main() {
  let json =
    document.query_selector("#model")
    |> result.map(element.inner_text)

  let flags =
    case json.decode_string(json, dynamic.int) {
      Ok(count) -> count
      Error(_) -> 0
    }

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

Hey that wasn’t so bad! We made sure to fall back to an initial count of 0 if we failed to decode the JSON: this lets us handle cases where the server might not want us to hydrate.

If you were to set this all up, run it, and check your browser’s developer tools, you’d see that the existing HTML was not replaced and the app is fully interactive!

For many cases serialising the entire model will work just fine. But remember that Lustre’s super power is that pure view function. If you’re smart, you can reduce the amount of data you serialise and derive the rest of your model from that.

We brushed over quite a few details showing how hydration could work here, but in the next guide we’ll go into a lot more detail on how to set up and run a full-stack Lustre app.

Getting help

If you’re having trouble with Lustre or not sure what the right way to do something is, the best place to get help is the Gleam Discord server. You could also open an issue on the Lustre GitHub repository.

Search Document