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
Section titled “1. Generate the scaffold”rails generate scaffold Post title:string body:textrails db:migrate2. Include the controller concern
Section titled “2. Include the controller concern”Edit the generated controller or ApplicationController:
class ApplicationController < ActionController::Base include Ruact::Controllerend3. Update the controller actions
Section titled “3. 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) endend4. Write the ERB views
Section titled “4. Write the ERB views”app/views/posts/index.html.erb
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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_atendNow <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.