---
title: "How Rails builds a page with content_for and yield"
date: 2026-08-20T16:00:00
tags: [rails, ruby, erb, templates, frontend, backend, tech]
description: "How Rails assembles templates: why the layout renders last, where content_for content lives, what happens to a section nobody yields, and how the Rails model differs from Jinja2, Twig, Go html/template, and Handlebars."
embedding:
  version: 1
  model: "text-embedding-3-small"
  dimensions: 512
  hash: "sha256:39fddee9975c128585b3e588d347d9a0d185905d5bc90f99557cae6d103e3dd3"
  vector: "/h8H7SjR68/5+y339v8k+Ajh/hk6vxgyFvoMEPr2Dt73viL+JAI5EhLw5Oo8Hhr3BE4LE9L/yvCn/RLoMs/0DuYAr+MZGDgCGfkQ3z0rFe0+7sq6Bdfl+ej4zCXRJiIn2zz6JWbOzuJD2dcs+t2Y9vgj5dgNJu3xwgj49v3NG+Dr5jsFDM/iE/wX/kDq8BMV0V/b99jjK//a//OX+vcE3+IE7//E3Nj1sO0D/tsDCdzy0Psi+TDwIN8L/AXlIuwJDCDz+znsIDYSQjnoEx/n6svmCPsQHv7u8RsQLwc8Eh0BIyTp/AZBHuzSIN3K8/7CA/z49hUSz8o2LVL4yPPpwBg5/eXUJeEE1joJ+CzlOEEg/NTJGfnrEuT6Dh/l/u5AMyHqChwDFxox8Q8CFNQZEmLz8BgSDr4nwNnM/EP9HtUHyt4EHAwg8M/y7tDz7vmr4CULLsXT3PYB7uEHFSr79CYk8f0V170W1uruMBFd4R4JKR5T/93L6hASFBIEKhjZAQIF58MLDf3kBiEvMB4zDTQt0tb59TPi7fU/+hQC9dcj2h04A7HR7NIqPsr9vOQvKhQKAuYNDQIt+eoIgQQH3xDA7RXqB/H1QRob7gxk1gL+FCoGMd748e3CzELYCdnRDNf+9vZj9wQV4Ob9MOrz9M/SJ7rjIQkZ8dsiBvkD8P8="
  scale: 0.0012291635
---

![content_for and yield in Rails](/assets/img/ru/blog/tech/frontend/img/content-for-yield-rails-preview.jpg)

This article is about one small pair of tools: `yield` and `content_for`. They look trivial until you have to answer "in what order does this actually run" - and especially "what happens if I declare a section that nobody outputs".

Everything below was verified on ActionView 6.1; the experiment code is included.

<!-- truncate -->

## The problem

A layout is a frame around the page. The problem is that the frame needs data only the concrete page knows: the tab title, social `<meta>` tags, its own CSS, breadcrumbs, buttons in the header.

The naive solution is instance variables:

```erb
<%# app/controllers/products_controller.rb %>
@page_title = "Nike sneakers"

<%# app/views/layouts/application.html.erb %>
<title><%= @page_title %></title>
```

It works for strings and falls apart on markup: if the title needs a `<span>` or a link, you start assembling HTML in the controller. `content_for` solves exactly that - it lets a template send a piece of **markup** upward into the layout.

## Three words of vocabulary

```erb
<%# layouts/application.html.erb %>
<head>
  <title><%= yield :title %></title>
  <%= yield :head %>
</head>
<body class="<%= content_for?(:sidebar) ? 'two-column' : 'one-column' %>">
  <main><%= yield %></main>
  <%= yield :sidebar %>
</body>
```

```erb
<%# products/show.html.erb %>
<% content_for :title, "Nike sneakers" %>

<% content_for :head do %>
  <meta property="og:image" content="<%= @product.image_url %>">
<% end %>

<h1><%= @product.name %></h1>
```

- `yield` with no argument - the main page body;
- `yield :name` - a named slot;
- `content_for :name` - filling a slot (with a block or a string);
- `content_for?(:name)` - checking that the slot is non-empty, without outputting it.

The difference between reading via `yield :name` and `content_for :name` is one practical thing: `yield` works only inside templates, `content_for` also works inside helpers.

## Assembly order: bottom-up

