TurboCrud (v0.5.0)
TurboCrud is a small Rails + Turbo helper for people who want CRUD to feel smooth instead of fragile. It handles the repetitive Turbo wiring so you can focus on your app logic (and maybe drink your coffee while it’s still hot).
Quick start
If you are starting new CRUD screens:
bin/rails g turbo_crud:scaffold Post title body:text published:boolean --container=bothIf you already have a Rails app and want to drop TurboCrud in:
- Add layout frames (
turbo_crud_flash_frame,turbo_crud_modal_frame,turbo_crud_drawer_frame) - Update controller create/update/destroy to
turbo_create,turbo_update,turbo_destroy - Render index list with
turbo_list_id(Model)+ a row partial collection - Ensure row partial exists (
_rowor existing model partial like_blog) - Wrap
new/editpages withturbo_crud_container
If you see Content missing, your new/edit views are usually rendering outside the expected Turbo frame/container.
What you get
- Consistent Turbo Stream responses for create/update/destroy
- Built-in modal frame + drawer frame + flash frame
-
turbo_savehelper (create/update with one method) - Generator scaffold that auto-builds form fields from attributes
- Test coverage + a small dummy app you can extend
Why TurboCrud
| Task | Vanilla Rails + Turbo | TurboCrud |
|---|---|---|
| Wire create/update/destroy streams | Manual per action |
turbo_create, turbo_update, turbo_destroy
|
| Keep flash updates working in-frame | Easy to mis-wire |
turbo_crud_flash_frame + built-in stream helpers |
| Modal and drawer support | Custom frame plumbing |
turbo_crud_modal_link / turbo_crud_drawer_link
|
| Existing app integration | Ad-hoc changes |
turbo_crud:install + turbo_crud:doctor
|
| Scaffold setup | Multiple generators + custom edits |
turbo_crud:scaffold (+ --full when needed) |
Install
Add to your Gemfile:
gem 'turbo_crud'Then:
bundle installRun the installer once:
bin/rails g turbo_crud:installLayout setup (required)
Put these in app/views/layouts/application.html.erb:
<%= turbo_crud_flash_frame %>
<%= turbo_crud_modal_frame %>
<%= turbo_crud_drawer_frame %>Put the modal/drawer frames near the end of <body> so Turbo can target them reliably.
Optional initializer
If you want to customize defaults, create:
-
config/initializers/turbo_crud.rb(optional)
TurboCrud.configure do |c|
c.default_container = :modal # or :drawer
c.default_insert = :prepend # or :append
# Row partial auto-detect is default (:auto)
# If your app uses a custom partial path, set it:
# c.row_partial = "posts/post"
# Optional per-model overrides (safer than one global row_partial):
# key can be model class, class name, or model symbol/string.
c.model_defaults = {
"Blog" => { row_partial: "blogs/blog", container: :drawer, insert: :append }
}
# Flash rendering:
# :default => TurboCrud built-in flash partial
# :app => render app partial "shared/flash"
# (auto-wrapped in TurboCrud flash container if your partial is minimal)
# :off => disable flash rendering in Turbo stream updates
# "path/flash" => custom partial path
# ->(view, messages:) { ... } => custom renderer proc
# c.flash_renderer = :app
# Flash placement:
# :top_right (default), :top_center, :inline
# c.flash_position = :top_right
# Auto-hide delay in ms (nil to disable)
# c.flash_auto_hide_ms = 4500
endCSS (Sprockets)
Add:
/*
*= require turbo_crud
*= require turbo_crud_modal
*= require turbo_crud_drawer
*/CSS (Rails 8 / Propshaft / cssbundling)
If your application.css is plain CSS (no Sprockets manifest block), use imports instead:
@import "turbo_crud.css";
@import "turbo_crud_modal.css";
@import "turbo_crud_drawer.css";turbo_crud:install now detects this and appends @import lines automatically.
If your browser shows 404 for these imports in Rails 8, load the TurboCrud stylesheets directly in layout instead:
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "turbo_crud", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "turbo_crud_modal", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "turbo_crud_drawer", "data-turbo-track": "reload" %>Then remove the @import "turbo_crud*.css" lines from application.css to avoid duplicate requests.
Controller usage
class PostsController < ApplicationController
include TurboCrud::Controller
def create
@post = Post.new(post_params)
turbo_create(@post, list: Post, success_message: "Post created!")
end
def update
@post = Post.find(params[:id])
@post.assign_attributes(post_params)
turbo_update(@post, success_message: "Post updated!")
end
def destroy
@post = Post.find(params[:id])
turbo_destroy(@post, list: Post, success_message: "Post deleted.")
end
endSingle helper for create/update
turbo_save(@post, list: Post, success_message: "Saved!")TurboCrud decides whether to insert (create) or replace (update).
Resource DSL (turbo_crud_resource)
You can generate standard CRUD actions with one declaration:
class PostsController < ApplicationController
include TurboCrud::Controller
turbo_crud_resource Post,
scope: -> { Post.order(created_at: :desc) },
permit: %i[title body published],
authorize_with: :pundit, # :pundit, :cancancan, :nil, or omit for auto-detect
container: :drawer
endWhat this gives you:
-
index/new/create/edit/update/destroyactions - strong params via
permit: -
create/update/destroywired to TurboCrud responders - optional authorization adapter hooks (
authorize/authorize!) - model-level default container (
container:) used byturbo_crud_form_with
Notes:
-
permit:is required. -
only:/except:are supported to limit generated actions. - If
authorize_with:is omitted, TurboCrud auto-detects in this order:-
authorize!=> CanCanCan -
authorize=> Pundit - none => no authorization call
-
-
authorize_with: :punditexpectsauthorize. -
authorize_with: :cancancanexpectsauthorize!. -
authorize_with: nilexplicitly disables authorization calls. - Your normal Rails controller permissions still apply (for example
before_actionchecks inApplicationController), because your controller still inherits from it.
If you use your own controller permissions (no Pundit/CanCanCan):
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :enforce_permissions!
private
def enforce_permissions!
# your app's permission logic
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
include TurboCrud::Controller
before_action :authenticate_user!, only: %i[show edit update destroy]
before_action :enforce_permissions!
turbo_crud_resource Post,
scope: -> { Post.order(created_at: :desc) },
permit: %i[title body published],
authorize_with: nil
private
def enforce_permissions!
# your app's permission logic
end
endMore controller examples:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
include TurboCrud::Controller
include Pundit::Authorization
# Auto-detect would also pick Pundit because `authorize` exists,
# but this keeps intent explicit.
turbo_crud_resource Post,
scope: -> { policy_scope(Post).order(created_at: :desc) },
permit: %i[title body published],
authorize_with: :pundit,
container: :drawer
end
# app/controllers/admin/posts_controller.rb
class Admin::PostsController < ApplicationController
include TurboCrud::Controller
include CanCan::ControllerAdditions
# Omitted `authorize_with:` -> auto-detects CanCanCan (`authorize!`)
turbo_crud_resource Post,
scope: -> { Post.order(created_at: :desc) },
permit: %i[title body published featured]
end
# app/controllers/internal/posts_controller.rb
class Internal::PostsController < ApplicationController
include TurboCrud::Controller
# Explicitly disable authorization calls from the DSL.
turbo_crud_resource Post,
scope: -> { Post.order(created_at: :desc) },
permit: %i[title body published],
authorize_with: nil
endEquivalent generated action behavior:
class PostsController < ApplicationController
include TurboCrud::Controller
# This declaration generates the CRUD actions.
turbo_crud_resource Post, permit: %i[title body published], authorize_with: :pundit
# Rough equivalent of generated update:
# def update
# @post = Post.find(params[:id])
# authorize(@post)
# @post.assign_attributes(params.require(:post).permit(:title, :body, :published))
# turbo_update(@post)
# end
#
# Rough equivalent of generated destroy:
# def destroy
# @post = Post.find(params[:id])
# authorize(@post)
# turbo_destroy(@post, list: Post)
# end
endValidation and error behavior
TurboCrud validates key options early and raises clear errors:
-
turbo_createand create-pathturbo_respondrequirelist: -
insert:must be:prepend,:append, ornil -
replace:must be:row, a DOM id (String/Symbol), ornil
If row partial rendering fails, TurboCrud raises:
TurboCrud::MissingRowPartialError
This error includes the model name and the partial candidates TurboCrud tried.
Modal vs Drawer
Links:
turbo_crud_modal_link "New", new_post_pathturbo_crud_drawer_link "New", new_post_path
Forms:
-
turbo_crud_form_with ...(defaults to your configured container) - or explicitly:
turbo_crud_form_with ..., frame: TurboCrud.config.drawer_frame_id - submit buttons auto-disable during Turbo submit (opt out:
data: { turbo_crud_auto_disable: false }) -
Escapecloses active modal/drawer andTabfocus stays inside the open container
Example submit button loading text:
<%= turbo_crud_form_with model: @post do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save", data: { turbo_crud_loading_text: "Saving..." } %>
<% end %>Generator
rails g turbo_crud:scaffold Post title body:text published:boolean views:integerIt generates:
- controller wired to TurboCrud
- views: index/new/edit/_row/_form
-
_formwill contain inputs for each attribute you passed.
Notes
TurboCrud is intentionally small. The goal is predictable behavior and easy integration, especially in existing Rails apps. Small scope, fewer surprises.
Generator options
By default the scaffold generates modal new/edit views.
You can switch to drawer views:
rails g turbo_crud:scaffold Post title body:text --container=drawerOr generate both (modal files + extra drawer files as new.drawer.html.erb / edit.drawer.html.erb):
rails g turbo_crud:scaffold Post title body:text --container=both--container is validated strictly and must be one of:
modaldrawerboth
Tip: if you want the whole app to prefer drawers, set:
TurboCrud.configure do |c|
c.default_container = :drawer
endExisting app integration (step-by-step)
You can keep your existing model, routes, and form partials. No rewrite-from-scratch drama required.
1) Add layout frames once
In app/views/layouts/application.html.erb (near end of <body>):
<%= turbo_crud_flash_frame %>
<%= turbo_crud_modal_frame %>
<%= turbo_crud_drawer_frame %>2) Update controller actions to TurboCrud responders
Example for an existing BlogsController:
class BlogsController < ApplicationController
include TurboCrud::Controller
before_action :set_blog, only: %i[show edit update destroy]
def index
@blogs = Blog.order(created_at: :desc)
end
def new
@blog = Blog.new
render(**turbo_crud_template_for(:new))
end
def edit
render(**turbo_crud_template_for(:edit))
end
def create
@blog = Blog.new(blog_params)
turbo_create(@blog, list: Blog, row_partial: "blogs/blog", success_message: "Blog created!")
end
def update
@blog.assign_attributes(blog_params)
turbo_update(@blog, row_partial: "blogs/blog", success_message: "Blog updated!")
end
def destroy
turbo_destroy(@blog, list: Blog, success_message: "Blog deleted.")
end
endNote: keep your preferred strong params style (require/permit or Rails 8 expect).
3) Update index to use Turbo list id + row partial collection
Replace the classic scaffold loop:
<div id="blogs">
<% @blogs.each do |blog| %>
<%= render blog %>
<% end %>
</div>
<%= link_to "New blog", new_blog_path %>with:
<%= turbo_crud_modal_link "New blog", new_blog_path %>
<div id="<%= turbo_list_id(Blog) %>">
<%= render partial: "blogs/blog", collection: @blogs, as: :blog %>
</div>Use turbo_crud_drawer_link instead if your app prefers drawers.
4) Reuse your existing _blog partial as the row partial
If you already have app/views/blogs/_blog.html.erb, you can use it directly:
<div id="<%= dom_id blog %>">
<div>
<strong>Title:</strong>
<%= blog.title %>
</div>
<div>
<strong>Content:</strong>
<%= blog.content %>
</div>
<div class="mt-3">
<%= turbo_crud_modal_link "Edit", edit_blog_path(blog),
class: "rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-slate-900 hover:bg-slate-50" %>
</div>
</div>5) Wrap new/edit pages so frame requests render container UI
app/views/blogs/new.html.erb:
<%= turbo_crud_container title: "New Blog" do %>
<%= render "form", blog: @blog %>
<% end %>app/views/blogs/edit.html.erb:
<%= turbo_crud_container title: "Edit Blog" do %>
<%= render "form", blog: @blog %>
<% end %>If you see "Content missing", it usually means your new/edit templates are not rendering inside TurboCrud container/frame markup.
Drawer Content missing fix
If this happens when clicking turbo_crud_drawer_link:
- Confirm your layout includes
<%= turbo_crud_drawer_frame %>. - Confirm
new/edituseturbo_crud_container(or drawer frame wrapper). - Confirm the link uses drawer target:
<%= turbo_crud_drawer_link "New", new_blog_path %>
- Update to the latest TurboCrud and restart the Rails server (includes frame auto-detection improvements).
If this fixed it, congrats: you and Turbo are friends again.
Flash message does not update until refresh
If create/update/delete works but the message stays on an older value:
- Ensure
application.html.erbhas exactly one<%= turbo_crud_flash_frame %>. - Remove legacy layout flash blocks like
<%= notice %>and<%= alert %>. - Ensure your flash partial uses key checks so explicit Turbo locals are respected:
<% notice_message = local_assigns.key?(:notice) ? local_assigns[:notice] : flash[:notice] %>
<% alert_message = local_assigns.key?(:alert) ? local_assigns[:alert] : flash[:alert] %>- Use a TurboCrud version where flash stream updates use
update(notreplace), so theturbo_flashframe id stays targetable across requests.
If your flash still looks haunted, run bin/rails g turbo_crud:doctor --fix and check for duplicate flash blocks in your layout.
6) Common integration mistakes
- Index list container uses
id="blogs"instead ofid="<%= turbo_list_id(Blog) %>" - Controller
create/updatedoes not passlist: Blog - No row partial available (
blogs/_roworblogs/_blog) -
new/editrenders plain form page instead ofturbo_crud_container - Using wrong route helper in row partial (example:
edit_dan_pathinstead ofedit_blog_path)
Row partial auto-detection (if you don't pass row_partial)
TurboCrud tries:
blogs/_row.html.erbblogs/_blog.html.erb-
TurboCrud.config.row_partial(only if path looks compatible with current model)
You can also set globally:
TurboCrud.configure do |c|
c.row_partial = "blogs/blog"
endTip: if your app has multiple resources, prefer passing row_partial: per action/controller instead of one global path.
Per-model defaults (recommended for multi-resource apps)
Instead of a global c.row_partial, use:
TurboCrud.configure do |c|
c.model_defaults = {
"Blog" => { row_partial: "blogs/blog", container: :drawer, insert: :append },
"Post" => { container: :modal, insert: :prepend }
}
endPer-model defaults apply to:
-
row_partial(create/update rendering) -
container(default modal/drawer forturbo_crud_form_with/turbo_crud_container) -
insert(default append/prepend for create)
Full scaffold generator (model + migration + routes + TurboCrud views)
full_scaffold creates:
- Model + migration (like
rails g model ...) - Routes (
resources :things) - TurboCrud controller + views (modal/drawer/both)
Run it like:
bin/rails g turbo_crud:full_scaffold Post title body:text published:boolean --container=bothNotes:
- TurboCrud does not run
db:migrateunless you opt in with--migrate. - If routes already exist, TurboCrud won’t double-inject them.
Main generator: turbo_crud:scaffold
By default it generates controller + views (no model, no routes):
bin/rails g turbo_crud:scaffold Post title body:text published:boolean --container=bothIf you want FULL scaffold (model + migration + routes + controller + views), add --full:
bin/rails g turbo_crud:scaffold Post title body:text published:boolean --container=both --full--full does not run db:migrate by default.
Use --migrate to run migrations automatically.
You can control parts:
bin/rails g turbo_crud:scaffold Post title body:text --full --skip-model
bin/rails g turbo_crud:scaffold Post title body:text --full --skip-routesInstall helper (--install)
You can ask the scaffold generator to wire up your app layout + CSS too.
bin/rails g turbo_crud:scaffold Post title body:text --container=both --installWhat --install does:
- injects these frames near the end of
app/views/layouts/application.html.erb(before</body>):turbo_crud_flash_frameturbo_crud_modal_frameturbo_crud_drawer_frame
- updates
app/assets/stylesheets/application.css:- if Sprockets manifest style is detected, adds:
*= require turbo_crud*= require turbo_crud_modal*= require turbo_crud_drawer
- otherwise appends:
@import "turbo_crud.css";@import "turbo_crud_modal.css";@import "turbo_crud_drawer.css";
- if Sprockets manifest style is detected, adds:
If it can’t find those files, it prints a warning with manual steps.
You can also use the dedicated installer:
bin/rails g turbo_crud:installOptional Stimulus behavior setup:
bin/rails g turbo_crud:install --stimulusDoctor command
TurboCrud includes a diagnostic generator for existing apps:
bin/rails g turbo_crud:doctorIt checks:
- layout frames (
flash,modal,drawer) - controller inclusion of
TurboCrud::Controller - presence of view partials for stream rendering
Use strict mode (non-zero exit on issues):
bin/rails g turbo_crud:doctor --strictAuto-fix common setup issues (layout frames + CSS requires):
bin/rails g turbo_crud:doctor --fixAuto-fix + optional Stimulus install:
bin/rails g turbo_crud:doctor --fix --stimulusThink of doctor as: "scan app, find potholes, patch the obvious ones."
CI / Security
This repo includes separate GitHub Actions workflows:
-
Testworkflow: runsbundle exec rake testacross Ruby/Rails matrix -
Securityworkflow: runsbrakemanandbundle-audit
Run locally:
bundle exec rake test
bundle exec brakeman --force --no-pager -q
bundle exec bundle-audit checkObservability events
TurboCrud emits ActiveSupport::Notifications events:
turbo_crud.createturbo_crud.updateturbo_crud.destroyturbo_crud.row_partial_missing
Payload includes controller/action, model/id, format, and success/error metadata.
Subscribe example:
ActiveSupport::Notifications.subscribe("turbo_crud.create") do |_name, _start, _finish, _id, payload|
Rails.logger.info("[turbo_crud.create] #{payload.inspect}")
endIn most apps, put this in an initializer for centralized logging/metrics.
Compatibility
CI runs the test suite across current Ruby/Rails combinations using the gemfiles in gemfiles/.
Check .github/workflows/test.yml for the exact matrix used by the current release.
Releases
- Changelog:
CHANGELOG.md - Version policy: backward-incompatible changes are announced in the changelog before major updates.