Chat Demo
A real-time chat room demonstrating Hotwire patterns—Turbo Streams broadcasting and Stimulus controllers written in Ruby.
Table of Contents
- Create the App
- Run with Rails
- Run in the Browser
- Run on Node.js
- Deploy to Vercel
- Deploy to Fly.io
- Deploy to Deno Deploy
- Deploy to Cloudflare
- The Code
- WebSocket Implementation by Platform
- What This Demo Shows
- Extending the Demo
- Next Steps
Create the App
curl -sL https://raw.githubusercontent.com/ruby2js/ruby2js/master/test/chat/create-chat | bash -s chat
cd chat
This creates a Rails app with:
- Message model — username, body, timestamps
- Real-time broadcasting — messages appear instantly for all users
- Stimulus controller — auto-scroll written in Ruby
- Turbo Streams —
broadcast_append_to,broadcast_remove_to
Run with Rails
bin/rails db:prepare
bin/dev
Open http://localhost:3000 in multiple browser tabs. Send messages. Watch them appear everywhere.
The Ruby Stimulus controller transpiles automatically at boot. Edit the .rb file, refresh, and see your changes—no restart needed.
Run in the Browser
bin/juntos dev -d dexie
Same chat interface, running entirely in your browser. The browser target uses BroadcastChannel—messages sync between tabs on the same device.
Run on Node.js
bin/juntos db:prepare -d sqlite
bin/juntos up -d sqlite
Open multiple browser tabs. Messages broadcast via WebSocket to all connected clients.
Deploy to Vercel
For Vercel deployment, you need a universal database and a real-time service. Juntos supports two approaches:
Option 1: Supabase (Database + Real-time)
Supabase provides both PostgreSQL and real-time in one service:
bin/juntos db:prepare -d supabase
bin/juntos deploy -d supabase
Juntos automatically uses Supabase Realtime for Turbo Streams broadcasting.
Setup:
- Create a Supabase project
- Add environment variables to Vercel:
SUPABASE_URL— Project URLSUPABASE_ANON_KEY— Anonymous keyDATABASE_URL— Direct Postgres connection (for migrations)
Option 2: Any Database + Pusher
For other databases (Neon, Turso, PlanetScale), use Pusher for real-time:
bin/juntos db:prepare -d neon
bin/juntos deploy -d neon
Juntos detects Vercel + non-Supabase database and configures Pusher automatically.
Setup:
- Create database (Neon, Turso, or PlanetScale)
- Create a Pusher app (free tier: 200K messages/day)
- Add environment variables to Vercel:
DATABASE_URL(or database-specific vars)PUSHER_APP_ID,PUSHER_KEY,PUSHER_SECRET,PUSHER_CLUSTER
Why Two Options?
Vercel’s serverless functions can’t maintain WebSocket connections. Both solutions use HTTP-based approaches:
- Supabase Realtime — Built into Supabase, uses their WebSocket infrastructure
- Pusher — Third-party service (Vercel’s recommended approach for real-time)
Deploy to Fly.io
Fly.io is ideal for the chat demo—it has native WebSocket support, so Turbo Streams broadcasting works without Pusher or other external services:
juntos db:create -d mpg
# Start proxy in separate terminal: fly mpg proxy chatapp_production
juntos db:prepare -d mpg
juntos deploy -t fly -d mpg
Setup:
- Install Fly CLI and run
fly auth login - The
db:createcommand creates your app, database, and connects them automatically
No Pusher configuration needed. Messages broadcast via WebSocket to all connected clients, just like Rails with Action Cable.
Deploy to Deno Deploy
juntos db:prepare -d neon
juntos deploy -t deno-deploy -d neon
Like Vercel, Deno Deploy requires Pusher for real-time (or Supabase Realtime if using Supabase).
Setup:
- Install deployctl
- Create a Neon database
- Create a Pusher app
- Set environment variables in Deno Deploy dashboard
Deploy to Cloudflare
juntos db:prepare -d d1
juntos deploy -d d1
The db:prepare command creates the D1 database (if needed), runs migrations, and seeds if fresh.
Your model’s broadcast_append_to calls route through Durable Objects. Subscribers on different edge instances receive the update. Real-time, globally distributed.
The Code
Message Model
Try it — edit the Ruby to see how the model transpiles:
# app/models/message.rb
class Message < ApplicationRecord
validates :username, presence: true
validates :body, presence: true
after_create_commit do
broadcast_append_to "chat_room",
target: "messages",
partial: "messages/message",
locals: { message: self }
end
after_destroy_commit do
broadcast_remove_to "chat_room",
target: "message_#{id}"
end
end
The after_create_commit callback broadcasts new messages to all subscribers. The after_destroy_commit removes deleted messages from everyone’s view.
View Subscription
Try it — edit the ERB to see how views transpile:
<%# app/views/messages/index.html.erb %>
<div data-controller="chat">
<%= turbo_stream_from "chat_room" %>
<div id="messages">
<%= render @messages %>
</div>
<%= form_with model: Message.new,
data: { action: "turbo:submit-end->chat#clearInput" } do |f| %>
<%= f.text_field :username, placeholder: "Name" %>
<%= f.text_field :body, placeholder: "Message",
data: { chat_target: "body" } %>
<%= f.submit "Send" %>
<% end %>
</div>
The turbo_stream_from helper establishes the WebSocket subscription. New messages append to #messages automatically. The turbo:submit-end action clears the input field after each message is sent.
Message Partial
Try it — partials transpile the same way:
<%# app/views/messages/_message.html.erb %>
<div id="<%= dom_id(message) %>" data-chat-target="message">
<span><%= message.username %></span>
<span><%= message.body %></span>
</div>
The data-chat-target="message" attribute tells Stimulus to track this element. When new messages are appended, Stimulus calls messageTargetConnected.
Stimulus Controller in Ruby
Try it — edit the Ruby code to see how it transpiles:
class ChatController < Stimulus::Controller
# Auto-scroll to show the new message
def messageTargetConnected(element)
element.scrollIntoView()
end
# Clear the message input after form submission
def clearInput
bodyTarget.value = ""
bodyTarget.focus()
end
end
The controller auto-scrolls the chat when new messages arrive and clears the input after each message is sent. The messageTargetConnected callback is called by Stimulus whenever a new element with data-chat-target="message" is added to the DOM—this is the idiomatic Stimulus pattern for reacting to dynamic content.
Key transpilations:
bodyTargetusage auto-generatesstatic targets = ["body"]messageTargetConnectedstays as-is (Stimulus convention)bodyTargetbecomesthis.bodyTarget(Stimulus target accessor)self.elementbecomesthis.element
WebSocket Implementation by Platform
| Platform | Implementation | Scope |
|---|---|---|
| Rails | Action Cable | Server-managed connections |
| Browser | BroadcastChannel |
Same-origin tabs only |
| Node.js | ws package |
All connected clients |
| Bun | Native WebSocket | All connected clients |
| Deno | Native WebSocket | All connected clients |
| Fly.io | Native WebSocket | All connected clients |
| Vercel | Pusher or Supabase Realtime | All connected clients |
| Deno Deploy | Pusher or Supabase Realtime | All connected clients |
| Cloudflare | Durable Objects | Global edge distribution |
Browser Limitations
The browser target uses BroadcastChannel, which only works between tabs on the same device. This is ideal for:
- Local development
- Offline-first apps
- Single-user scenarios
For cross-device real-time, deploy to a server target.
Cloudflare Durable Objects
Cloudflare Workers are stateless—each request might hit a different instance. WebSockets need state to track subscriptions.
Juntos uses Durable Objects as coordinators:
- Client connects to
/cable - Worker routes to the
TurboBroadcasterDurable Object - Durable Object manages WebSocket connections
- Broadcasts fan out to all subscribers
The Durable Object uses hibernation for cost efficiency—connections stay open but don’t consume CPU while idle.
What This Demo Shows
Turbo Streams Broadcasting
broadcast_append_to— add content to a targetbroadcast_remove_to— remove content from a targetturbo_stream_from— subscribe to a channel
Stimulus in Ruby
Stimulus::Controllerbase classself.targetsfor target definitionsconnectlifecycle methodtargetConnectedcallbacks for dynamic content- Direct JavaScript object access (
self.element)
Format Negotiation
Try it — see how respond_to transpiles:
# app/controllers/messages_controller.rb
def create
@message = Message.new(message_params)
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to messages_path }
end
end
end
The controller responds differently based on the request’s Accept header.
Extending the Demo
Ideas for building on this foundation:
- User presence — show who’s online
- Typing indicators — broadcast “user is typing”
- Message editing — use
broadcast_replace_to - Rooms — multiple chat channels
- Private messages — user-scoped streams
The Hotwire patterns scale from this simple demo to complex real-time applications.
Next Steps
- Read Hotwire for the full reference
- Try the Blog Demo for CRUD patterns
- See Database Overview for database setup guides
- See Fly.io Deployment for native WebSocket support
- See Vercel Deployment for edge deployment
- See Deno Deploy for Deno-native edge
- See Cloudflare Deployment for Durable Objects