Rails 8 Request Variants for Device UIs

Modern web applications need to serve different experiences to different devices. While responsive CSS handles many cases, sometimes the markup itself needs to change—mobile users might need a simplified navigation, tablets might get a touch-optimized interface, and desktops might show data-dense layouts.

Rails request variants solve this elegantly. Instead of cluttering views with device conditionals or building separate APIs, variants let controllers serve different templates based on the requesting device. The same action, different presentations.

How Request Variants Work

Request variants extend Rails' template lookup. When a variant is set, Rails looks for templates with that variant suffix before falling back to the default. Set request.variant = :mobile, and Rails will prefer index.html+mobile.erb over index.html.erb.

The variant becomes part of the format chain: format first, then variant. This means show.html+tablet.erb serves HTML to tablets, while show.json.erb still handles API requests normally.

Setting Up Device Detection

The first step is detecting the device type. While user-agent parsing isn't perfect, it works reliably for the major categories. A before_action in ApplicationController handles this globally:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_request_variant

  private

  def set_request_variant
    request.variant = device_variant
  end

  def device_variant
    user_agent = request.user_agent.to_s.downcase

    if user_agent.match?(/iphone|android.*mobile|windows phone|mobile/)
      :mobile
    elsif user_agent.match?(/ipad|android(?!.*mobile)|tablet/)
      :tablet
    else
      :desktop
    end
  end
end

This detection covers the common cases. For production applications requiring precision, dedicated user-agent parsing gems provide more thorough device databases, but the pattern remains the same.

Creating Variant Templates

With detection in place, create variant-specific templates by adding the variant name before the format extension. Rails uses a plus sign as the separator:

# app/views/products/index.html.erb (default/desktop)
<% @products.each do |product| %>
<%= image_tag product.featured_image, class: "product-image" %>

<%= link_to product.name, product %>

<%= truncate(product.description, length: 200) %>

<%= number_to_currency(product.price) %> <%= product.stock_status %> <%= render_stars(product.average_rating) %>
<%= button_to "Add to Cart", cart_items_path(product_id: product.id), class: "btn-primary" %>
<% end %>
# app/views/products/index.html+mobile.erb
<% @products.each do |product| %>
<%= image_tag product.thumbnail, class: "product-thumb" %>

<%= link_to product.name, product %>

<%= number_to_currency(product.price) %>
<%= button_to "+", cart_items_path(product_id: product.id), class: "btn-add-quick" %>
<% end %>
# app/views/products/index.html+tablet.erb
<% @products.each do |product| %>
<%= image_tag product.featured_image, class: "product-image", data: { action: "click->zoom#open" } %>

<%= link_to product.name, product %>

<%= number_to_currency(product.price) %> <%= button_to "Add to Cart", cart_items_path(product_id: product.id), class: "btn-touch-large" %>
<% end %>

The mobile variant strips down to essentials—thumbnail, name, price, quick-add button. The tablet variant keeps more detail but uses touch-friendly targets. The desktop version shows full product cards with descriptions and metadata.

Responding to Multiple Variants in Controllers

Sometimes different devices need different data, not just different templates. The respond_to block accepts variant specifications:

# app/controllers/dashboards_controller.rb
class DashboardsController < ApplicationController
  def show
    @user = Current.user

    respond_to do |format|
      format.html do |variant|
        variant.mobile do
          @recent_orders = @user.orders.recent.limit(3)
          @notifications = @user.notifications.unread.limit(5)
        end

        variant.tablet do
          @recent_orders = @user.orders.recent.limit(5)
          @notifications = @user.notifications.unread.limit(10)
          @quick_stats = DashboardStats.new(@user).summary
        end

        variant.desktop do
          @recent_orders = @user.orders.recent.limit(10)
          @notifications = @user.notifications.unread
          @quick_stats = DashboardStats.new(@user).detailed
          @activity_chart = @user.activity_data(days: 30)
        end

        variant.none do
          @recent_orders = @user.orders.recent.limit(10)
          @notifications = @user.notifications.unread
          @quick_stats = DashboardStats.new(@user).detailed
          @activity_chart = @user.activity_data(days: 30)
        end
      end
    end
  end
end

The variant.none block handles cases where no variant matches—useful as a fallback. Mobile gets minimal data to keep the page light; desktop loads the full dashboard with charts and detailed statistics.

Sharing Partials Across Variants

Not everything needs variant-specific markup. Partials can be shared or overridden selectively. Rails looks for variant-specific partials first, then falls back to the default:

# app/views/orders/_order.html.erb (used by all variants)
#<%= order.number %> <%= number_to_currency(order.total) %> <%= order.status.humanize %>
# app/views/orders/_order_actions.html.erb (desktop default)
<%= link_to "View Details", order, class: "btn" %> <%= link_to "Download Invoice", order_invoice_path(order), class: "btn" %> <%= link_to "Track Shipment", order_tracking_path(order), class: "btn" if order.shipped? %> <%= button_to "Reorder", reorder_path(order), class: "btn btn-primary" %>
# app/views/orders/_order_actions.html+mobile.erb (mobile override)
<%= link_to "View", order, class: "btn-icon" %> <%= button_to "Reorder", reorder_path(order), class: "btn-primary-mobile" %>

The order partial stays consistent across devices—it's simple enough. The actions partial gets a mobile override that shows fewer options with touch-friendly buttons.

Testing Variants

Request specs can set variants directly to verify each device path renders correctly:

# spec/requests/products_spec.rb
RSpec.describe "Products", type: :request do
  describe "GET /products" do
    let!(:products) { create_list(:product, 5) }

    it "renders desktop variant by default" do
      get products_path

      expect(response).to be_successful
      expect(response.body).to include("product-grid")
    end

    it "renders mobile variant for mobile devices" do
      get products_path, headers: {
        "HTTP_USER_AGENT" => "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)"
      }

      expect(response).to be_successful
      expect(response.body).to include("product-list-mobile")
    end

    it "renders tablet variant for tablet devices" do
      get products_path, headers: {
        "HTTP_USER_AGENT" => "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X)"
      }

      expect(response).to be_successful
      expect(response.body).to include("product-grid-tablet")
    end
  end
end

When to Use Variants vs. Responsive CSS

Variants shine when devices need fundamentally different markup: different navigation structures, different data density, different interaction patterns. Responsive CSS works better for the same content at different sizes.

Use variants for: navigation menus that become drawers, tables that become cards, multi-column layouts that need complete restructuring, or pages that should load different amounts of data.

Stick with responsive CSS for: adjusting spacing, resizing images, reflowing grids, or hiding secondary elements.

Summary

Request variants provide a clean Rails-native solution for serving device-appropriate interfaces. Set the variant in a before_action, create templates with the variant suffix, and optionally customize controller logic per variant. The result is maintainable device-specific experiences without framework gymnastics or API duplication.

10 claps
← Back to Blog