The main non-obvious part: **the layout renders last**. Not first, the way intuition suggests ("frame first, then content").

Here is the Rails code that does it - `actionview/lib/action_view/renderer/template_renderer.rb`:

```ruby
def render_with_layout(view, template, path, locals)
  layout = path && find_layout(path, locals.keys, [formats.first])

  body = if layout
    # yield(layout) here renders the main template.
    # Its result is stored in the buffer under the key :layout ...
    view.view_flow.set(:layout, yield(layout))
    # ... and only then is the layout itself rendered
    layout.render(view, locals) { |*name| view._layout_for(*name) }
  else
    yield
  end
  build_rendered_template(body, template)
end
```

And bare `yield` inside a layout is simply reading that same buffer (`action_view/context.rb`):

```ruby
def _layout_for(name = nil)
  name ||= :layout
  view_flow.get(name).html_safe
end
```

So `<%= yield %>` in a layout does not "run" the template. The template has already finished; `yield` pulls a ready string out of a hash under the key `:layout`.

### Experiment

We will build a mini stand on bare ActionView and record the real call order:

```ruby
require "action_view"
require "action_view/testing/resolvers"

TEMPLATES = {
  "layouts/app.html.erb" => <<~ERB,
    <% $log << "layout: start" %>
    HEAD[<%= yield :head %>]
    BODY[<%= yield %>]
    <% $log << "layout: end" %>
  ERB
  "page.html.erb" => <<~ERB,
    <% $log << "page: start" %>
    <% content_for :head do %><% $log << "page: content_for(:head) block" %>meta<% end %>
    <% content_for :nowhere do %><% $log << "page: content_for(:nowhere) block" %>wasted<% end %>
    <%= render "widget" %>
    <% $log << "page: end" %>
  ERB
  "_widget.html.erb" => <<~ERB,
    <% $log << "partial: runs" %>
    <% content_for :head do %><% $log << "partial: content_for(:head) block" %>+css<% end %>
    <widget>
  ERB
}

$log = []
lookup = ActionView::LookupContext.new([ActionView::FixtureResolver.new(TEMPLATES)])
view = ActionView::Base.with_empty_template_cache.new(lookup, {}, nil)
puts view.render(template: "page", layout: "layouts/app")
puts $log
```

Output:

```
1. page: start
2. page: content_for(:head) block
3. page: content_for(:nowhere) block
4. partial: runs
5. partial: content_for(:head) block
6. page: end
7. layout: start
8. layout: end

HEAD[meta+css]
BODY[
<widget>
]
```

Notice: `HEAD` stands **above** `BODY` in the finished HTML, even though it ran **later**. Source order and execution order are different things.

You also see that `content_for` from a partial quietly reached the layout and was **appended** to what the page put there: `meta+css`. By default `content_for` concatenates rather than replaces.

```mermaid
sequenceDiagram
    participant C as Controller
    participant TR as TemplateRenderer
    participant V as page.html.erb
    participant P as _widget.html.erb
    participant F as view_flow (Hash)
    participant L as layouts/app.html.erb

    C->>TR: render :show
    TR->>V: render (template first!)
    V->>F: content_for :head -> "meta"
    V->>F: content_for :nowhere -> "wasted"
    V->>P: render "widget"
    P->>F: content_for :head -> += "+css"
    P-->>V: widget HTML
    V-->>TR: page body
    TR->>F: set(:layout, page body)
    TR->>L: render (layout last)
    L->>F: yield :head -> get(:head)
    L->>F: yield -> get(:layout)
    L-->>C: finished HTML
```

## Where the content lives

All of this is one `ActionView::OutputFlow` object that lives for exactly one request (`action_view/flows.rb`):

```ruby
class OutputFlow
  def initialize
    @content = Hash.new { |h, k| h[k] = ActiveSupport::SafeBuffer.new }
  end

  def get(key)
    @content[key]
  end

  def set(key, value)
    @content[key] = ActiveSupport::SafeBuffer.new(value)
  end

  def append(key, value)
    @content[key] << value
  end
end
```

A hash of strings, and nothing more. The page body is stored there too, under the reserved key `:layout`, next to your `:head` and `:sidebar`.

`content_for` itself is also simple:

