ruby_ui_scaffold
Rails scaffold generator that outputs Phlex views built with ruby_ui components, plus a smart seed command to populate models with fake data.
Drop-in alternative to rails g scaffold — generates model, migration, controller, routes, tests, and Phlex view classes wired to ruby_ui components.

Requirements
- Rails 7.1+
- Tailwind CSS (any Rails Tailwind setup —
tailwindcss-railsis fine)
Disclaimer
ruby_ui_scaffold is a development-only generator built on the ruby_ui component library — you add both to your app. faker and lucide-rails come along automatically as runtime dependencies of ruby_ui_scaffold — faker powers the seed command, and lucide-rails renders the icons in the generated views (the index action menu plus the New / Edit / Create / Update buttons).
If you later remove
ruby_ui_scaffold, addgem "lucide-rails"to your Gemfile (default group) yourself. The generated views you keep still calllucide_icon, andruby_uidoesn't depend onlucide-rails— so dropping the scaffold gem would otherwise takelucide-railswith it and break the icons. (fakeronly matters if you still run theseedcommand.)
First-time setup
On a fresh Rails app, add both gems to your Gemfile — ruby_ui (the component library, used at runtime) and ruby_ui_scaffold (the generator, development only):
# Gemfile
gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false
group :development do
gem "ruby_ui_scaffold"
end
Then bundle and run the installer:
$ bundle install
$ bin/rails g ruby_ui_scaffold:install
That's it. The installer wires up phlex, ruby_ui, and the base scaffold components — it's fully idempotent, so re-running it only touches what's actually missing.
Convenience fallbacks (so a missed step never blocks you):
- If you skip the
ruby_uiline above, the installer adds it to yourGemfileand runsbundle installfor you.- If you jump straight to
bin/rails g ruby_ui_scaffold ...without running the installer, the first scaffold auto-runsruby_ui_scaffold:installbefore writing the views (pass--skip-installto only warn instead).Doing the explicit setup above just keeps things predictable.
What the installer does
0. **ruby_ui gem bootstrap** — if `ruby_ui` isn't loadable, adds `gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false` to the Gemfile (unless an entry already exists) and runs `bundle install`. Skipped entirely once the gem loads. 1. **`phlex:install`** — creates `app/views/base.rb` (`Views::Base`), `app/components/base.rb` (`Components::Base`), and `config/initializers/phlex.rb` (wires the `Views::` autoloader). Skipped if `app/views/base.rb` already exists. 2. **`ruby_ui:install`** — mixes `include RubyUI` into `Components::Base`, adds `config/initializers/ruby_ui.rb`, and the Tailwind preset. Skipped if `Components::Base` already includes RubyUI. 3. **Base components** — installs the components every scaffold's shell uses regardless of columns/flags: `table`, `link`, `button`, `card`, `typography`, `dropdown_menu`, `alert_dialog`, `form`, `input`. Each is skipped if already present. The installer does **not** run `ruby_ui:component:all` — column/flag-specific components are installed on demand (see below). On the first scaffold generation, `register_output_helper :lucide_icon` is also injected into `Components::Base` (idempotent), so the index dropdown trigger renders out of the box. **On-demand components.** Instead of installing every ruby*ui component upfront, each `rails g ruby_ui_scaffold ...` installs just the components \_that scaffold* references, right after writing the view files — skipping anything already present. So an app only carries the components it actually uses: | Column / flag | Installed on demand | | ------------- | -------------------------------------------------------------- | | `boolean` | `badge`, `checkbox` | | `text` | `textarea` | | `references` | `combobox`, `select` | | `date` | `date_picker` (pulls `calendar` + `popover`) | | `--datatable` | `data_table` (pulls `table`, `native_select`, `pagination`, …) | `ruby_ui:component` resolves transitive dependencies itself, so installing `date_picker`/`data_table` brings their sub-components along automatically. Pass `--skip-install` to opt out of all automatic installation.Quick start
# 1. Generate a CRUD scaffold for any model
$ bin/rails g ruby_ui_scaffold Buddy name:string email:string admin:boolean bio:text birthday:date
$ bin/rails db:migrate
# 2. Seed it with 50 fake records
$ bin/rails ruby_ui_scaffold:seed Buddy --count 50
# 3. Open /buddies in your browser

