# ============================================
# Aaliyah James — Rapid Prototyping Playbook
# "Your SPA took 3 sprints. I shipped in a weekend."
# ============================================
# RULE 1: If you're writing a React component for a CRUD form,
# you've already lost the race.
# RULE 2: Hotwire is not a compromise. It's a cheat code.
# RULE 3: Ship it. Get feedback. Iterate. Repeat.
# ============================================
# PHASE 0: Scaffold in 90 seconds flat
# ============================================
rails new rapidship \
--database=postgresql \
--css=tailwind \
--skip-jbuilder \
--skip-test \
-T
cd rapidship
# The essentials — nothing more
bundle add devise # auth solved in 5 minutes
bundle add pagy # pagination that doesn't hate you
bundle add inline_svg # icons without the JS bloat
bundle add faker # seed data for demos
bundle add letter_opener # preview emails in dev
# Testing (we ship fast, not reckless)
group :test do
bundle add rspec-rails
bundle add factory_bot_rails
bundle add capybara
end
rails generate rspec:install
rails generate devise:install
rails generate devise User
rails db:create db:migrate
# ============================================
# PHASE 1: Domain Model (30 minutes, tops)
# ============================================
# Example: Feature request tracker for a SaaS product
# db/migrate/xxx_create_feature_requests.rb
class CreateFeatureRequests < ActiveRecord::Migration[7.2]
def change
create_table :feature_requests do |t|
t.references :user, null: false, foreign_key: true
t.string :title, null: false
t.text :body, null: false
t.string :status, null: false, default: "pending"
t.string :priority, null: false, default: "medium"
t.integer :votes_count, null: false, default: 0
t.timestamps
end
create_table :votes do |t|
t.references :user, null: false, foreign_key: true
t.references :feature_request, null: false, foreign_key: true
t.timestamps
end
add_index :votes, [:user_id, :feature_request_id], unique: true
create_table :comments do |t|
t.references :user, null: false, foreign_key: true
t.references :feature_request, null: false, foreign_key: true
t.text :body, null: false
t.timestamps
end
end
end
# ============================================
# PHASE 2: Models — Fat models, skinny controllers
# ============================================
# app/models/feature_request.rb
class FeatureRequest < ApplicationRecord
belongs_to :user
has_many :votes, dependent: :destroy
has_many :voters, through: :votes, source: :user
has_many :comments, dependent: :destroy
validates :title, presence: true, length: { maximum: 120 }
validates :body, presence: true
validates :status, inclusion: {
in: %w[pending reviewing planned in_progress shipped rejected]
}
validates :priority, inclusion: { in: %w[low medium high critical] }
scope :by_votes, -> { order(votes_count: :desc) }
scope :by_recent, -> { order(created_at: :desc) }
scope :with_status, ->(s) { where(status: s) if s.present? }
# Turbo Broadcasts — real-time updates with ZERO JavaScript
broadcasts_refreshes
def voted_by?(user)
votes.exists?(user: user)
end
def toggle_vote!(user)
vote = votes.find_by(user: user)
vote ? vote.destroy : votes.create!(user: user)
end
STATUS_COLORS = {
"pending" => "bg-gray-100 text-gray-700",
"reviewing" => "bg-yellow-100 text-yellow-800",
"planned" => "bg-blue-100 text-blue-800",
"in_progress" => "bg-purple-100 text-purple-800",
"shipped" => "bg-green-100 text-green-800",
"rejected" => "bg-red-100 text-red-700"
}.freeze
def status_badge_class
STATUS_COLORS.fetch(status, STATUS_COLORS["pending"])
end
end
# app/models/vote.rb
class Vote < ApplicationRecord
belongs_to :user
belongs_to :feature_request, counter_cache: true
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :user
belongs_to :feature_request, touch: true
validates :body, presence: true
broadcasts_refreshes_to :feature_request
after_create_commit -> {
Turbo::StreamsChannel.broadcast_update_to(
feature_request,
target: "comment_count_#{feature_request.id}",
html: feature_request.comments.count.to_s
)
}
end
# ============================================
# PHASE 3: Controller — boring on purpose
# ============================================
# app/controllers/feature_requests_controller.rb
class FeatureRequestsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_feature_request, only: [:show, :edit, :update]
def index
scope = FeatureRequest
.with_status(params[:status])
.includes(:user)
scope = case params[:sort]
when "votes" then scope.by_votes
when "recent" then scope.by_recent
else scope.by_votes
end
@pagy, @feature_requests = pagy(scope, items: 20)
end
def show
@comment = Comment.new
@comments = @feature_request.comments
.includes(:user)
.order(created_at: :asc)
end
def new
@feature_request = current_user.feature_requests.build
end
def create
@feature_request = current_user.feature_requests.build(fr_params)
if @feature_request.save
redirect_to @feature_request,
notice: "Feature request submitted! 🚀"
else
render :new, status: :unprocessable_entity
end
end
def update
if @feature_request.update(fr_params)
redirect_to @feature_request
else
render :edit, status: :unprocessable_entity
end
end
private
def set_feature_request
@feature_request = FeatureRequest.find(params[:id])
end
def fr_params
params.require(:feature_request)
.permit(:title, :body, :priority, :status)
end
end
# ============================================
# PHASE 4: Turbo — The "How is this not an SPA?" part
# ============================================
# app/controllers/votes_controller.rb
class VotesController < ApplicationController
before_action :authenticate_user!
def create
@feature_request = FeatureRequest.find(params[:feature_request_id])
@feature_request.toggle_vote!(current_user)
respond_to do |format|
format.turbo_stream {
render turbo_stream: turbo_stream.replace(
"vote_button_#{@feature_request.id}",
partial: "feature_requests/vote_button",
locals: { feature_request: @feature_request }
)
}
format.html { redirect_to @feature_request }
end
end
end
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :authenticate_user!
def create
@feature_request = FeatureRequest.find(params[:feature_request_id])
@comment = @feature_request.comments.build(comment_params)
@comment.user = current_user
if @comment.save
respond_to do |format|
format.turbo_stream {
render turbo_stream: [
turbo_stream.append(
"comments",
partial: "comments/comment",
locals: { comment: @comment }
),
turbo_stream.replace(
"comment_form",
partial: "comments/form",
locals: {
feature_request: @feature_request,
comment: Comment.new
}
)
]
}
format.html { redirect_to @feature_request }
end
else
render partial: "comments/form",
locals: { feature_request: @feature_request, comment: @comment },
status: :unprocessable_entity
end
end
private
def comment_params
params.require(:comment).permit(:body)
end
end
<%# ============================================ %>
<%# PHASE 5: Views — Hotwire + Tailwind = instant UI %>
<%# ============================================ %>
<%# app/views/feature_requests/index.html.erb %>
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-gray-900">Feature Requests</h1>
<%= link_to "Submit Request",
new_feature_request_path,
class: "bg-indigo-600 text-white px-4 py-2 rounded-lg
hover:bg-indigo-700 transition-colors" %>
</div>
<%# Turbo Frame for filter/sort — no full page reload %>
<%= turbo_frame_tag "feature_list" do %>
<%# Filter tabs %>
<div class="flex gap-2 mb-6 flex-wrap">
<% statuses = [nil, *FeatureRequest::STATUS_COLORS.keys] %>
<% statuses.each do |s| %>
<%= link_to (s&.titleize || "All"),
feature_requests_path(status: s, sort: params[:sort]),
class: "px-3 py-1 rounded-full text-sm font-medium transition
#{params[:status] == s ? 'bg-indigo-600 text-white' :
'bg-gray-100 text-gray-600 hover:bg-gray-200'}",
data: { turbo_frame: "feature_list" } %>
<% end %>
</div>
<%# Sort toggle %>
<div class="flex gap-4 mb-4 text-sm text-gray-500">
Sort:
<%= link_to "Most Voted",
feature_requests_path(sort: "votes", status: params[:status]),
class: "#{'font-bold text-indigo-600' if params[:sort] != 'recent'}",
data: { turbo_frame: "feature_list" } %>
|
<%= link_to "Newest",
feature_requests_path(sort: "recent", status: params[:status]),
class: "#{'font-bold text-indigo-600' if params[:sort] == 'recent'}",
data: { turbo_frame: "feature_list" } %>
</div>
<%# Real-time list — broadcasts_refreshes handles live updates %>
<div id="feature_requests"
class="space-y-3"
data-turbo-stream-source="<%= turbo_stream_from 'feature_requests' %>">
<%= render @feature_requests %>
</div>
<div class="mt-6">
<%== pagy_nav(@pagy) %>
</div>
<% end %>
</div>
<%# app/views/feature_requests/_feature_request.html.erb %>
<%= turbo_frame_tag dom_id(feature_request) do %>
<div class="flex items-start gap-4 p-4 bg-white rounded-lg border
border-gray-200 hover:border-indigo-300 transition-colors">
<%# Vote button — instant Turbo Stream update %>
<div id="vote_button_<%= feature_request.id %>">
<%= render "feature_requests/vote_button",
feature_request: feature_request %>
</div>
<div class="flex-1 min-w-0">
<%= link_to feature_request_path(feature_request),
class: "block",
data: { turbo_frame: "_top" } do %>
<h3 class="text-lg font-semibold text-gray-900 truncate">
<%= feature_request.title %>
</h3>
<% end %>
<p class="text-sm text-gray-500 mt-1 line-clamp-2">
<%= feature_request.body %>
</p>
<div class="flex items-center gap-3 mt-2 text-xs text-gray-400">
<span class="px-2 py-0.5 rounded-full text-xs font-medium
<%= feature_request.status_badge_class %>">
<%= feature_request.status.titleize %>
</span>
<span><%= feature_request.user.email.split("@").first %></span>
<span><%= time_ago_in_words(feature_request.created_at) %> ago</span>
<span id="comment_count_<%= feature_request.id %>">
💬 <%= feature_request.comments.count %>
</span>
</div>
</div>
</div>
<% end %>
<%# app/views/feature_requests/_vote_button.html.erb %>
<%# This partial gets swapped via Turbo Stream. No JS. %>
<%= button_to feature_request_votes_path(feature_request),
method: :post,
class: "flex flex-col items-center p-2 rounded-lg transition
#{feature_request.voted_by?(current_user) ?
'bg-indigo-100 text-indigo-700' :
'bg-gray-50 text-gray-400 hover:bg-gray-100'}",
data: { turbo_method: :post } do %>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 3l-7 7h4v7h6v-7h4L10 3z"/>
</svg>
<span class="text-sm font-bold mt-1">
<%= feature_request.votes_count %>
</span>
<% end %>
# ============================================
# PHASE 6: Routes — RESTful. Clean. Done.
# ============================================
# config/routes.rb
Rails.application.routes.draw do
devise_for :users
resources :feature_requests do
resources :comments, only: [:create]
resources :votes, only: [:create]
end
root "feature_requests#index"
end
# ============================================
# PHASE 7: Stimulus — only when HTML can't
# ============================================
# (spoiler: that's rare)
# app/javascript/controllers/character_count_controller.js
import { Controller } from "@hotwired/stimulus"
// This is the ONLY JS I wrote for this entire feature.
// React devs are currently writing their 47th useState hook.
export default class extends Controller {
static targets = ["input", "count"]
static values = { max: { type: Number, default: 500 } }
connect() { this.update() }
update() {
const remaining = this.maxValue - this.inputTarget.value.length
this.countTarget.textContent = remaining
this.countTarget.classList.toggle("text-red-500", remaining < 50)
}
}
# ============================================
# PHASE 8: Seed + Demo in under 2 minutes
# ============================================
cat > db/seeds.rb << 'SEED'
puts "🌱 Seeding..."
10.times do
User.create!(
email: Faker::Internet.unique.email,
password: "password123"
)
end
users = User.all
30.times do
fr = FeatureRequest.create!(
user: users.sample,
title: Faker::Company.catch_phrase,
body: Faker::Lorem.paragraph(sentence_count: 4),
status: FeatureRequest::STATUS_COLORS.keys.sample,
priority: %w[low medium high critical].sample
)
rand(0..15).times do
fr.votes.create(user: users.sample) rescue nil
end
fr.update_column(:votes_count, fr.votes.count)
rand(0..5).times do
fr.comments.create!(
user: users.sample,
body: Faker::Lorem.sentence(word_count: rand(8..30))
)
end
end
puts "✅ #{FeatureRequest.count} requests, #{Vote.count} votes, #{Comment.count} comments"
SEED
rails db:seed
# ============================================
# TIMELINE RECAP
# ============================================
{
"Phase 0 — Rails new + gems": "5 minutes",
"Phase 1 — Database schema": "10 minutes",
"Phase 2 — Models + validations": "15 minutes",
"Phase 3 — Controllers": "15 minutes",
"Phase 4 — Turbo Streams + voting": "20 minutes",
"Phase 5 — Views + Tailwind": "30 minutes",
"Phase 6 — Routes": "2 minutes",
"Phase 7 — Stimulus (one controller)": "5 minutes",
"Phase 8 — Seeds + demo": "3 minutes",
# -----------------------------------------
"TOTAL": "~1 hour 45 minutes"
}
# What you get:
# ✅ Real-time voting (no page reload)
# ✅ Live comments (broadcasted to all viewers)
# ✅ Filter + sort with Turbo Frames (instant)
# ✅ Status badges, pagination, auth
# ✅ Mobile-responsive out of the box
# ✅ ZERO npm packages. ZERO webpack. ZERO bundler drama.
# ✅ Deployable to Fly.io / Render / Hatchbox in minutes.
# Your move, Next.js.
# — Aaliyah ✌️