ruby erb 70 lines · 3 tabs

Use 303 See Other after POST in Turbo flows

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby, erb Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
typescript
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

typescript reliability retry
by codesnips 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Use 303 See Other after POST in Turbo flows — share card
Link copied