Skip to content
v0.0.8·MIT

Write Rails.
Ship React.

React Server Components for Ruby on Rails. Write ERB, pass Ruby values as props, hydrate only where you need interactivity — no rewrites, no route duplication, no Node.js in production.

app/views/posts/show.html.erb
<PostCard post={@post} author={@author} />
Ruby props → Flight → React
app/javascript/components/PostCard.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
"use client"

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>
  )
}
02 / The problem

Two taxes, no thanks.

Adopting React in a Rails app usually means paying one of two taxes. The full SPA: duplicate your routes, build an API layer, solve auth twice, maintain a second app. Or sprinkles: keep Rails but give up the React component model, its ecosystem, and typed data flow.

ruact removes the trade-off — Rails stays the single source of truth for routes, auth, and data; React is simply how your views render.

03 / How it works

ERB in, React out.

ERB
<PostCard post={@post} />
Flight payload · pure Ruby
1:I["PostCard.jsx","PostCard"]
0:["$","$L1",null,{"post":{"title":"Hello"}}]
React · hydrated
<article></article>
01

Tag it.

A PascalCase tag in ERB — <PostCard post={@post} /> — becomes a React element. Props are native Ruby values.

02

Rails serializes it.

The component tree ships as a React Flight payload, produced by a pure-Ruby implementation of the wire format. No Node.js server anywhere in production.

03

React hydrates it.

The browser renders the tree and hydrates only the components marked "use client". Navigation, redirects, and scroll restoration are derived from your Rails routes.

04 / Server functions

Your routes are already your API.

Include one module and your controller actions become typed TypeScript functions. The route verb decides exposure, the route table generates the types, and your existing before_action auth and CSRF protection keep working — because it's still just Rails handling the request.

app/controllers/posts_controller.rb
1
2
3
4
5
6
7
8
9
class PostsController < ApplicationController
  include Ruact::Server

  def create
    @post = Post.create!(post_params)
    redirect_to @post
  end
end
app/javascript/components/PostForm.tsx
1
2
3
4
5
import { createPost } from "@/.ruact/server-functions"

export function PostForm() {
  return <form action={createPost}></form>
}
app/javascript/.ruact/server-functions.tsgenerated
// AUTO-GENERATED by vite-plugin-ruact. DO NOT EDIT.
// Source: Rails route table
export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>) =
_makeServerFunction({ method: "POST", path: "/posts", segments: [] });
export const search: (params: { q: string | number | boolean | null }) => Promise<unknown> =
_makeQuery({ path: "/q/search", kind: "query" });
05 / Features

Everything derives from Rails.

Routes are the truth.

Client-side navigation, redirects, and typed accessors are all derived from rails routes — nothing is declared twice.

Flight in pure Ruby.

A full server-side implementation of React's wire format. Node.js is a build tool, not a runtime dependency.

Progressive "use client".

Server-rendered by default; opt into interactivity per component, exactly like RSC intends.

Typed queries from Ruby kwargs.

Query params are reflected from your method signatures into TypeScript — no annotations, no drift.

useQuery with in-flight dedup.

Three components mounting the same query issue one GET.

ruact_props allowlisting.

Models declare exactly which attributes cross the wire. Nothing leaks by default.

Errors that teach.

Typos get file, line, and a "did you mean?" — at template-compile time, not in the browser console.

Zero new concepts.

Controllers, actions, views, models. If you know Rails MVC, you already know ruact.

06 / Security posture

Serialize-only, by design.

The wire protocol serializes Ruby values out; it never reconstructs objects from client bytes. That single invariant places ruact outside the entire class of RSC deserialization vulnerabilities — by construction, not by patching. A built-in tripwire in ruact:doctor guards the invariant on every run. Record references cross the wire as signed, purpose-scoped, expiring tokens — never raw IDs.

Read the security model →
07 / Developer experience

Three commands to first render.

$ bundle add ruact
$ rails generate ruact:install
$ bin/dev

The installer owns the whole setup — Vite config, React entry point, bin/dev, the initializer — and ruact:doctor diagnoses your app when something's off. When you typo a component name, this is the error, at compile time:

compile error
ruact: Component "LikeButtoon" not found in manifest.
  Did you mean "posts/_like_button"?
  Did you run the Vite build? Run 'npm run build' or start the Vite dev server.

Ship your first component today.

$ bundle add ruact

Ruby 3.2+ · Rails 7.0–8.x · React 19 · Node 20+ (build only)