Server components
off_topic works with Lustre server components — apps whose Gleam logic runs on the BEAM but whose UI lives in the browser.
How it works
When your app runs as a server component, Gleam runs on the server and the
browser renders a <lustre-server-component> element. off_topic adds a second
custom element — ot-client-runtime — alongside it. This element is the bridge:
it receives subscription start and stop instructions from the server, runs the
corresponding JavaScript handlers in the browser, and sends dispatched values
back over the wire.
Almost all built-in subscriptions work in server components. Each one has two
paths internally: a direct browser path for SPAs, and a delegated path that runs
through the client runtime when the app is on the BEAM. The only exceptions are
the ones marked JS in the API reference (e.g. Websockets and Server-sent evnets)
which only have a browser path.
Setting up
Use off_topic.application or off_topic.component as you normally would.
On the server target, the ot-client-runtime element is injected into your
view function automatically. You still need to load the client runtime script
in the host page. Serve priv/static/off-topic.mjs (or the minified variant)
from your web server and include it as a module script:
<script type="module" src="/off-topic.mjs"></script>
Alternatively, off_topic.script() returns the runtime as an inline <script>
element — useful when you don’t control the host page directly.
The subscriptions callback works identically to a browser app. Subscriptions
switch to their server path on their own:
fn subscriptions(model: Model) -> off_topic.Subscription(Msg) {
off_topic.batch([
off_topic.page_state(PageStateChanged),
off_topic.every(duration.seconds(1), False, Ticked),
])
}
Slowing things down
Some events fire very rapidly. Pointer-move events, for example, can arrive
hundreds of times per second. off_topic lets you rate-limit any subscription with
throttle or delay:
off_topic.on_pointer_move(MouseMoved)
|> off_topic.throttle(wait: duration.milliseconds(50))
throttle passes the first event through immediately, then drops the rest until
wait has elapsed. delay holds the last event in a burst and dispatches it only
once the subscription has been quiet for wait — useful for search-as-you-type:
off_topic.on_key_up(SearchQueryChanged)
|> off_topic.delay(wait: duration.milliseconds(300))
You can combine both: throttle handles the running stream while delay catches
the final value after the burst ends.
Extending with custom subscriptions and commands
The client runtime’s job is to run JavaScript on behalf of the server. It does
this through two registries on window.OffTopic: one for subscriptions (start
a listener, return a cleanup) and one for commands (fire and forget). The server
refers to handlers by name — off_topic.remote starts a named subscription,
off_topic.command fires a named command.
This is also how the built-in subscriptions work under the hood. Each one
uses lustre.is_browser() to decide which path to take: in the browser it sets
up the listener directly; on the server it calls remote with the name of the
corresponding built-in JS handler. You can follow the same pattern to write
subscriptions that work in both contexts — browser SPAs and server components —
from a single Gleam function. When running in the browser, set up the listener
yourself with from; when running on the server, delegate to a named handler
you register in window.OffTopic.
pub fn geolocation(send: fn(Float, Float) -> msg) -> off_topic.Subscription(msg) {
let decoder = {
use lat <- decode.field("lat", decode.float)
use lng <- decode.field("lng", decode.float)
decode.success(send(lat, lng))
}
off_topic.remote(name: "my-app/geolocation", with: [], run: decoder)
}
The corresponding JavaScript handler starts the watch and returns the cleanup:
window.OffTopic.subscriptions["my-app/geolocation"] = (dispatch) => {
const id = navigator.geolocation.watchPosition((pos) => {
dispatch({ lat: pos.coords.latitude, lng: pos.coords.longitude });
});
return () => navigator.geolocation.clearWatch(id);
};
If you want the same subscription to also work in a browser SPA — without going
through the bridge — use lustre.is_browser() to branch: return remote on the
server and a direct from subscription in the browser.
off_topic.command fires a one-shot JavaScript handler from an effect:
pub fn scroll_to(selector: String) -> Effect(msg) {
off_topic.command("my-app/scroll-to", [json.string(selector)])
}
Register both in window.OffTopic in the host page:
window.OffTopic ??= {};
window.OffTopic.subscriptions ??= {};
window.OffTopic.commands ??= {};
window.OffTopic.subscriptions["my-app/media-query"] = (query, dispatch) => {
const mq = window.matchMedia(query);
const handler = () => dispatch(mq.matches);
handler();
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
};
window.OffTopic.commands["my-app/scroll-to"] = (selector) => {
document.querySelector(selector)?.scrollIntoView({ behavior: "smooth" });
};
The ??= guards let your setup code and the runtime initialise in any order.
See Extending the ot-client runtime for the
full handler API, including how to dispatch DOM events and use the host element.
including
When a subscription runs through the server-component bridge, the value passed
to dispatch is serialised and sent over the wire. If you dispatch a raw
browser event, including restricts which fields are forwarded:
off_topic.on(on: off_topic.Document, event: "keydown", run: my_decoder)
|> off_topic.including(["key", "code", "ctrlKey"])
including has no effect on client-side from or element subscriptions.
The built-in on_key_* functions already call including with the correct
fields internally.