Rung 1 — the metaphor
Have your cake and eat it too.
Real React, right in your Rails views — like a partial. Your Ruby data flows straight in. No API, no second repo, no Node in production.
<h1><%= @post.title %></h1>
<LikeButton postId={@post.id} />My First Post
<h1> from ERB · React button, hydrated
That @post.id? It's your Rails data — straight into a React prop. No serializer, no JSON layer to write.
Rung 2 — the data
Your @post — straight into React.
No JSON endpoint. No serializer to hand-write. Just declare what's safe to send.
class Post < ApplicationRecord include Ruact::Serializable ruact_props :id, :title, :body end
<PostCard post={@post} /> ↓ the browser receives only { id, title, body } — nothing else.
@post and only id, title, body cross the wire — password_digest, tokens and internal flags never leave your server. Safe by construction, loud when you typo.The alternative? “Render JSON instead of HTML now” — a serializer layer you write and keep in sync. Here, there isn't one. You allowlist; ruact does the rest.
The central wedge
Your database is the state.
No client-side store to keep in sync. When data changes, the server re-renders — you never play “which is right, the server or the client?”
The trap — SPA + API
post.likesout of sync · Δ2
⇅ sync by hand
⇅ sync by hand
“Three levels of truth — which one is right?”
ruact
post.likes
│ renders ▼
└▶ mutate → revalidate → re-render from source
One home. No drift.
await createPost(formData); // mutation + refresh from Rails, in one awaitNo cache to invalidate. No useEffect to re-run. The page shows what Rails knows — because that's the only place it's stored.
The deploy story
Real React. Your prod stack is still just Rails.
The React tree is built on the server, in Ruby — no Node sidecar. In production, nothing Node runs.
"use client" import { useState } from "react" export function LikeButton({ postId }) { const [liked, setLiked] = useState(false) return ( <button onClick={() => setLiked(!liked)}> {liked ? "❤️ Liked" : "🤍 Like"} </button> ) }
web: bundle exec puma
↑ one process.
no node, ever.That's real React 19 — the actual library, the actual ecosystem. Not a DSL, not a subset. Bundled once by Vite at deploy (Node at build time, like any JS app); at runtime, only Rails.
Built to be driven — by you, and by your agent.
The same one-repo, one-router simplicity that keeps you in flow is what a coding agent needs to get it right the first time.
See the agent setup →Rung 3 — the real app
See it work. Then run it yourself.
A real Rails app. A React datatable. Live search over your data — no API, no reload.
That search? A ruact server query — your Ruby data, straight in. No JSON endpoint, no client cache.
Try it in your own app.
From rails new to a full CRUD with a searchable React table — in a few minutes.
rails new myapp --skip-javascript && cd myapp bundle add ruact rails generate ruact:install rails generate ruact:scaffold Post title:string body:text rails db:migrate bin/dev
No sign-up. No account. No Node server to provision. bundle remove ruact and it's gone.