Skip to content

Why ruact?

If you already use Hotwire, or you’re weighing Inertia.js or a Next.js frontend over a Rails API, this page is the comparison — trade-offs included. Two ground rules, so you can trust it:

  • Every code block is a byte-for-byte excerpt of a runnable demo in the ruact repository (docs/examples/why-ruact/). CI boots the ruact side of each pair, syntax-checks the counterpart side, and fails if the page and the demos drift apart.
  • Each section says what the other tool does well — including the things ruact does not do. The problem ruact targets is a pattern (hand-written JSON layers, cross-file drift), not any of these projects.

Complementary, not a substitute. Hotwire and ruact answer different questions. Hotwire asks: how much interactivity can HTML-over-the-wire give you with zero build step? The answer is: a lot — Turbo 8 morphing, Streams and Frames cover most list-and-form UIs without any JavaScript of your own. If your views are ERB and your interactivity is sprinkles, Hotwire is the shortest path, and nothing on this page argues otherwise.

ruact asks a different question: when your UI layer is React — client state, existing React components, the React ecosystem — can the server side stay plain Rails? The two coexist in one app: ruact only activates for RSC-rendered pages, and Turbo keeps driving the rest (see Turbo and Hotwire coexistence).

The same outcome on both sides: a button that increments a server-side count and shows the new value without a reload.

The Turbo side — the controller stays ordinary; the response is a turbo_stream view that replaces the button’s frame with fresh server HTML:

app/controllers/likes_controller.rb
class LikesController < ApplicationController
def index
@count = Like.count
end
def create
Like.create!
@count = Like.count
# Turbo picks app/views/likes/create.turbo_stream.erb automatically.
end
end
app/views/likes/_button.html.erb
<%= turbo_frame_tag "like-button" do %>
<%= button_to "#{count}", likes_path %>
<% end %>
app/views/likes/create.turbo_stream.erb
<%= turbo_stream.replace "like-button" do %>
<%= render "likes/button", count: @count %>
<% end %>

The ruact side — the same controller with one added line; the button is a React component that calls the routed action by name and keeps the count in client state:

app/controllers/likes_controller.rb
class LikesController < ApplicationController
include Ruact::Server # non-GET routed actions become callable from React
def index
@count = Like.count
end
def create
Like.create!
@count = Like.count # the caller's `await createLike()` resolves { count: … }
end
end
app/views/likes/index.html.erb
<h1>One button, one count</h1>
<LikeButton count={@count} />
app/javascript/components/LikeButton.jsx
"use client";
import { useState } from "react";
import { createLike } from "@/.ruact/server-functions";
export function LikeButton({ count: initial }) {
const [count, setCount] = useState(initial);
async function like() {
const result = await createLike(); // POST /likes — the routed action
setCount(result.count);
}
return <button onClick={like}>{count}</button>;
}

Both deliver the outcome. Turbo asks for a turbo_stream view and gives you zero build step. ruact asks for a build step (Vite) and gives you a real React component fed by the same controller — worth it when the button lives inside a React tree, not worth it for a lone counter. Pick per page, not per app.

Inertia solved a real problem first: pass controller data to a React (or Vue, or Svelte) page as props, with no hand-written API layer in between. ruact inherits that goal directly — and its validation-errors round-trip is deliberately Inertia-style, because Inertia’s “redirect back with errors” pattern is the right shape for Rails.

The differentiator is the wire: native Flight vs a proprietary protocol. Inertia invented a page object — a component name plus a props JSON payload — and the client mounts that one component. ruact serializes the React Flight wire format, the protocol React itself defines for Server Components. That is not an abstract purity point; it changes what the wire can carry:

  • Server-rendered markup anywhere in the tree. An ERB template ships as React elements, so a page is a mixed tree — server-rendered markup wrapping client components — instead of one client component receiving props (the demo below shows exactly this).
  • <Suspense> boundaries in ERB, serialized natively on the wire, with a streamed fallback when streaming is on.
  • Server queries: useQuery accessors typed from the query method’s keyword arguments, generated from the route table.

The same outcome on both sides: a server-fed list page with one piece of client interactivity.

The Inertia side:

app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
# The page-object protocol: a component name plus a props JSON payload.
# Everything below the layout renders on the client, from these props.
render inertia: "Posts/Index", props: {
posts: Post.order(created_at: :desc).map { |p| p.as_json(only: %i[id title]) }
}
end
end
app/frontend/pages/Posts/Index.tsx
import { useState } from "react";
type Post = { id: number; title: string };
// The page component IS the page: heading, list, everything — all of it
// client-rendered from the props JSON the controller sent.
export default function Index({ posts }: { posts: Post[] }) {
const [selectedId, setSelectedId] = useState<number | null>(null);
return (
<main>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id} onClick={() => setSelectedId(post.id)}>
{post.id === selectedId ? <strong>{post.title}</strong> : post.title}
</li>
))}
</ul>
</main>
);
}

The ruact side — the heading stays a server-rendered element; only the list is a client component, nested inside server markup:

app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.order(created_at: :desc) # a plain ivar, like any Rails page
end
end
app/views/posts/index.html.erb
<%# This ERB renders on the server and ships as React elements (Flight).
The heading stays a server-rendered element; only <PostList /> is a
client component — the page is a tree, not a single client entry point. %>
<h1>Posts</h1>
<% rows = @posts.map { |p| { "id" => p.id, "title" => p.title } } %>
<PostList posts={rows} />
app/javascript/components/PostList.jsx
"use client";
import { useState } from "react";
export function PostList({ posts }) {
const [selectedId, setSelectedId] = useState(null);
return (
<ul>
{posts.map((post) => (
<li key={post.id} onClick={() => setSelectedId(post.id)}>
{post.id === selectedId ? <strong>{post.title}</strong> : post.title}
</li>
))}
</ul>
);
}

