Hotwire / Turbo Streams
Hotwire sends HTML over the wire instead of JSON. Turbo Streams let the server push HTML fragments that the browser splices into the page: append a row, replace a region, remove a node.
Mercure is a clean transport for Turbo Streams. No extra dependency on the server; on the client, three lines of glue.
#Subscribe to Hotwire Turbo Streams via Mercure
// Subscribe to Hotwire Turbo Streams via Mercure
import { connectStreamSource } from "@hotwired/turbo";
const url = new URL("https://hub.example.com/.well-known/mercure");
url.searchParams.append("match", "https://example.com/posts/42/comments");
const es = new EventSource(url);
connectStreamSource(es);
Turbo treats every SSE message as a Turbo Stream and applies it. The data of each message is HTML in the Turbo Stream format.
#Publish Turbo Streams to Mercure
The server publishes Turbo Stream HTML on the matching topic:
<!-- Publish Turbo Streams to Mercure -->
<turbo-stream action="append" target="comments">
<template>
<li id="comment_99">Great post!</li>
</template>
</turbo-stream>
# Publish Turbo Streams to Mercure
curl -X POST https://hub.example.com/.well-known/mercure \
-H "Authorization: Bearer $JWT" \
-d 'topic=https://example.com/posts/42/comments' \
--data-urlencode 'data=<turbo-stream action="append" target="comments"><template><li id="comment_99">Great post!</li></template></turbo-stream>'
In Rails:
# In a controller after the comment is created
Mercure.publish(
topic: post_comments_url(@post),
data: render_to_string(partial: "comments/turbo_append", locals: { comment: @comment }),
)
In Symfony with the Mercure component:
// Publish Turbo Streams to Mercure
$update = new Update(
$this->generateUrl('comments', ['post' => $post->getId()]),
$this->renderView('comments/_append.html.twig', ['comment' => $comment]),
);
$hub->publish($update);
#Disconnecting a Turbo Stream source from Mercure
// Disconnecting a Turbo Stream Source from Mercure
import { disconnectStreamSource } from "@hotwired/turbo";
es.close();
disconnectStreamSource(es);
Always disconnect when the page (or component) using the stream goes away.
#A Stimulus controller for Mercure Turbo streams
Wire the stream into a <div> and let Stimulus manage its lifecycle:
// turbo_stream_controller.js
import { Controller } from "@hotwired/stimulus";
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo";
export default class extends Controller {
static values = { url: String };
connect() {
this.es = new EventSource(this.urlValue, { withCredentials: true });
connectStreamSource(this.es);
}
disconnect() {
this.es.close();
disconnectStreamSource(this.es);
}
}
<!-- A Stimulus Controller for Mercure Turbo Streams -->
<div
data-controller="turbo-stream"
data-turbo-stream-url-value="https://hub.example.com/.well-known/mercure?match=https%3A%2F%2Fexample.com%2Fposts%2F42%2Fcomments"
>
<ul id="comments">
<!-- server-rendered initial state -->
</ul>
</div>
The stream goes live on connect (when the element enters the DOM) and shuts down on disconnect. Turbo Drive navigations don't drop the stream in unexpected ways.
#Private Turbo Streams over Mercure
For per-user or per-team streams (a kanban board only the team's members can see), authorize via cookie:
// Private Turbo Streams over Mercure
{
"mercure": {
"subscribe": [
{ "match": "https://example.com/teams/acme/board" },
{ "match": "https://example.com/users/42/notifications" },
],
},
}
Publish the Turbo Stream as a private update (private=on). Only authorized subscribers receive it.
The cookie should be set during the page render (not in JavaScript) so that EventSource(url, { withCredentials: true }) already has it. See Authorization.
#Many streams, one connection
A page often watches several streams: comments, presence, notifications, a sidebar counter. Use match* parameters on a single connection rather than spinning up four EventSources:
// Many streams, one connection
const url = new URL("https://hub.example.com/.well-known/mercure");
url.searchParams.append("match", "https://example.com/posts/42/comments");
url.searchParams.append("match", "https://example.com/posts/42/votes");
url.searchParams.append(
"matchURLPattern",
"https://example.com/users/:id/notifications",
);
Turbo applies whichever stream is in the data; the target attribute on each <turbo-stream> element decides where it lands.
#Hotwire and Mercure rendering performance
Turbo Stream HTML is just bytes; no different from JSON for the hub. The cost is on the rendering side: every connected user re-runs morphdom (or whichever DOM patcher Turbo uses) on each message. Avoid publishing 100 streams a second to a page; coalesce on the server, or fall back to a JSON delta you render yourself.
#Hotwire native (iOS / Android)
The same Mercure topic works for Hotwire Native apps; the bridge ships an SSE consumer. Use the platform's EventSource-equivalent (or fetch-event-source) and feed bytes into the Turbo Native stream renderer.
#Next steps for Hotwire over Mercure
- Subscribing:
EventSourcedetails. - Authorization: cookies for browsers.
- Collaborative editing: for editing scenarios where Turbo Streams aren't enough.