ruact
Real React, right in your Rails views. Write <LikeButton likes={@likes} /> in an ERB template and a React component renders, with a Ruby value passed straight in — no hand-written JSON layer, no Node process in production.
Quick start
# 1. A throwaway app to try it in
rails new myapp --skip-javascript && cd myapp
# 2. Add the gem
bundle add ruact
# 3. Write the config and an AGENTS.md (no layout of yours is edited) — then run npm install
rails generate ruact:install
# 4. Rails + Vite, one command
bin/devThat is the whole install. The Getting Started guide picks it up from here — first component, first scaffold, ruact:doctor. Already have an app? Start at step 2, then read Progressive migration — ruact renders only the controllers you include it in and leaves the rest of your views alone.
How it works
<%# app/views/posts/show.html.erb %>
<PostCard post={@post} author={@author} />// app/javascript/components/PostCard.tsx
"use client"
import { useState } from "react"
export function PostCard({ post, author }) {
const [liked, setLiked] = useState(false)
return (
<article>
<h1>{post.title}</h1>
<p>by {author.name}</p>
<button onClick={() => setLiked(!liked)}>
{liked ? "Liked" : "Like"}
</button>
</article>
)
}Capitalized tag means React. Lowercase stays HTML. "use client" is the only directive you need to learn.
Your ERB view renders on the server the way it always did, and ruact sends the result to the browser as a React tree — in the same wire format React uses for Server Components, implemented in Ruby. The data the view needs is inlined in the page, so React renders immediately, without a fetch. Node builds the bundle with Vite and is needed nowhere else.
Call Rails from React
Add one line to a controller and its routed non-GET actions become callable from React at their real routes:
class PostsController < ApplicationController
include Ruact::Server # ← the only new line
def create
@post = Post.create!(post_params)
redirect_to @post
end
# ...
endimport { createPost } from "@/.ruact/server-functions";
await createPost({ post: { title: "Hi", body: "…" } });The verb decides — there is no per-action DSL and no second endpoint. The export name is derived from the route (posts#create → createPost), and ruact's Vite plugin regenerates the module whenever your routes change. What the call resolves is decided by the action you already wrote: this one redirects, so ruact follows the redirect and the call resolves null; an action that assigns @post instead resolves { post: … }, through the same ruact_props allowlist as everything else. Reading works the same way: a Ruact::Query class mounted with ruact_queries draws one named GET route per public method, and React reads it with useQuery. Both are documented in Server functions & queries.
What you get
Every item below is shipped in this gem at v0.0.11:
-
ERB as server components —
include Ruact::Controller, then use your components by name in the views you already have: capitalized is React, lowercase stays HTML. Docs -
"use client"— the one directive that marks a file as client-side. The bundled Vite plugin scans for it and writes the manifest. Docs -
Server functions and queries —
include Ruact::ServerandRuact::Query+useQuery, both reachable through a module generated from your route table. A query's declared keywords become an exact TypeScript signature; an action's accessor is typed to accept an object or aFormDataand resolve whatever the action answers. Docs -
An opt-in allowlist for props — by default a model prop is serialized with
as_json, so every attribute crosses and the log says so.include Ruact::Serializable+ruact_props :id, :titlemakes it an allowlist, andstrict_serialization(on in production) turns the permissive path into an error rather than a warning. Docs -
Validation errors round-trip —
ruact_errors(record)hands React{ title: ["can't be blank"] }without a serializer. Docs -
Signed record references —
Ruact.signed_global_id(record, for:, expires_in:)out,Ruact.locate_signed(token, for:)back in; a tampered token is a400, not a lookup. Docs - Client-side navigation — link interception, scroll restoration and redirect-after-POST, derived from your Rails routes. Docs
-
A CRUD generator —
rails generate ruact:scaffold Post title:string body:textdelegates the model, migration and route to Rails' ownresourcegenerator, then adds the ruact layer. Plain semantic HTML by default;--shadcnopts into the Tailwind/shadcn path. It does not run migrations —rails db:migrateis still yours. Docs -
bin/rails ruact:doctor— nine checks over the manifest, Vite, the layout, the client-component CSS and streaming; exits1when one fails. Docs -
One runtime dependency —
nokogiri. Rails itself is not a declared dependency of this gem.
AI tools and coding agents
The only line you have to add to an AI-generated React component is "use client" at the top of the file. One thing to check rather than add: the component needs a PascalCase named export, because that name is the tag you write in ERB.
- Generate the component with whatever AI tool you already use.
- Save it as
app/javascript/components/MyComponent.tsx. - Add
"use client"at the top if it is not already there. - Call
<MyComponent />from ERB.
That is the whole adaptation, and it holds for any tool that outputs standard React components — nothing here is pinned to one vendor. The worked example, the traps and the real error messages are on AI Tools & Agents.
For agents driving the app rather than writing one component: rails generate ruact:install writes an AGENTS.md into your app, ruact.dev/llms.txt serves the same context to tools that fetch from the web, and bin/rails ruact:doctor -- --json / bin/rails ruact:routes -- --json emit machine-readable output (experimental — schema_version: 0, and the -- separator is required).
Where ruact fits
A good fit: a Rails monolith that needs real React on real screens — dashboards, editors, admin tools, anything with interaction that ERB alone makes painful — with one team shipping both sides and no appetite for a second application to keep in sync.
Not a fit yet: pages that must render without JavaScript, or content search engines need to read out of the HTML. ruact renders client-side from a payload inlined in the page; there is no server-rendered HTML for the React tree. That is a real gap, not a roadmap wink — if the page is your SEO surface, keep it in ERB (the two coexist in the same app, per-view) or use something that server-renders.
Compatibility
| Version | Where that comes from | |
|---|---|---|
| Ruby | >= 3.2 | the gemspec's required_ruby_version
|
| Rails | tested against 7.0, 7.1, 7.2 and 8.0 | every commit runs the full CI matrix; the gemspec sets no Rails bound |
| React | 19.x | the package.json the install generator writes |
| Node.js | >= 20 | the build only — ruact runs no Node process in production |
Documentation
Everything lives at ruact.dev:
-
Getting Started — from
rails newto a rendered component - Why ruact? — where it sits next to Hotwire and Inertia
- Server functions & queries — the full request/response contract
- Progressive migration — adopting it one controller at a time
- Testing — render assertions on the server side
- Changelog — also published at ruact.dev/docs/changelog
Contributing
Bug reports and pull requests are welcome at github.com/luizcg/ruact/issues.
Setup, the checks a PR runs, and how to try your working tree in a real Rails app: CONTRIBUTING.md.
Release process: RELEASING.md. Security policy and private reporting: SECURITY.md.
License
MIT — see LICENSE.txt.