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.

Search Document