```ruby
def content_for(name, content = nil, options = {}, &block)
  if content || block_given?
    if block_given?
      options = content if content
      content = capture(&block)   # <-- the block runs HERE, immediately
    end
    if content
      options[:flush] ? @view_flow.set(name, content) : @view_flow.append(name, content)
    end
    nil
  else
    @view_flow.get(name).presence
  end
end
```

The key line is `capture(&block)`. `capture` swaps the output buffer, runs the block fully, and returns the accumulated string. There is no laziness: the block executes when it is encountered, whether anyone will need it or not.

## What if there is `content_for` but no `yield`?

That was the original question. The answer: **the block runs, HTML is built, the string lands in the hash and sits there until the end of the request, then GC collects it**.

In the experiment above, the `:nowhere` section was never output. Check the buffer state after render:

```ruby
p view.view_flow.content.keys
# => [:head, :nowhere, :layout]

p view.view_flow.content[:nowhere].to_s
# => "wasted"
```

The log line `3. page: content_for(:nowhere) block` is the proof: the work was done. Rails does not analyze the layout ahead of time and does not know which slots anyone is waiting for.

### How much does it cost

Let's measure. 200 "garbage" `content_for` calls inside a loop on one page, 300 renders:

```ruby
Benchmark.bm(22) do |x|
  x.report("without content_for") { N.times { run(a) } }
  x.report("content_for to nowhere") { N.times { run(b) } }
end
```

```
                             user     system      total        real
without content_for      0.079012   0.004745   0.083757 (  0.083810)
content_for to nowhere   0.179607   0.007918   0.187525 (  0.187599)

allocated objects: without=1852  with garbage content_for=3499
```

The render became 2.2x slower, and almost twice as many objects were allocated. But recalculated per call: **about 1.7 microseconds and ~8 objects**.

Practical takeaway from these numbers:

- **One or two "dead" `content_for` calls on a page are noise.** Against a database query they are invisible. There is nothing to optimize here.
- **`content_for` inside a loop or inside a collection partial is real money.** A thousand table rows, each with its own `content_for`, means milliseconds and GC garbage for no good reason.
- **If the block hits the database or a helper with logic, the cost is no longer in microseconds.** `capture` will run everything honestly, including `@product.reviews.count`.

### The real problem is not performance

It is silence. Rails **gives no warning** if a name does not match:

```erb
<%# layout %>
<%= yield :sidebar %>

<%# view %>
<% content_for :side_bar do %>...<% end %>
```

Tests stay green, the page renders, the sidebar is empty. Debugging this means eyeballing two names in different files.

A couple of practices that help:

```erb
<%# 1. Explicitly show that the slot is optional - and change the layout at the same time %>
<body class="<%= content_for?(:sidebar) ? 'has-sidebar' : '' %>">

<%# 2. A default value %>
<title><%= content_for?(:title) ? yield(:title) : "Store" %></title>
```

And if you want guarantees - put slot names into constants or helpers (`page_title(...)` instead of `content_for :title`) so a typo is caught at the Ruby level, not by eye.

### Side effect: accumulation

Because `content_for` appends by default, a partial in a collection will write as many times as there are items:

```ruby
T = {
  "layouts/app.html.erb" => "SIDEBAR[<%= yield :sidebar %>]\nBODY[<%= yield %>]",
  "page.html.erb"        => "<%= render partial: 'item', collection: [1,2,3] %>",
  "_item.html.erb"       => "<% content_for :sidebar do %>(<%= item %>)<% end %>[<%= item %>]"
}
```

```
SIDEBAR[(1)(2)(3)]
BODY[[1][2][3]]
```

Sometimes that is exactly what you want (collect every modal on the page). Sometimes it is a triple `<script>` in `<head>`. The fix is `content_for :sidebar, flush: true do`, which calls `set` instead of `append`.

A separate trap: **`content_for` does not survive fragment caching**. Rails says so honestly in the source:

> WARNING: `content_for` is ignored in caches. So you shouldn't use it for elements that will be fragment cached.

On a cache hit, the `cache do ... end` block does not run - a ready string is returned. That means `content_for` inside it also does not run, and the slot ends up empty. On a cold cache everything works; on a warm cache it breaks, which makes the bug especially pleasant.

## When the order flips: `provide` and streaming

`content_for` has a sibling - `provide`. In normal mode the difference is almost invisible, but with `render stream: true` it is fundamental.

