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
endThis 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) %>
<%= 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
endThe 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
endWhen 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.