That's it. You get:
BuddiesControllerwith full CRUD wired- Index: plain ruby_ui
Tablewith header + body. Pass--datatablefor the full DataTable (search + per-page + sort + pagination). - Phlex views (
app/views/buddies/{index,show,new,edit,form}.rb) - New/Edit form: submit button plus a
Backlink (returns torequest.referer, falling back to the index) - Lucide icons on the action buttons —
plus(New),pencil(Edit), andplus/checkon the form's Create/Update submit - Action column:
DropdownMenu(Lucidemore-horizontaltrigger) with Show / Edit / Delete - Delete confirmation via ruby_ui
AlertDialog(no JS browser confirm) - Cells truncate with hover-to-see-full
- Realistic fake data via Faker (real names, emails, dates, paragraphs)
belongs_to (1×N) — Books + Authors walkthrough
Generate the parent first, then the child with :references:
$ bin/rails g ruby_ui_scaffold Author name:string bio:text
$ bin/rails g ruby_ui_scaffold Book title:string pages:integer published:boolean author:references
$ bin/rails db:migrate
# Seed parent first — Book requires Author records to exist
$ bin/rails ruby_ui_scaffold:seed Author --count 10
$ bin/rails ruby_ui_scaffold:seed Book --count 25
What you get automatically:
- Form — switches between
Combobox(searchable, when the parent table has more thanCOMBOBOX_THRESHOLD = 100records) andSelect(for smaller lists). Both are populated fromAuthor.allwith a label fallback:record.try(:name) → :title → :display_name → "Author #id". On edit, the current value is pre-selected. - Index and Show — display the friendly assoc label (
book.author&.try(:name) || ...) instead of the raw foreign key. - Controller — auto-eager-loads via
scope.includes(:author)to avoid N+1 on the index. - Preflight — seeding
Bookbefore anyAuthorexists aborts with a clear message:
ERROR: Book requires Author records first. Run `rails ruby_ui_scaffold:seed Author --count 10` first.
Snippet of the generated form for author:references
# app/views/books/form.rb
class Views::Books::Form < Views::Base
COMBOBOX_THRESHOLD = 100 # tune per-form
def view_template
form_with(...) do |form|
FormField do
FormFieldLabel(for: "book_author_id") { "Author" }
if Author.count > COMBOBOX_THRESHOLD
Combobox do
ComboboxTrigger(placeholder: "Select Author")
ComboboxPopover do
ComboboxSearchInput(placeholder: "Search Author...")
ComboboxList do
Author.all.each do |record|
ComboboxItem do
ComboboxRadio(value: record.id.to_s, name: "book[author_id]", checked: record.id == @book.author_id)
span { (record.try(:name) || record.try(:title) || record.try(:display_name) || "Author #{record.id}").to_s }
end
end
end
end
end
else
current_author_label = if @book.author
assoc = @book.author
(assoc.try(:name) || assoc.try(:title) || assoc.try(:display_name) || "Author #{assoc.id}").to_s
end
Select do
SelectInput(name: "book[author_id]", value: @book.author_id)
SelectTrigger { SelectValue(placeholder: "Select Author") { current_author_label } }
SelectContent do
Author.all.each do |record|
SelectItem(value: record.id.to_s, aria_selected: (record.id == @book.author_id).to_s) do
(record.try(:name) || record.try(:title) || record.try(:display_name) || "Author #{record.id}").to_s
end
end
end
end
end
FormFieldError { @book.errors.messages_for(:author_id).to_sentence }
end
end
end
end
Trade-off: each form render does one Author.count query per belongs_to field. Fine for typical scaffold use; tune COMBOBOX_THRESHOLD (or memoize at the controller level) if it becomes a hot path.
Polymorphic associations (commentable:references{polymorphic}) fall back to a plain integer input with a TODO comment — populate the *_type and *_id manually for now.
Optional flags
| Flag | What it does |
|---|---|
--datatable |
Wrap the index in ruby_ui DataTable — adds search input, per-page select, sortable headers, and manual pagination. The controller is upgraded with SORTABLE_COLUMNS allowlist, params parsing, and scope building. |
--literal |
Emit views using literal's prop macros instead of def initialize + @ivar assignments. Idempotently injects extend Literal::Properties into app/components/base.rb on first use. Combines with --datatable. |
--phlex-layout=ClassName |
Wrap every view in render(ClassName) do ... end + emit layout false in the controller. Use when your app has a Phlex layout (with include Phlex::Rails::Layout) instead of the default application.html.erb. |
--skip-install |
Don't auto-run ruby_ui_scaffold:install when phlex/ruby_ui aren't detected — only print a warning (the pre-auto-install behavior). |
--skip-model |
Skip model/migration/fixtures generation and only (re)generate the controller, views, and route. For re-runs against an existing model. Implies --force (overwrites generated files, bypasses the collision check). |
--skip-routes, --no-test-framework, --no-helper, ... |
All standard rails g scaffold flags work — inherited from Rails::Generators::ScaffoldGenerator. |
--datatable
$ bin/rails g ruby_ui_scaffold Buddy name:string email:string birthday:date --datatable

