class SubscriptionsController < ApplicationController
before_action :set_subscription, only: %i[show destroy]
def new
@subscription = Subscription.new
end
def create
@subscription = current_account.subscriptions.build(subscription_params)
respond_to do |format|
if @subscription.save
format.turbo_stream
format.html { redirect_to @subscription, status: :see_other, notice: "Subscription created." }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
def destroy
@subscription.destroy!
redirect_to subscriptions_path, status: :see_other, notice: "Subscription cancelled."
end
private
def set_subscription
@subscription = current_account.subscriptions.find(params[:id])
end
def subscription_params
params.require(:subscription).permit(:plan_id, :seats)
end
end
<%= form_with model: @subscription, class: "subscription-form" do |form| %>
<% if @subscription.errors.any? %>
<div class="errors" role="alert">
<%= pluralize(@subscription.errors.count, "error") %> prevented saving:
<ul>
<% @subscription.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :plan_id %>
<%= form.collection_select :plan_id, Plan.active, :id, :name %>
</div>
<div class="field">
<%= form.label :seats %>
<%= form.number_field :seats, min: 1 %>
</div>
<%= form.submit "Subscribe" %>
<% end %>
<%= turbo_stream.append "subscriptions" do %>
<%= render partial: "subscriptions/subscription", locals: { subscription: @subscription } %>
<% end %>
<%= turbo_stream.replace "new_subscription" do %>
<%= render partial: "subscriptions/form", locals: { subscription: Subscription.new } %>
<% end %>
<%= turbo_stream.update "flash" do %>
<div class="notice">Subscription created.</div>
<% end %>
Turbo intercepts form submissions and uses fetch under the hood, which changes how HTTP redirects behave compared to classic browser navigation. When a form is submitted with a non-GET method and the server responds with a plain 302 Found, the browser (and Turbo) will re-issue the follow-up request using the same method — so a POST redirect can become another POST to the target URL, causing duplicate creates or unexpected 405s. The fix Rails bakes in is to respond with 303 See Other, which is defined by the spec to force the follow-up request to be a GET regardless of the original method.
In SubscriptionsController, the standard Post/Redirect/Get pattern is shown for both the success and failure paths. On a successful create, redirect_to @subscription, status: :see_other sends the 303 so Turbo re-fetches the show page as a GET. On validation failure the controller renders :new with status: :unprocessable_entity (422) — Turbo only replaces the page content on non-2xx responses, so returning 422 is what lets the re-rendered form with error messages actually appear. Returning 200 on a failed render would leave the broken form invisible to the user.
The destroy action shows the same rule for deletions: after removing the record, redirect_to subscriptions_path, status: :see_other keeps Turbo from replaying the DELETE.
The new subscription form uses form_with, which defaults to local: false so Turbo drives the submission. No special client code is needed; the status codes carry all the semantics.
The create.turbo_stream.erb tab illustrates the alternative: instead of redirecting at all, the action can respond with a Turbo Stream that appends the new row and resets the form. This avoids the redirect question entirely, but the 303/422 convention still matters for any HTML fallback and for clients without Turbo. The trade-off is that Turbo Stream responses are more surgical but tie the controller to specific DOM ids, while the redirect approach is simpler and degrades gracefully. Reaching for :see_other should be the default habit for any redirect that follows a mutating request in a Hotwire app.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.