diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-04-05 13:22:40 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-05 13:22:40 +0100 |
commit | fa1f447bd0eaf9d27b6af407f90477b81e70b358 (patch) | |
tree | 9c72f3408e94fbe8b50a2390a0bd13d0754c19bc /pages/guide/04-server-side-rendering.md | |
parent | 99ba44fe0cc19cfc4191bd95ec6fccaea080a929 (diff) | |
download | lustre-fa1f447bd0eaf9d27b6af407f90477b81e70b358.tar.gz lustre-fa1f447bd0eaf9d27b6af407f90477b81e70b358.zip |
🔀 Write a guide on server-side rendering. (#103)
* :memo: Fix typos in state management guide.
* :memo: Begin writing ssr guide.
* :memo: Rename discard init arg to '_flags' in all examples.
* :memo: Show simple static HTML rendering example.
* :memo: Expand hydration section.
* :memo: Finish ssr guide.
* :wrench: Add ssr guide to built docs pages.
Diffstat (limited to 'pages/guide/04-server-side-rendering.md')
-rw-r--r-- | pages/guide/04-server-side-rendering.md | 219 |
1 files changed, 219 insertions, 0 deletions
diff --git a/pages/guide/04-server-side-rendering.md b/pages/guide/04-server-side-rendering.md new file mode 100644 index 0000000..b72d887 --- /dev/null +++ b/pages/guide/04-server-side-rendering.md @@ -0,0 +1,219 @@ +# 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](https://hexdocs.pm/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): + +```sh +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! + +```gleam +import gleam/bytes_builder +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import lustre/element.{type 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: + +```gleam +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: + +```gleam +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](https://github.com/lustre-labs/lustre/blob/main/pages/hints/pure-functions.md) +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 whate the server used to render the page. + +```gleam +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: + +```gleam +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](https://hexdocs.pm/plinth/plinth.html) package provides +bindings to many browser APIs, we can use that to read the JSON out of the script +tag: + +```gleam +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](https://hexdocs.pm/lustre/guide/05-full-stack-applications.html) +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](https://discord.gg/Fm8Pwmy). +You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). |