When streaming, Rails wants to send `<head>` to the browser as early as possible, but `<head>` lives in the layout, and its content comes from the template. The "template, then layout" order does not work here. The solution is fibers (`renderer/streaming_template_renderer.rb`):

```ruby
fiber = Fiber.new { layout.render(view, locals, output, &yielder) }
view.view_flow = StreamingFlow.new(view, fiber)
fiber.resume                    # start the layout FIRST

if fiber.alive?                 # layout paused on yield :title
  content = template.render(view, locals, &yielder)
  view.view_flow.set(:layout, content)
  fiber.resume while fiber.alive?
end
```

`StreamingFlow#get` with a missing key does `Fiber.yield` - it freezes the layout and hands control to the template. And `provide` on write does `@fiber.resume` and unfreezes the layout again:

```ruby
def append!(key, value)
  super
  @fiber.resume if @waiting_for == key
end
```

```mermaid
sequenceDiagram
    participant L as layout (in Fiber)
    participant F as StreamingFlow
    participant V as view
    participant B as browser

    L->>B: opened html and head
    L->>F: yield :title
    F-->>L: key missing -> Fiber.yield
    F->>V: render template
    V->>F: provide :title, "Sneakers"
    F->>L: fiber.resume
    L->>B: title and close head
    Note over V: template finishes rendering
    V->>F: set(:layout, body)
    L->>B: body with page content
```

The semantic difference: `content_for` says "I might append more", so under streaming the layout waits until the end of the template. `provide` says "the slot is done" - and the layout continues immediately. That is why streaming pages use `provide` for `<title>` and `<head>`, and leave `content_for` for slots assembled from multiple places.

## Two families of template engines

Here it becomes clear that Rails did not choose the only possible model. Every engine with "blocks" splits into two camps.

```mermaid
flowchart TB
    subgraph PUSH["Push / buffer: child pushes"]
        direction TB
        C1["child runs fully"] --> C2["each content_for<br/>writes to a shared buffer"]
        C2 --> C3["layout reads the buffer"]
        C3 --> C4["extra section ran,<br/>result discarded"]
    end

    subgraph PULL["Pull / inheritance: parent pulls"]
        direction TB
        P1["parent: block head"] --> P2["looks for an override<br/>in the child template"]
        P2 --> P3["calls only the blocks<br/>it encountered"]
        P3 --> P4["child's unused block<br/>does NOT run"]
    end
```

### Pull: template inheritance

**Jinja2 / Django (Python), Twig (PHP), Smarty 3+ (PHP), Go `html/template`, handlebars-layouts (Node).**

The child declares `{% extends %}`, the parent runs the show. Blocks compile into separate functions, and the parent calls the ones it reaches.

I checked on Jinja2 - a `nowhere` block that is absent from the base template does not run at all:

```python
'base.html': 'HEAD[{% block head %}default{% endblock %}] BODY[{% block content %}{% endblock %}]',
'page.html': '''{% extends "base.html" %}
{% block head %}{{ log("head block runs") }}meta{% endblock %}
{% block content %}{{ log("content block runs") }}page body{% endblock %}
{% block nowhere %}{{ log("NOWHERE block runs") }}wasted{% endblock %}'''
```

```
RUN: head block runs
RUN: content block runs
HEAD[meta] BODY[page body]
```

`NOWHERE` never appeared. And here execution order matches HTML order: `head` before `content`, because that is how it stands in the parent. In Rails it would be the opposite.

The same in Go, where named templates in a set play the role of blocks:

```go
base := `HEAD[{{block "head" .}}default{{end}}] BODY[{{template "content" .}}]`
child := `{{define "content"}}...{{end}}
{{define "head"}}...{{end}}
{{define "nowhere"}}{{log "NOWHERE"}}wasted{{end}}`
```

```
RUN: layout start
RUN: head block
RUN: content block
RUN: layout end
```

`nowhere` is parsed, sits in the template set, and is simply never called. The cost is a few bytes of memory for the parsed tree, zero CPU time.

The same result on `handlebars-layouts`: `{{#content "nowhere"}}` does not run, because blocks are stored as deferred functions and are only called from the parent's `{{#block}}`.