What Inertia does well that ruact does not:

  • Adapter breadth. React, Vue and Svelte on the client; Rails, Laravel and Django (and more) on the server. ruact is Rails + React, full stop.
  • First-class SSR. Inertia server-renders pages for first paint and SEO; in v3 (current) SSR works out of the box in development. ruact has no SSR — if that’s a requirement, this is a real gap, not a footnote.
  • A mature v2/v3 feature set. Deferred props, prefetching, polling, infinite scroll (merged props), optimistic updates — shipped, documented, battle-tested. ruact has useQuery with in-flight de-duplication and opt-in auto-revalidate; it does not have Inertia’s breadth here.
  • Years of production use across a large community, with inertia_rails actively maintained, well documented, and equipped with generators.

The honest differentiator here is topology, not React quality. Next.js’ RSC implementation is more complete than ruact’s — genuinely asynchronous server components, mature streaming, and server-side rendering. If you need SSR or SEO-critical pages, Next.js delivers them and ruact does not; concede that up front and weigh it for your app.

What the Next.js + Rails API topology costs is the boundary: two codebases, two deploys, and a JSON contract written twice — once as a Rails serializer, once as a TypeScript type — kept in sync by discipline alone. The same outcome on both sides: a comment form that creates a record and shows the result.

The two-codebase side — three files, two repositories, one hand-written contract:

rails-api · app/controllers/comments_controller.rb
class CommentsController < ApplicationController
# One half of the hand-written boundary: this JSON shape and the Next.js
# `Comment` type are two copies of the same contract, synced by discipline.
def create
comment = Comment.create!(body: params.require(:body))
render json: { id: comment.id, body: comment.body }, status: :created
end
end
next · app/comments/actions.ts
"use server";
// The other half of the hand-written boundary: this type mirrors the Rails
// serializer by hand — nothing checks that the two stay in sync.
export type Comment = { id: number; body: string };
const API = process.env.RAILS_API_URL ?? "http://localhost:3001";
export async function createComment(formData: FormData): Promise<Comment> {
const res = await fetch(`${API}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: formData.get("body") }),
});
if (!res.ok) throw new Error(`Rails API answered ${res.status}`);
return (await res.json()) as Comment;
}
next · app/comments/comment-form.tsx
"use client";
import { useState } from "react";
import { createComment, type Comment } from "./actions";
export function CommentForm() {
const [posted, setPosted] = useState<Comment[]>([]);
async function submit(formData: FormData) {
const comment = await createComment(formData);
setPosted((current) => [...current, comment]);
}
return (
<form action={submit}>
<input name="body" required />
<button>Comment</button>
<ul>
{posted.map((c) => (
<li key={c.id}>{c.body}</li>
))}
</ul>
</form>
);
}

The monolith side — the controller action is the server function, and the client half of the boundary is generated from the route table:

app/controllers/comments_controller.rb
class CommentsController < ApplicationController
include Ruact::Server # POST /comments is now callable from React by name
def index
end
def create
comment = Comment.create!(body: params.require(:body))
@id = comment.id # the ivars ARE the response: { "id": …, "body": … }
@body = comment.body
end
end
app/javascript/components/CommentForm.jsx
"use client";
import { useState } from "react";
import { createComment } from "@/.ruact/server-functions";
export function CommentForm() {
const [posted, setPosted] = useState([]);
async function submit(formData) {
const comment = await createComment(formData); // no fetch, no URL, no type copy
setPosted((current) => [...current, comment]);
}
return (
<form action={submit}>
<input name="body" required />
<button>Comment</button>
<ul>
{posted.map((c) => (
<li key={c.id}>{c.body}</li>
))}
</ul>
</form>
);
}

Same form, same outcome — minus the fetch wrapper, the base URL, the second copy of the shape, and the second deploy. What you give up for that is everything the separate frontend buys: SSR/SEO, independent scaling and deploys, a frontend team working in its own repo. If those are load-bearing for you, the two-codebase topology earns its cost.

Most teams evaluating this page in 2026 have a second developer in the loop: an agent. That changes the comparison in one honest way and two structural ones.

The honest one first: agents know Inertia and Next.js by heart. Both are massively represented in training data; ruact is new and is not. An agent’s prior will reach for router.post or a fetch wrapper before it reaches for a generated accessor. ruact’s mitigation is shipped context, not hope: rails generate ruact:install emits an AGENTS.md into your app (the contract, the traps, the verify commands), and llms.txt is the same reference at a stable URL. Machine-readable diagnostics back it up: bin/rails ruact:doctor -- --json and bin/rails ruact:routes -- --json (see ruact:doctor).

The structural ones are where the architecture does work the agent cannot undo:

  • The client half is machine-written. The accessor module (app/javascript/.ruact/server-functions.ts) is generated from the route table — an agent (or a human) cannot drift a client it doesn’t write. The generated file is the ground truth an agent can cat instead of guessing accessor names.
  • Contracts fail at build/boot time, not in production. Accessor-name collisions fail codegen loudly; a component call site that omits a required prop fails at preprocess time with a did-you-mean error (__ruactContract); query params are typed from keyword arguments, so tsc catches a hallucinated parameter before any request is made.
  • Serialization is an allowlist. Only fields named in ruact_props cross to the client — an invented field raises a clean error instead of silently leaking or silently returning undefined.

None of this makes an agent write better Ruby. It narrows the surface where a plausible-looking wrong line can survive: the places Inertia and Next.js leave to convention (prop shapes, endpoint URLs, serializer sync) are places ruact either generates or checks.

If the trade-offs above read acceptable for your app, the fastest way to evaluate ruact is to run it: