Project

ruact

0.0
The project is in a healthy, maintained state
ruact renders React components straight from your ERB views: write a PascalCase tag, pass a Ruby value as a prop, and React hydrates it in the browser over the Flight wire format. Server functions and queries are drawn from your existing route table, so there is no hand-written JSON layer to keep in sync and no Node process in production.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 1.15
 Project Readme

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.

CI Gem Version codecov

An ERB template holding a <LikeButton likes={@likes} /> tag, and the "use client" React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. Children are then put inside the tag — the JSX habit — and the next request stops server-side with Ruact::ChildrenNotSupportedError, which names the component, the template file and line, and the fix. The children come out again and the page renders.

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/dev

That 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
  # ...
end
import { 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::Server and Ruact::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 a FormData and 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, :title makes it an allowlist, and strict_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 a 400, 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:text delegates the model, migration and route to Rails' own resource generator, then adds the ruact layer. Plain semantic HTML by default; --shadcn opts into the Tailwind/shadcn path. It does not run migrations — rails db:migrate is still yours. Docs
  • bin/rails ruact:doctor — nine checks over the manifest, Vite, the layout, the client-component CSS and streaming; exits 1 when 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.

  1. Generate the component with whatever AI tool you already use.
  2. Save it as app/javascript/components/MyComponent.tsx.
  3. Add "use client" at the top if it is not already there.
  4. 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:

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.