Rails Apps on GitHub Pages
Without further ado, the apps which speak for themselves:
- Blog - the obligatory Rails blog demo
- Chat - a classic chat application
- Notes - React Server FUnctions-style path helpers with search
- Photo Gallery - take pictures using your webcam
- Workflow Builder - graphically create a workflow using reactflow.
Each demo is small and focused. Each uses Action Cable, Hotwire, and Tailwind. Open two windows, add a comment to an article in one, see the comment instantly appear in the second window. Close the browser. Come back later. Your changes persist. These are full stack applications.
For more information, see the documentation, for the impatient, create-blog is the script that creates the first demo. Run the app that is produced in Rails, in your browser, on the edge, or even your phone or in your Dock/Taskbar - see the full list of targets for details.
And if you have not been following along, Rails on V8 Isolates and Rails to the Edge and Beyond introduce Juntos and provide additional context.
What follows is a deeper dive into what's new.
New Targets
Electron, Tauri, and Capacitor can take a JavaScript app and make it appear in your dock, taskbar, app launcher, or mobile device—with full access to native OS features like the file system, system tray, and camera.
Here's an excerpt of the Stimulus controller in the Photo Gallery app:
import [
"Camera",
"CameraResultType",
"CameraSource"
], from: "@capacitor/camera" # Pragma: capacitor
class CameraController < Stimulus::Controller
def takePhoto
if defined?(Camera)
# Native mobile - use Capacitor Camera plugin
takePhotoCapacitor()
else
# Browser - use getUserMedia
takePhotoBrowser()
end
end
def takePhotoCapacitor
# Camera is guaranteed to exist when building for capacitor target
result = await Camera.getPhoto(
quality: 80,
allowEditing: false,
resultType: CameraResultType.Base64,
source: CameraSource.Camera
)
handlePhoto(result.base64String)
end
def takePhotoBrowser
# Start video stream from webcam
stream = await navigator.mediaDevices.getUserMedia(
video: { facingMode: "user" }
)
videoTarget.srcObject = stream
videoTarget.classList.remove("hidden")
captureBtnTarget.classList.remove("hidden")
formTarget.classList.add("hidden")
end
end
Syntax is Ruby with direct access to JavaScript platform APIs, conditional imports, and taking different execution paths based on what is defined in the environment.
All built on Vite. Vite provides Hot Module Replacement. Fast rebuilds. Tree shaking. Minification. Fingerprinted assets. Source maps that work. A vast ecosystem of Vite plugins.
Full Jamstack w/ Server Functions
Rails offers several ways to integrate React, including the react_component helper from React on Rails or mounting components via a Stimulus controller. Juntos—the runtime powering these demos—provides a deeper and richer alternative.
For starters, you will be able to drop a JSX file into your app/views folder and use it directly. Or you can code the same thing in Ruby and unlock more.
Here's an excerpt of an RBX view in the Workflow demo:
import WorkflowCanvas from 'components/WorkflowCanvas'
import JsonStreamProvider from '/lib/JsonStreamProvider.js'
import [Node], from: '/app/models/node.js'
import [Edge], from: '/app/models/edge.js'
export default def Show(workflow:)
# Get nodes and edges from workflow (preloaded by controller)
flow_nodes = ...
flow_edges = ...
handle_add_node = ->(position) {
# Create node directly in the database
Node.create(
label: "New Node",
node_type: "default",
position_x: position.x,
position_y: position.y,
workflow_id: workflow.id
)
}
%x{
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold">{workflow.name}</h1>
<a href="/workflows" className="text-blue-600 hover:underline">
Back to Workflows
</a>
</div>
<JsonStreamProvider stream={"workflow_#{workflow.id}"}>
<WorkflowCanvas
initialNodes={flow_nodes}
initialEdges={flow_edges}
onSave={handle_save}
onAddNode={handle_add_node}
onAddEdge={handle_add_edge}
onDeleteNode={handle_delete_node}
onUpdateNode={handle_update_node}
/>
</JsonStreamProvider>
</div>
}
end
Ruby syntax, React semantics. Add hooks as needed. But note the imports from the model, the JsonStreamProvider, and the call to Node.create.
When the deployment target is the browser, access to models is direct. But when the deployment target is Node.js or Cloudflare, RPC calls get the job done. Next.js with React Server Functions points the way. RBX views compile to React on the client, and the call to the model becomes an RPC call to the Active Record model, with JSON being passed both ways.
While React Server Functions are the inspiration for the implementation, the developer experience here is modeled after Rails, not React. No "use server". Just direct calls into the models.
Now having your UI pelt the server with database calls might be fine for demos or personal information apps that you attach to your dock or taskbar, they are not the right level of abstraction for most business logic. Path helpers now support HTTP methods for calling controller actions. notes_path.get(q: "search") fetches JSON, notes_path.post(note: {...}) creates a record, and note_path(id).delete removes one. The same API works whether running in-browser (direct controller call) or against a server (Fetch API).
Here's an excerpt from the Notes demo showing path helper RPC in action:
# React is auto-imported when JSX is used (React 17+ style)
import [useState, useEffect], from: 'react'
import [notes_path, note_path], from: '../../../config/paths.js'
export default def Index()
notes, setNotes = useState([])
searchQuery, setSearchQuery = useState("")
# Load notes on mount and when search changes
useEffect(-> {
notes_path.get(q: searchQuery).json { |data| setNotes(data) }
}, [searchQuery])
handleCreate = -> {
notes_path.post(note: { title: "Untitled", body: "" }).json do |note|
setNotes([note, *notes])
end
}
handleDelete = ->(id) {
note_path(id).delete.then { setNotes(notes.filter { |n| n.id != id }) }
}
# ... render JSX
end
Just like React Server Functions let you call server functions from the client, Juntos lets you call ActiveRecord and controller actions the same way—transparently, with the framework handling the boundary.
Current Status
Apologies in advance, this section is a status report/disclaimer.
All of this is new, so nothing is battle tested or production hardened as of yet. Additionally, this is mostly an 80/20 implementation of Rails patterns: it implements perhaps 20% of what Rails does, focusing initially on what you need 80% of the time.
Example: at the moment, it supports migrations, but only up, but not down, redo, step, version, reset, or status. Not that those are hard, they just aren't implemented yet.
Example: Arel is huge. Juntos supports common Arel chainable methods like all, where, order, limit, offset, select, distinct, includes, not, and or, along with terminal methods like find, find_by, first, last, count, exists?, and pluck; but does not yet support joins, group, having, eager_load, preload, reorder, unscope, lock, or complex Arel predicates.
And the options for transpiling Ruby to JS are more of a spectrum than a clear choice. Ruby WASM and Opal occupy one end with full method_missing capability, but the result runs in a sandbox with an FFI interface to JavaScript. At the other extreme, you have Ruby syntax with JavaScript semantics. Ruby2JS lets you pick where you want to be on this spectrum, providing an omakase preset that works for most and options to tailor it to taste.
There also are a large number of database and target platforms. Some combinations have only been tried once or twice, many not at all.
The testing effort is immense, but extremely parallelizable. If people test what they would most like to see working and report back any issues they might find, that would be very much appreciated.
Summary
The JavaScript ecosystem did the heavy lifting, Ruby2JS is just a bridge.
The real stars here are Rails and Vite. Rails for its heavy use of conventions and metaprogramming allowing you to describe your intent declaratively rather than specifying how your application is to be implemented. And Vite which transforms everything including JSX, TypeScript, Elm, and ClojureScript to JavaScript then tree shakes, bundles, and produces source maps.
The rest is borrowing design ideas, not only from Rails itself but from JavaScript frameworks like Next.js.
Write a single app; deploy it unchanged to your browser, phone, or the edge, and even split it into a front end with an API-only backend. Mix and match, and use Stimulus controllers if that's what you prefer.
At this point, the vision is real. What remains is mostly more implementation and testing.
Ruby2JS is open source: github.com/ruby2js/ruby2js