Full CRUD with Scaffold
This guide builds a complete CRUD application for a Post resource using ruact. By the end you will have index, show, new, create, edit, update, and destroy — all as React Server Components.
1. Generate the scaffold
rails generate scaffold Post title:string body:text
rails db:migrate2. Include the controller concern
Edit the generated controller or ApplicationController:
class ApplicationController < ActionController::Base
include Ruact::Controller
end3. Update the controller actions
Replace render with ruact_render in each action. Redirects stay as normal Rails redirects — ruact handles them automatically on the client side.
class PostsController < ApplicationController
before_action :set_post, only: %i[show edit update destroy]
def index
@posts = Post.all.order(created_at: :desc)
ruact_render
end
def show
ruact_render
end
def new
@post = Post.new
ruact_render
end
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post, notice: "Post created."
else
ruact_render template: "posts/new"
end
end
def edit
ruact_render
end
def update
if @post.update(post_params)
redirect_to @post, notice: "Post updated."
else
ruact_render template: "posts/edit"
end
end
def destroy
@post.destroy
redirect_to posts_path, notice: "Post deleted."
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body)
end
end4. Write the ERB views
app/views/posts/index.html.erb
<h1>Posts</h1>
<%= link_to "New Post", new_post_path %>
<% @posts.each do |post| %>
<article>
<h2><%= link_to post.title, post_path(post) %></h2>
<p><%= post.body.truncate(100) %></p>
<%= link_to "Edit", edit_post_path(post) %>
<%= button_to "Delete", post_path(post), method: :delete,
data: { confirm: "Are you sure?" } %>
</article>
<% end %>app/views/posts/show.html.erb
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>
<%= link_to "Edit", edit_post_path(@post) %>
<%= link_to "Back", posts_path %>app/views/posts/new.html.erb
<h1>New Post</h1>
<%= render "form", post: @post %>
<%= link_to "Cancel", posts_path %>app/views/posts/edit.html.erb
<h1>Edit Post</h1>
<%= render "form", post: @post %>
<%= link_to "Cancel", post_path(@post) %>app/views/posts/_form.html.erb
<%= form_with model: post do |f| %>
<% if post.errors.any? %>
<div class="errors">
<h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>
<ul>
<% post.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div>
<%= f.label :body %>
<%= f.text_area :body, rows: 6 %>
</div>
<%= f.submit %>
<% end %>5. Add an interactive client component
Let's add a like button that only exists on the client:
app/javascript/components/LikeButton.jsx:
"use client"
import { useState } from "react"
export function LikeButton({ postId }) {
const [liked, setLiked] = useState(false)
return (
<button onClick={() => setLiked(!liked)} className={liked ? "liked" : ""}>
{liked ? "❤️ Liked" : "🤍 Like"} post #{postId}
</button>
)
}Use it in show.html.erb:
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>
<LikeButton postId={@post.id} />
<%= link_to "Edit", edit_post_path(@post) %>The like button state is local to the browser — no server round-trip needed.
6. Start the server and verify
bin/devNavigate to http://localhost:3000/posts. You should see:
- Index page loads — client-side navigation when you click links (no full reload)
- New/edit forms submit via AJAX — redirect follows client-side
- Delete button triggers a client-side navigation back to index
- Like button state persists while browsing — resets on full page reload
Controlling which fields are serialized
By default, if you pass @post as a prop to a PascalCase component, ruact calls as_json on it. To control exactly which fields are exposed, use Ruact::Serializable:
class Post < ApplicationRecord
include Ruact::Serializable
ruact_props :id, :title, :body, :created_at
endNow <PostCard post={@post} /> sends only id, title, body, and created_at to the browser — not updated_at, internal flags, or any other columns. ruact_props works directly on an ActiveRecord model: a typo in a prop name still fails loudly (for an AR model at the first serialize, since its readers are defined lazily).
See the Ruact::Serializable API reference for more options.