Rails Layouts & Rendering Expert
Render the right thing, the right way, with the right status code.
The #1 Rule: Partials Use Locals, Not Instance Variables
# ❌ WRONG — implicit coupling, untestable, will break
<%= render "product" %>
# _product.html.erb uses @product
# ✅ RIGHT — explicit, testable, reusable
<%= render partial: "product", locals: { product: @product } %>
# or shorthand:
<%= render "product", product: @product %>
Every partial gets its data through locals. No exceptions. Instance variables in partials create invisible coupling between controllers and views that breaks when partials are reused.
The #2 Rule: render vs redirect_to
These do fundamentally different things. Getting this wrong is the most common agent mistake.
| | render | redirect_to |
|---|---|---|
| What it does | Renders a template in the CURRENT request | Sends HTTP 302, browser makes NEW request |
| Instance variables | Available (same request) | Gone (new request) |
| URL in browser | Stays the same | Changes to new URL |
| Use when | Showing errors, displaying content | After successful mutations |
| HTTP round trips | 0 (same request) | 1 (browser → server again) |
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post # ← Success: redirect (browser gets new URL)
else
render :new, status: :unprocessable_entity # ← Failure: render (keep form data)
end
end
Critical: render :action_name does NOT run that action's code. It only uses the template. If index.html.erb needs @posts, rendering :index from show won't set @posts — you must set it yourself or redirect instead.
When To Use This Skill
- Choosing between render, redirect_to, and head
- Setting up layouts (per-controller, per-action, conditional, nested)
- Creating partials with proper local variables
- Using content_for / yield for multi-section layouts
- Rendering collections efficiently
- Handling Turbo/Hotwire status codes correctly
- Streaming responses
- Rendering JSON/XML/plain text/HTML from controllers
Instructions
Step 1: Choose the Right Response Type
# Full HTML response (most common)
render :show # Convention: renders show.html.erb
render "products/show" # Cross-controller template
render partial: "form", locals: { post: @post }
# Data responses
render json: @product # Auto-calls .to_json
render xml: @product # Auto-calls .to_xml
render plain: "OK" # text/plain, no layout
render html: helpers.tag.strong("Hi") # HTML fragment
# Redirect (after successful mutation)
redirect_to @product # 302 by default
redirect_to products_path, status: :see_other # 303 for Turbo
redirect_back fallback_location: root_path
# Headers only
head :no_content # 204, for API delete
head :created, location: photo_url(@photo)
Step 2: Get Status Codes Right (Turbo-Critical)
With Turbo Drive (Rails 7+), status codes determine behavior:
# After failed validation — MUST be 422 for Turbo to replace the page
render :new, status: :unprocessable_entity # 422
# After successful redirect — MUST be 303 for Turbo
redirect_to @post, status: :see_other # 303
# Turbo Stream responses
render turbo_stream: turbo_stream.remove(@post) # 200 OK
| Scenario | Status | Why |
|----------|--------|-----|
| Validation failed, re-render form | 422 :unprocessable_entity | Turbo replaces page content |
| Successful create/update | 303 :see_other (redirect) | Turbo follows redirect with GET |
| Destroy success | 303 :see_other (redirect) | Same reason |
| API success | 200 :ok or 201 :created | Standard API convention |
| Not found | 404 :not_found | Standard |
If you use redirect_to without status: :see_other in a Turbo app, Turbo may not follow the redirect correctly after form submissions.
Step 3: Layouts
How Rails Finds Layouts
- Per-action:
render layout: "special"in the action - Per-controller:
layout "admin"declaration - Convention:
app/views/layouts/photos.html.erbforPhotosController - Fallback:
app/views/layouts/application.html.erb
# Per-controller layout
class AdminController < ApplicationController
layout "admin"
end
# Conditional layout
class ProductsController < ApplicationController
layout "product", except: [:index, :rss]
end
# Runtime layout selection
class ProductsController < ApplicationController
layout :choose_layout
private
def choose_layout
current_user&.admin? ? "admin" : "application"
end
end
# Per-action override
def special
render layout: "minimal"
end
# No layout at all
def api_endpoint
render json: @data, layout: false
end
Layout Inheritance
Layouts cascade down the controller hierarchy:
class ApplicationController < ActionController::Base
layout "main" # All controllers use "main"
end
class ArticlesController < ApplicationController
# Inherits "main" layout
end
class SpecialArticlesController < ArticlesController
layout "special" # Overrides to "special"
end
class ApiController < ApplicationController
layout false # No layout at all
end
Nested Layouts (Sub-Templates)
Use content_for + render template: to extend a parent layout:
<%# app/views/layouts/admin.html.erb — extends application layout %>
<% content_for :head do %>
<%= stylesheet_link_tag "admin" %>
<% end %>
<% content_for :content do %>
<div class="admin-sidebar"><%= yield :sidebar %></div>
<div class="admin-main"><%= yield %></div>
<% end %>
<%= render template: "layouts/application" %>
The application layout needs to support this:
<%# app/views/layouts/application.html.erb %>
<html>
<head><%= yield :head %></head>
<body>
<%= content_for?(:content) ? yield(:content) : yield %>
</body>
</html>
Step 4: Partials — Always Use Locals
Basic Partial Rendering
# Explicit (preferred when passing locals)
<%= render partial: "form", locals: { post: @post } %>
# Shorthand (works for simple cases)
<%= render "form", post: @post %>
# Model shorthand — renders _post.html.erb with local `post`
<%= render @post %>
# Cross-directory partial
<%= render "shared/navbar", current_user: @user %>
The Partial Contract
Every partial should document its expected locals at the top:
<%# app/views/posts/_post.html.erb %>
<%# locals: (post:, show_actions: true) %>
<article>
<h2><%= post.title %></h2>
<p><%= post.body %></p>
<% if show_actions %>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
</article>
The magic comment <%# locals: (post:, show_actions: true) %> (Rails 7.1+) does two things:
- Documents expected locals
- Raises errors if required locals are missing
Optional Locals (Before Rails 7.1)
<%# Check with local_assigns for optional params %>
<% if local_assigns[:full] %>
<%= simple_format article.body %>
<% else %>
<%= truncate article.body %>
<% end %>
Step 5: Collection Rendering
Always use collection rendering for lists. It's faster (single render call) and cleaner.
# ❌ SLOW — N render calls
<% @products.each do |product| %>
<%= render partial: "product", locals: { product: product } %>
<% end %>
# ✅ FAST — single render call, Rails optimizes internally
<%= render partial: "product", collection: @products %>
# ✅ FASTEST shorthand — Rails infers partial name from model
<%= render @products %>
# Empty collection handling
<%= render(@products) || "No products yet." %>
Collection Features
# Custom local variable name
<%= render partial: "product", collection: @products, as: :item %>
# Counter variable (0-indexed) — available as product_counter
# Inside _product.html.erb: product_counter gives 0, 1, 2...
# Spacer template — rendered between items
<%= render partial: @products, spacer_template: "product_divider" %>
# Extra locals passed to every item
<%= render partial: "product", collection: @products,
locals: { show_price: true } %>
# Layout for each item
<%= render partial: "product", collection: @products, layout: "card" %>
Heterogeneous Collections
# Rails picks the right partial based on model class
<%= render [customer1, employee1, customer2] %>
# Renders customers/_customer.html.erb and employees/_employee.html.erb
Step 6: content_for and yield
Use content_for to inject content into named regions of your layout.
<%# Layout: app/views/layouts/application.html.erb %>
<html>
<head>
<title><%= yield :title %></title>
<%= yield :head %>
</head>
<body>
<%= yield :breadcrumbs %>
<%= yield %> <%# Main content (unnamed yield) %>
</body>
</html>
<%# View: app/views/posts/show.html.erb %>
<% content_for :title, @post.title %>
<% content_for :head do %>
<%= tag.meta name: "description", content: @post.excerpt %>
<% end %>
<% content_for :breadcrumbs do %>
<nav>Posts > <%= @post.title %></nav>
<% end %>
<article>
<h1><%= @post.title %></h1>
<%= simple_format @post.body %>
</article>
content_for? — Conditional Sections
<%# Only render sidebar wrapper if content exists %>
<% if content_for?(:sidebar) %>
<aside><%= yield :sidebar %></aside>
<% end %>
provide vs content_for
provide :title, "My Page" # Sets once, stops looking (streaming-friendly)
content_for :title, "My Page" # Appends, can be called multiple times
Use provide for single values (page title). Use content_for for accumulated content (multiple script tags).
Step 7: Avoid Double Render Errors
Rails raises AbstractController::DoubleRenderError if you render/redirect twice.
# ❌ BUG — both render calls execute
def show
@book = Book.find(params[:id])
if @book.special?
render :special_show
end
render :regular_show # Always runs!
end
# ✅ FIX — return after render
def show
@book = Book.find(params[:id])
if @book.special?
return render :special_show
end
render :regular_show
end
# ✅ ALSO FINE — implicit render for else case
def show
@book = Book.find(params[:id])
render :special_show if @book.special?
# Implicit render of :show if special? is false
end
The return render pattern is the cleanest for conditional rendering.
Step 8: Template Inheritance
Controllers inherit template lookup from parent controllers:
# Lookup order for Admin::ProductsController#index:
# 1. app/views/admin/products/index.html.erb
# 2. app/views/admin/index.html.erb
# 3. app/views/application/index.html.erb
This makes app/views/application/ ideal for shared partials:
<%# app/views/application/_empty_list.html.erb %>
<p>No items yet.</p>
<%# Usable from any controller's view: %>
<%= render(@products) || render("empty_list") %>
Quick Reference
render Cheat Sheet
# Templates
render :edit # Same controller template
render "edit" # Same (string)
render "products/show" # Other controller template
render template: "products/show" # Explicit
# Data
render json: @product # JSON
render xml: @product # XML
render plain: "OK" # Plain text
render html: "<b>Hi</b>".html_safe # HTML fragment
render body: "raw" # Raw body, no content type
render js: "alert('hi')" # JavaScript
# Files
render file: Rails.root.join("public/404.html"), layout: false
render inline: "<%= 1 + 1 %>" # Don't use this
# Options (combinable)
render :edit, status: :unprocessable_entity
render :show, layout: "minimal"
render :show, layout: false
render :show, content_type: "application/rss"
render :show, formats: [:json]
render :show, variants: [:mobile]
redirect_to Cheat Sheet
redirect_to @post # record → show path
redirect_to posts_path # named route
redirect_to "https://example.com" # URL
redirect_to action: :index # hash
redirect_back fallback_location: root_path # back button
# With status (important for Turbo!)
redirect_to @post, status: :see_other # 303
redirect_to posts_path, status: 301 # permanent
# With flash
redirect_to @post, notice: "Created!"
redirect_to @post, alert: "Problem!"
Standard CRUD Controller Pattern (Turbo-Aware)
class PostsController < ApplicationController
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post, notice: "Created!", status: :see_other
else
render :new, status: :unprocessable_entity
end
end
def update
@post = Post.find(params[:id])
if @post.update(post_params)
redirect_to @post, notice: "Updated!", status: :see_other
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@post = Post.find(params[:id])
@post.destroy!
redirect_to posts_path, notice: "Deleted!", status: :see_other
end
end
Anti-Patterns
- Instance variables in partials — Always pass locals explicitly
renderthinking it'sredirect_to— render doesn't change URL or re-run actions- Missing
status: :unprocessable_entity— Turbo won't replace page without 422 - Missing
status: :see_other— Turbo may not follow redirects after POST/PUT/DELETE render inline:— Defeats MVC. Use a template- Double render without return — Use
return render :actionpattern redirect_tofor form errors — Redirect loses@record.errors; render instead- Forgetting
fallback_locationwithredirect_back— Will raise if no referer render file:with user input — Path traversal vulnerabilitycontent_forwhenprovidesuffices —provideis streaming-friendly
See reference.md in this skill directory for detailed rendering patterns, layout examples, and edge cases.