Skip to content

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