Generates an index built around DataTable:
# app/views/buddies/index.rb (excerpt with --datatable)
DataTable(id: "buddies_data_table") do
DataTableToolbar do
DataTableSearch(path: buddies_path, frame_id: "buddies_data_table", value: @search, ...)
DataTablePerPageSelect(path: buddies_path, ..., value: @per_page)
end
Table(class: "table-fixed") do
TableHeader do
TableRow do
DataTableSortHead(label: "Name", column_key: "name", sort: @sort, direction: @direction, ...)
DataTableSortHead(label: "Email", column_key: "email", ...)
...
end
end
TableBody do
@buddies.each do |buddy|
TableRow do
# truncated cells + action DropdownMenu (same as default)
end
end
end
end
DataTablePaginationBar do
Text(...) { "Showing #{...} of #{@total_count}" }
DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, ...)
end
end
And the controller gains:
# app/controllers/buddies_controller.rb (with --datatable)
SORTABLE_COLUMNS = %w[name email birthday].freeze
DEFAULT_PER_PAGE = 10
MAX_PER_PAGE = 100
def index
@per_page = clamp_per_page(params[:per_page])
@page = [params[:page].to_i, 1].max
@search = params[:search].to_s
@sort = params[:sort] if SORTABLE_COLUMNS.include?(params[:sort])
@direction = %w[asc desc].include?(params[:direction]) ? params[:direction] : "asc"
scope = Buddy.all
# ... eager loading + LIKE search + order + limit/offset ...
@total_count = scope.count
@buddies = scope.limit(@per_page).offset(@per_page * (@page - 1))
render ::Views::Buddies::Index.new(buddies:, page:, per_page:, total_count:, search:, sort:, direction:)
end
Without --datatable, the controller's index is the bare minimum (Buddy.all plus .includes when there are belongs_to references) and the view receives a single buddies: kwarg. Use the flag when you need a list larger than ~50 rows to be navigable; skip it for tiny CRUDs.
--literal
$ bin/rails g ruby_ui_scaffold Buddy name:string species:string --literal
Generated views use literal's prop macros — same behavior, less boilerplate, runtime type checking:
# app/views/buddies/show.rb (with --literal)
class Views::Buddies::Show < Views::Base
prop :buddy, Buddy
def view_template
# @buddy is available — Literal generates the initialize + ivar
end
end
# app/views/buddies/form.rb
class Views::Buddies::Form < Views::Base
prop :buddy, Buddy
prop :url, String
prop :method, String
# ...
end
Combines with --datatable — the DataTable index gets the full typed prop set:
# app/views/buddies/index.rb (with --datatable --literal)
class Views::Buddies::Index < Views::Base
prop :buddies, _Any
prop :page, Integer
prop :per_page, Integer
prop :total_count, Integer
prop :search, _Nilable(String), default: nil
prop :sort, _Nilable(String), default: nil
prop :direction, _Nilable(String), default: nil
# ...
end
First scaffold with --literal injects extend Literal::Properties into app/components/base.rb (idempotent — re-runs don't duplicate it). Controllers don't change: render Views::Buddies::Show.new(buddy: @buddy) still works since Literal generates a compatible initialize.
The literal gem ships as a runtime dependency of ruby_ui_scaffold, so Bundler pulls it in automatically — no extra setup.
If you later remove
ruby_ui_scaffold, addgem "literal"to your Gemfile (default group) yourself. Views generated with--literalkeep using itspropmacros at runtime, and nothing else pullsliteralin — so dropping the scaffold gem would otherwise break those views.
--phlex-layout=ApplicationLayout
$ bin/rails g ruby_ui_scaffold Buddy name:string --phlex-layout=ApplicationLayout
Generates:
# app/controllers/buddies_controller.rb
class BuddiesController < ApplicationController
layout false # skip Rails' default layout
# ...
end
# app/views/buddies/index.rb (and show/new/edit)
def view_template
render(ApplicationLayout) do
# ... all the view content ...
end
end
Use this when your app has two layouts (e.g. one ERB layout for guest pages, one Phlex layout for the authenticated dashboard with Stimulus controllers). Without --phlex-layout, scaffolded pages would inherit the default Rails layout — which might load the wrong JS bundle and break Stimulus controllers.
--skip-install
By default, the scaffold generator checks for phlex (app/views/base.rb) and ruby_ui (RubyUI mixed into app/components/base.rb) before writing views. If either is missing, it auto-runs ruby_ui_scaffold:install first (idempotent) so the generated views work out of the box:
$ bin/rails g ruby_ui_scaffold Buddy name:string
→ phlex + ruby_ui not detected — running `ruby_ui_scaffold:install` first.
(idempotent; pass --skip-install to only warn instead)
...
Pass --skip-install to suppress that and only print a warning instead:
$ bin/rails g ruby_ui_scaffold Buddy name:string --skip-install
Auto-install only fires when there's an app bin/rails to drive it; if the ruby_ui gem isn't bundled, the installer aborts with instructions and the scaffold falls back to a warning.
--skip-model
Re-running rails g ruby_ui_scaffold Buddy ... with the same name normally trips up on the model: Rails would try to recreate the model and add a duplicate migration, and the run aborts on the controller's class-collision check. --skip-model is for exactly this re-run case — it skips the whole model step (model, migration, model test, fixtures) and only (re)generates the controller, views, and route:
# First run — full scaffold
$ bin/rails g ruby_ui_scaffold Buddy name:string email:string
$ bin/rails db:migrate
# Later: refresh the views, or switch to the DataTable index, without
# touching the model or creating a second migration
$ bin/rails g ruby_ui_scaffold Buddy name:string email:string --datatable --skip-model
--skip-model implies --force: it overwrites the regenerated controller/views without per-file prompts and bypasses the collision check (the re-run would otherwise abort because Buddy/BuddiesController already exist). The route is left untouched if it already exists (idempotent).
The mental model: the model is your code (associations, validations, scopes) and is never touched on a re-run; the controller and views are generated and get overwritten. Put custom logic in the model. Note --skip-model is meant for re-runs — using it on a model that doesn't exist yet leaves you with a controller/views but no model.
Reference
Generated files (full scaffold)
invoke active_record
create db/migrate/20XXXXXXXXXXXX_create_buddies.rb
create app/models/buddy.rb
invoke test_unit
create test/models/buddy_test.rb
create test/fixtures/buddies.yml
invoke ruby_ui_scaffold:scaffold_controller
create app/controllers/buddies_controller.rb
invoke ruby_ui_scaffold:scaffold
create app/views/buddies/index.rb
create app/views/buddies/show.rb
create app/views/buddies/new.rb
create app/views/buddies/edit.rb
create app/views/buddies/form.rb
inject app/components/base.rb (adds `register_output_helper :lucide_icon`)
invoke test_unit
create test/controllers/buddies_controller_test.rb
invoke helper
create app/helpers/buddies_helper.rb
invoke resource_route
route resources :buddies
Generated namespacing
Views live under the Views:: module — matching the convention installed by phlex:install (which creates Views::Base and wires app/views/ as the Views:: namespace root):
# app/views/buddies/index.rb
class Views::Buddies::Index < Views::Base
...
end
# For namespaced resources (rails g ruby_ui_scaffold Admin::Buddy ...):
# app/views/admin/buddies/index.rb
class Views::Admin::Buddies::Index < Views::Base
...
end
Controllers render via the fully-qualified constant — render ::Views::Buddies::Index.new(...) — so there's no ambiguity with non-Phlex app/views/ siblings (e.g. ERB partials).
Type → ruby_ui component mapping (form inputs)
| Rails column type | ruby_ui component |
|---|---|
string |
Input(type: "text") |
text |
Textarea(rows: 4) |
integer |
Input(type: "number", step: 1) |
float, decimal |
Input(type: "number", step: "any") |
boolean |
Checkbox + hidden "0" |
date |
DatePicker (ruby_ui — Popover + Calendar over a submittable input) |
time |
Input(type: "time") |
datetime, timestamp |
Input(type: "datetime-local") |
password_digest |
Input(type: "password") |
references/belongs_to |
Combobox (if parent.count > 100) or Select |
polymorphic references |
Input(type: "number") + TODO |
attachment(s) |
Input(type: "file") |
Index features
Always present (both modes):
Table(class: "table-fixed")+ per-celltruncateso long values don't break the layout- Boolean columns rendered as
Badge(success/outline) belongs_tocolumns rendered with the friendly assoc label (name → title → display_name)- Action column:
DropdownMenu(options: { strategy: "fixed" })triggered by Lucidemore-horizontal - Delete inside an
AlertDialog(Title + Description + Cancel + Deleteform_with(method: :delete)) - Wrapped in
div(class: "h-dvh overflow-y-auto")so absolute-positioned popovers don't get clipped by ancestors withoverflow: hidden(common in dashboard layouts)
Only with --datatable:
DataTablewrapper withDataTableToolbar(DataTableSearch+DataTablePerPageSelect)DataTableSortHeadfor sortable columns (excludes:text,:rich_text,:json/:jsonb,:binary, attachments)DataTablePagination— manualpage/per_page/total_countadapter, no pagy/kaminari dependency- Controller bakes
SORTABLE_COLUMNS,params[:search]LIKE clause,params[:sort]allowlist,params[:page]/[:per_page]clamp
Seed command
$ bin/rails ruby_ui_scaffold:seed MODEL [--count N] [--reset] [--dry-run]
| Flag | Behavior |
|---|---|
--count N, -c N |
Number of records to create (defaults to 10) |
--reset |
Runs Model.destroy_all before seeding |
--dry-run |
Prints one sample attribute hash without saving |
Inference chain
For each column the value comes from the first source that matches:
belongs_toforeign key — samples an existing parent record (Author.ids.sample).ActiveRecord::Enum— samples a key fromModel.defined_enums[col].validates :col, inclusion: { in: [...] }— samples from the allowlist.validates :col, numericality: { greater_than: X, less_than: Y }— respects the range.- Column name heuristic — see table below.
- Column type fallback —
:integer→rand(1..1000),:boolean→[true, false].sample, etc.
Column-name heuristics
| Column name | Generator |
|---|---|
email, *_email |
Faker::Internet.unique.email |
first_name, last_name, name, full_name |
Faker::Name.* |
username, login, handle |
Faker::Internet.unique.username |
phone, phone_number, *_phone |
Faker::PhoneNumber.cell_phone |
address, street, city, state, country, zip |
Faker::Address.* |
url, website, homepage |
Faker::Internet.url |
title |
Faker::Lorem.sentence(word_count: 4) |
body, content, description, bio, summary, notes |
Faker::Lorem.paragraph |
company, company_name |
Faker::Company.name |
slug |
Faker::Internet.unique.slug |
uuid |
SecureRandom.uuid |
birthdate, birthday, dob, date_of_birth |
Faker::Date.birthday |
age |
rand(18..80) |
color |
Faker::Color.color_name |
latitude / longitude |
Faker::Address.latitude / .longitude |
price, amount |
(rand * 1000).round(2) |
quantity, qty |
rand(1..100) |
password, password_digest |
"password123" (let has_secure_password hash it) |
Skipped columns
Never assigned:
idcreated_at,updated_at- Counter caches (
*_count) - STI inheritance column (default
type) - Polymorphic
*_typecolumns
Failure handling
Each record gets up to 3 retries with newly-generated attributes. After that, it's counted as skipped and the run continues. Final summary shows how many were created vs. skipped and the first 3 unique error messages.
Changelog
See CHANGELOG.md.
License
MIT