layouts-and-rendering

Installation
SKILL.md

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

  1. Per-action: render layout: "special" in the action
  2. Per-controller: layout "admin" declaration
  3. Convention: app/views/layouts/photos.html.erb for PhotosController
  4. 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:

  1. Documents expected locals
  2. 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

  1. Instance variables in partials — Always pass locals explicitly
  2. render thinking it's redirect_to — render doesn't change URL or re-run actions
  3. Missing status: :unprocessable_entity — Turbo won't replace page without 422
  4. Missing status: :see_other — Turbo may not follow redirects after POST/PUT/DELETE
  5. render inline: — Defeats MVC. Use a template
  6. Double render without return — Use return render :action pattern
  7. redirect_to for form errors — Redirect loses @record.errors; render instead
  8. Forgetting fallback_location with redirect_back — Will raise if no referer
  9. render file: with user input — Path traversal vulnerability
  10. content_for when provide sufficesprovide is streaming-friendly

See reference.md in this skill directory for detailed rendering patterns, layout examples, and edge cases.

Related skills

More from thinkoodle/rails-skills

Installs
4
GitHub Stars
4
First Seen
7 days ago