Smarty stands apart and deserves a separate mention, because it can do **both** approaches. Its `{extends}` / `{block}` resolve at compile time - a child block with no pair in the parent is physically cut out by the compiler and never reaches compiled PHP. But Smarty also has a second mechanism, `{capture name="foo"}...{/capture}` read via `{$smarty.capture.foo}`, and that is exactly the Rails model: run it and put it in an array.

### Push: buffer and reverse order

**Rails ActionView, Laravel Blade, express-ejs-layouts (Node).**

First the page runs fully, filling a shared buffer along the way, then the wrapper renders and reads from the buffer.

Blade is almost a copy of Rails: `@section` writes into the `$sections` array, `@yield('name')` reads. `@extends` does not start the layout immediately; it defers its render until the end of the child template. A section without `@yield` likewise runs for nothing. The closest analogue of concatenating `content_for` is `@push` / `@stack`.

In Node there is no such mechanism in stock EJS or Handlebars: plain EJS only has `include()`, plain Handlebars only has partials. The layout model comes from wrappers. The `express-ejs-layouts` implementation is the most straightforward of all - `contentFor` simply inserts a text marker into the output:

```js
var contentPattern = '&&<>&&';

function contentFor(contentName) {
  return contentPattern + contentName + contentPattern;
}

function parseContents(locals) {
  var str = locals.body,
      regex = new RegExp('\r?\n?' + contentPattern + '.+?' + contentPattern + '\r?\n?', 'g'),
      split = str.split(regex),
      matches = str.match(regex);
  locals.body = split[0];
  // ... content between markers is laid out into locals[name]
}
```

So the page renders into one full string, then is cut with a regex on delimiters. A section nobody will output does not just run for nothing here - it also takes part in string parsing.

### Summary

| Engine | Mechanics | Order | Unused block |
|---|---|---|---|
| Rails ActionView | `view_flow` hash | bottom-up | runs, result discarded |
| Laravel Blade | `$sections` array | bottom-up | runs, result discarded |
| express-ejs-layouts | markers in a string + `split` | bottom-up | runs and is parsed by regex |
| Smarty `{capture}` | `$smarty.capture` array | bottom-up | runs, result discarded |
| Jinja2 / Django | block inheritance | top-down | does not run |
| Twig | blocks compile into methods | top-down | does not run |
| Smarty `{extends}` | merge at compile time | top-down | cut out by the compiler |
| Go `html/template` | named templates in a set | top-down | does not run |
| handlebars-layouts | blocks as deferred functions | top-down | does not run |

### What Rails pays and what it buys

The pull model is more efficient and more predictable in execution order. But it has a hard limit: **only the child template itself can fill a slot**. In Jinja2, a partial included via `{% include %}` three levels deep cannot append a `<script>` into the parent's `<head>` - blocks belong to the child-parent relationship, not to the whole render tree.

In Rails, anyone can, from any depth. A component in a partial in a partial in a collection adds its CSS to `<head>`, and it works because there is one buffer for the whole request. That is what makes things like `javascript_include_tag` from a nested component, or collecting modals from the whole page into one block before `</body>`, possible.

The extra microseconds on a "dead" `content_for` are the price of exactly that freedom. Rails cannot know ahead of time who will want to put something where, so it runs everything and sorts it out afterward.

## Cheat sheet

- The layout renders **after** the template. `yield` does not run the template; it reads a ready string from `view_flow[:layout]`.
- `content_for` runs the block **immediately** via `capture`. There is no laziness.
- A section without a matching `yield` is work that ran and was thrown away. One call is ~1.7 µs: not critical. In a loop or with a database query inside - already noticeable.
- Rails **stays silent** on a slot name typo. That is more dangerous than lost microseconds.
- `content_for` appends by default. Need replacement - `flush: true`.
- `content_for` **does not work** inside a fragment cache on a cache hit.
- To read a slot in a helper you can only use `content_for :name`; `yield :name` is unavailable there.
- For streaming (`render stream: true`) use `provide` instead of `content_for`.

## Links

- [Layouts and Rendering in Rails - Structuring Layouts](https://guides.rubyonrails.org/layouts_and_rendering.html#structuring-layouts)
- [Turbo Frames from Scratch](/en/blog/tech/frontend/turbo-frames-s-nulya/) and [Stimulus from Scratch](/ru/blog/tech/frontend/stimulus-s-nulya/) - what happens to this HTML next, already in the browser
