Glamour
Zero-flash SSR + claim for Lustre/Gleam on the BEAM.
Glamour renders your Lustre view on the server, serialises the exact model, fingerprints the markup, and claims the live DOM on the client without wiping it. If the fingerprint changes, the client falls back to hydrate and logs helpful errors. The goal is a two-line upgrade: one call on the server, one on the client, no flash between the first paint and the claimed DOM.
Project home: https://github.com/thirdreplicator/glamour
Why Glamour
- Zero-flash first paint – Server delivers the finished HTML and reuses it, so the user never sees an empty shell.
- One-line ergonomics –
server.render
on the backend andclient.claim
on the frontend. - Fingerprint safety – Stable SHA-256 hash over serialised state + markup keeps server/client in sync.
- Helpful diagnostics – Clear console output for missing state, selector mismatches, and Lustre start failures.
- Target flexibility – Works on both Erlang and JavaScript backends; incubated here prior to Hex publishing.
Installation
Incubator workflow (inside this repo)
- Add Glamour as a path dependency (already configured here):
# gleam.toml [dependencies] glamour = { path = "../lib/glamour" }
- Fetch dependencies and build:
gleam deps download gleam build --target erlang
Future Hex package (roadmap)
Once published, replace the path dependency with a semantic version:
[dependencies]
glamour = "~> 0.1"
Usage
1. Describe your Lustre app
Create an application spec that tells Glamour how to render, serialise, and decode your model. The spec assumes the Lustre app’s start_args
type matches the model.
import glamour/app
import lustre
import lustre/element/html as html
import gleam/json
import gleam/dynamic/decode as decode
pub type Model {
Model(count: Int)
}
fn init(model: Model) -> Model { model }
fn update(model: Model, _msg) -> Model { model }
fn view(Model(count:)) -> html.Node { html.text(int.to_string(count)) }
fn encode_model(Model(count:)) -> json.Json {
json.int(count)
}
fn decode_model() -> decode.Decoder(Model) {
decode.map(decode.int, Model)
}
pub fn spec() -> app.Spec(Model, msg) {
let lustre_app = lustre.simple(init, update, view)
app.new(lustre_app, view, encode_model, decode_model())
|> app.with_selector("#app") // optional, default is "#app"
|> app.with_state_script_id("glamour-state") // optional, default is "glamour-state"
}
2. Render on the server
Call glamour/server.render/3
inside your HTTP handler and return the HTML string it produces. The helper embeds the SSR fragment, serialised state, fingerprint, and client script tags.
import glamour/server
import gleam/option
pub fn render_page(model) -> server.Rendered {
let spec = spec()
let options =
server.default_options()
|> with_glamour_scripts()
|> server.Options(..)
|> option.Some
server.render(spec, model, options)
}
fn with_glamour_scripts(options: server.Options) -> server.Options {
server.Options(
..options,
title: option.Some("Dashboard"),
client_scripts: ["/assets/glamour/main.mjs"],
head: [
..options.head,
" <link rel=\"stylesheet\" href=\"/assets/app.css\">\n",
],
)
}
server.Options
fields:
lang
– HTML language tag ("en"
default).title
– Optional document title.csp_nonce
– Attach CSP nonce to embedded script tags.head
– Extra head markup (strings that already contain trailing newlines).client_scripts
–<script>
tags (module or classic) appended after the head entries.stream
– Reserved for future streaming support.
The returned Rendered(html)
contains a complete HTML document. Render failures bubble up as normal BEAM errors.
3. Claim on the client
Bundle a small entry point that calls glamour/client.claim/2
(or /3
with options) for each page that should reuse the SSR DOM.
import glamour/client
import glam_app/dashboard
import gleam/option
@target(javascript)
pub fn main() -> Nil {
case client.claim(dashboard.spec(), option.None) {
Ok(_) -> Nil
Error(error) -> handle_error(error)
}
}
client.Options
currently supports strict
mode. When strict: True
, a fingerprint mismatch logs an error and skips hydration; otherwise the client logs a warning and hydrates.
4. Ship the JavaScript bundle
- Compile the JS target:
gleam build --target javascript
- Copy the generated module(s) into your static assets directory. For example:
Adjust the paths to match your app name and deployment pipeline.mkdir -p priv/static/assets/glamour cp build/dev/javascript/myapp/myapp/glamour/client_bundle.mjs priv/static/assets/glamour/main.mjs rsync -a build/dev/javascript/glamour priv/static/assets/glamour/
- Reference the bundle in the
client_scripts
list so the browser loads it:<script type="module" src="/assets/glamour/main.mjs"></script>
Source Layout
src/glamour/*
– Target-agnostic modules used on both Erlang and JavaScript (server rendering, fingerprints, spec helpers).src-js/glamour/*
– JavaScript-only modules (client.gleam
,dom.gleam
,dom.ffi.mjs
). Keeping them insrc-js
means you do not need to move files between targets; Gleam automatically picks the right version during compilation.
Contracts & Assumptions
- Your Lustre
App
must accept its model asstart_args
; Glamour passes the reclaimed model directly intolustre.start
. - The
view
used on the server must match the one supplied tolustre.start
or fingerprints will diverge. - JSON encoders/decoders must round-trip the model losslessly. If parsing fails the client logs
JsonParse
and leaves the SSR DOM untouched. - The DOM selector in
Spec.selector
must resolve to the root element rendered by the server. A missing selector logsMissingRoot
. - Glamour embeds the state as
<script type="application/json" id="{state_script_id}">
. Do not mutate or rename this node on the server. - Fingerprints rely on the serialised JSON and raw HTML fragment. Any server-side post-processing (e.g., analytics scripts) should wrap, not mutate, the
<div>
Glamour controls.
Error Handling
- Server render failures bubble up as normal exceptions; let your HTTP framework surface them or wrap the call to return a diagnostics page.
- Client errors return
Result(Nil, client.Error)
and log to the console:NotInBrowser
– Guard for server-side or test environments.MissingRoot
,MissingStateScript
,MissingFingerprint
– Misconfiguration signals.JsonParse
,LustreStart
– Data/initialisation issues.FingerprintMismatch
– Fingerprints differ; hydration fallback or strict abort.
Use the error constructors in tests to assert the correct behaviour.
Conventions
- Selector defaults to
#app
; state script id defaults toglamour-state
. - Script tags inserted by
server.Options.client_scripts
should include trailing newlines, matching how Gleam concatenates head elements. - Client bundle entry points live under
@target(javascript)
modules (e.g.,src-js/your_app/glamour/client_bundle.gleam
). - When incubating inside another project, keep build artefacts in
priv/static/assets/glamour/
so they can be served by Mist or Plug.
Example: MyApp Admin
A typical integration renders admin routes with Glamour:
- Server specs:
src/myapp/glamour/login.gleam
,src/myapp/glamour/admin.gleam
- Client bundle:
src-js/myapp/glamour/client_bundle.gleam
→/assets/glamour/main.mjs
- Your HTTP handler returns the Glamour-produced HTML document.
Run the developer server with:
gleam run --target erlang --module main -- server
Testing
Server-side tests:
gleam test --target erlang
Client-side behaviour can be smoke-tested by loading the bundled app in a browser; upcoming work will add harness tests around client.claim
.
Roadmap
- Streaming SSR support (
Options.stream
) - Dev overlay with pretty error rendering
- Hex package metadata & publish checklist
- Production-ready asset pipeline (tree-shaken JS bundle)
Released under the MIT licence.