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.