---
title: "Turbo Framesとは？turbo_frame_tagの使い方とContent missingの対処【Turbo 8.0.23】"
url: "https://www.issoh.co.jp/tech/details/6746/"
published: 2025-05-14
updated: 2026-10-01
categories: ["Ruby on Rails"]
publisher: "株式会社一創"
---

# Turbo Framesとは？turbo\_frame\_tagの使い方とContent missingの対処【Turbo 8.0.23】

Turbo Framesは、ページの一部を `<turbo-frame>` 要素で囲み、その範囲のリンクやフォームの結果だけを差し替えるHotwire Turboの機能です。サーバーはいつもどおりHTMLを返し、Turboが応答の中から同じidのフレームを探して中身を入れ替えます。この記事では、2026年1月29日公開の @hotwired/turbo 8.0.23 と turbo-rails 2.0.23 を対象に、`turbo_frame_tag` の書き方、フォームと遅延読み込みの実装、フレームの外へ遷移させる方法、「Content missing」の原因と対処を、ソースとjsdomでの実測で確かめた範囲で解説します。

## まとめ：Turbo Framesの要点

- フレーム内のリンク・フォームの応答から、**同じidの `<turbo-frame>` だけ**が取り出されて差し替わる。応答がページ全体のHTMLでも、フレーム外の部分は捨てられる
- リクエストには `Turbo-Frame: フレームのid` ヘッダーが付く。turbo-railsはこれを見てレイアウトを最小のものへ差し替える
- 応答に同じidのフレームが無いと、`turbo:frame-missing` が発火し、イベントを取り消さなければフレームの中身が「Content missing」に置き換わって例外が投げられる
- フレーム内フォームの検証エラーは、Rails 8.1のscaffoldと同じく `status: :unprocessable_content`（422）で返す
- `loading="lazy"` を付けたフレームは、表示領域に入るまで `src` を取りに行かない
- フレームの外へ出すなら `target="_top"` か `data-turbo-frame="_top"`、URLも更新するなら `data-turbo-action="advance"`
- 1回の操作で複数箇所を更新したい、サーバーから押し込みたい場合はTurbo Streamsの担当

## Turbo Framesの仕組み：同じidによるフレームの部分更新

Turbo Framesの動作は、リクエストとレスポンスの2段で説明できます。フレームの中でリンクがクリックされると、Turboは通常の遷移を止めて `fetch` で同じURLを取りに行き、リクエストヘッダーに `Turbo-Frame` を付けます。応答HTMLを受け取ったら、その中から同じidの `<turbo-frame>` を探し、中身だけを現在のフレームへ移します。

```
<!-- 一覧ページ -->
<turbo-frame id="post_1">
  <h3>記事タイトル</h3>
  <a href="/posts/1/edit">編集</a>
</turbo-frame>

<!-- /posts/1/edit の応答（ページ全体でよい） -->
<h1>記事の編集</h1>
<turbo-frame id="post_1">
  <form action="/posts/1" method="post">...</form>
</turbo-frame>
```

jsdom上でTurbo 8.0.23を動かして確かめると、`<h1>ページ全体</h1>` とフレーム外の段落を含む応答を返しても、画面に入ったのはフレーム内の要素だけでした。送られたリクエストには `Turbo-Frame: comments` と `X-Turbo-Request-Id` が付き、GETの `Accept` は `text/html, application/xhtml+xml` です。描画が終わるとフレームに `complete` 属性が付き、`turbo:before-frame-render` → `turbo:frame-render` → `turbo:frame-load` の順でイベントが発火しました。

別オリジンのページを隔離して埋め込む用途は、Turbo Framesではなく[iframeとは？sandbox属性14種とCookie分割の実装判断を解説](/tech/details/17529/)の領域です。

### <turbo-frame>の属性と役割

[公式リファレンス](https://turbo.hotwired.dev/reference/frames)に載っている属性と、8.0.23のソースで確認できる挙動を並べます。`observedAttributes` は `disabled`・`loading`・`src` の3つで、この3つは後から書き換えても即座に反映されます。

| 属性                  | 値               | 役割                        |
| ------------------- | --------------- | ------------------------- |
| `id`                | 文字列（必須）         | 応答から探すフレームの識別子            |
| `src`               | URL             | このURLの応答でフレームを埋める         |
| `loading`           | eager（既定）／lazy  | lazyは表示領域に入るまで読み込まない      |
| `target`            | \_top／他フレームのid  | フレーム内リンクの遷移先              |
| `disabled`          | 真偽属性            | フレームによる遷移の横取りを止める         |
| `busy`              | Turboが付与        | リクエスト中だけ付く（aria-busyも同時）  |
| `complete`          | Turboが付与        | 読み込み完了で付く                 |
| `autoscroll`        | 真偽属性            | 読み込み後にフレームを画面内へスクロール      |
| `recurse`           | 入れ子フレームのid      | 応答に無いフレームを入れ子経由で探す        |
| `refresh`           | morph           | reload時とページリフレッシュ時にモーフィング |
| `data-turbo-action` | advance／replace | フレーム遷移をURL・履歴にも反映         |

`autoscroll` の位置は `data-autoscroll-block`（既定 `end`）、動きは `data-autoscroll-behavior`（既定 `auto`）で変えられます。`busy` の間はCSSの `turbo-frame[busy]` でスピナーを出す、といった使い方ができます。

## turbo\_frame\_tagの書き方：idの組み立てとsrc・target

Railsでは `<turbo-frame>` を直接書かず、turbo-railsの `turbo_frame_tag` ヘルパーで出力するのが一般的です。2.0.23のシグネチャは `turbo_frame_tag(*ids, src: nil, target: nil, **attributes, &block)` で、`loading` などそれ以外の属性はそのままタグに渡ります。

```
<%# app/views/posts/_post.html.erb %>
<%= turbo_frame_tag post do %>
  <h3><%= post.title %></h3>
  <%= link_to "編集", edit_post_path(post) %>
<% end %>

<%# app/views/posts/edit.html.erb：一覧と同じidで囲む %>
<%= turbo_frame_tag @post do %>
  <%= render "form", post: @post %>
<% end %>
```

idの決まり方はヘルパーのソースで確定できます。第1引数が `to_key` に応答するモデルかクラスなら `dom_id` に渡し、それ以外は引数を `_` でつなぎます。

| 書き方                                           | 出力されるid              |
| --------------------------------------------- | -------------------- |
| `turbo_frame_tag "tray"`                      | `tray`               |
| `turbo_frame_tag Article.find(1)`             | `article_1`          |
| `turbo_frame_tag Article`                     | `new_article`        |
| `turbo_frame_tag Article.find(1), "comments"` | `comments_article_1` |
| `turbo_frame_tag [user_id, "tray"]`           | `1_tray`             |

モデルと文字列を渡すと文字列が**前置**される点に注意してください。一覧側で `turbo_frame_tag post, "comments"`、詳細側で手書きの `id="article_1_comments"` のように書き方を混ぜると、idが一致せずContent missingになります。両側とも同じヘルパー呼び出しで揃えるのが確実です。

### コントローラー側の扱い：レイアウトの差し替えとturbo\_frame\_request?

turbo-railsは `Turbo::Frames::FrameRequest` を `ActionController::Base` に組み込み、`Turbo-Frame` ヘッダー付きのリクエストではアプリのレイアウトの代わりに `turbo_rails/frame.html.erb` を使います。中身は `csrf_meta_tags` と `yield :head` と本文だけの最小レイアウトで、ヘッダーやフッターの描画を省くための最適化です。アプリ側で独自のレイアウト指定をしている場合は、フレームリクエスト時に `"turbo_rails/frame"` を返す分岐も必要です。同じURLでもフレーム用とページ用でETagが分かれるよう、`etag { :frame if turbo_frame_request? }` も設定されます。

ビューやコントローラーで `turbo_frame_request?` と `turbo_frame_request_id` が使えるので、フレームから呼ばれたときだけ部分テンプレートを返す、といった分岐が書けます。レイアウトを変えたいときは、アプリ側に `app/views/layouts/turbo_rails/frame.html.erb` を置けばそちらが優先されます。

## フレーム内フォーム：検証エラーは422、成功時の遷移先にも同じフレーム

フレームの中にフォームを置くと、送信結果もそのフレームに描画されます。Rails 8.1.4のscaffoldテンプレートは、検証エラー時に `render :new, status: ActionDispatch::Constants::UNPROCESSABLE_CONTENT` を生成します。この定数はRack 3.1以上で `:unprocessable_content`、それ未満で `:unprocessable_entity` になり、どちらも422です。

```
def create
  @todo = Todo.new(todo_params)
  if @todo.save
    redirect_to todos_path, status: :see_other
  else
    render :new, status: :unprocessable_content
  end
end
```

jsdomでフレーム内フォームを送信して確かめると、422で返した検証エラー付きのフォームはそのままフレームに描画されました。一方、200でリダイレクトせずに返した場合もフレーム内には描画されます。Turbo Driveのページ全体へのPOSTなどのフォーム送信では、200でリダイレクトせず通常のHTMLを返す応答は「Form responses must redirect to another location」というエラーになりますが、フレーム内の送信はこの検査の対象外です。それでも422に揃えるべき理由は、同じフォームを後からフレームの外で使ったときにDrive側の検査で止まるためです。scaffoldの書き方に合わせておけば、置き場所を変えても壊れません。

見落としやすいのは成功時です。`redirect_to todos_path` の遷移先 `/todos` にも、送信元と同じidのフレームが必要です。一覧ページに `new_todo` のフレームが無ければ、保存は成功しているのにフレームにはContent missingが出ます。遷移先に同じフレームを置くか、フォームに `data-turbo-frame="_top"` を付けてページ全体を遷移させるか、どちらかを選びます。

フレーム内フォームをPOSTで送信した検証では、`Accept` に `text/vnd.turbo-stream.html` が加わりました。そのため `respond_to` で `format.turbo_stream` を返せば、フォームのあるフレームと一覧の件数など、複数箇所を1回の応答で更新できます。Streamsの書き方は[Turboとは？Hotwireの高速化フレームワークの仕組みとRailsでの使い方【Turbo 8.0.23】](/tech/details/3624/)で扱っています。scaffoldを使った雛形の作り方は[Railsジェネレータ（rails generate）の使い方｜モデル・scaffold・カスタム生成まで](/tech/details/2874/)が参考になります。

## Turbo Framesの遅延読み込み：loading属性とsrcの設定

`src` を持つフレームは、ページの表示後に別リクエストで中身を取りに行きます。既定の `loading="eager"` では接続した時点ですぐ読み込み、`loading="lazy"` では表示領域に入るまで待ちます。

```
<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
  <p>コメントを読み込んでいます…</p>
<% end %>
```

8.0.23の実装は `IntersectionObserver` をオプション無しで生成しているため、`rootMargin` による先読みはありません。IntersectionObserverをモックに置き換えたjsdomの検証では、交差を手動通知するまで `fetch` は0回で、通知した直後に1回だけ呼ばれ、ブロック内の「読み込んでいます」が応答の中身に置き換わりました。スクロールしてから読み込みが始まるので、画面に入る少し前から読み込みたいなら、別要素を監視するStimulusコントローラーで、先読みのタイミングに `loading="eager"` へ切り替えて `src` を設定するなど、自分で仕組みを足す必要があります。

公式ハンドブックは使いどころとして `<details>` の中やモーダルなど最初は隠れている領域を挙げ、ユーザーごとに変わる部分だけをフレームに切り出せばページの残りを共有キャッシュに載せられる、とキャッシュ面の利点も説明しています。逆に、検索結果に載せたい本文やページの主題は初期HTMLに置き、遅延フレームはコメント欄・関連情報・通知のような補助領域に限るのが安全です。

## Turbo Framesの遷移先・履歴制御：target・data-turbo-frame・data-turbo-action

フレーム内のリンクとフォームは、既定ではそのフレームだけを書き換えます。遷移先を変える方法は3つあり、指定する場所が違います。

| 指定                         | 付ける場所         | 効果                          |
| -------------------------- | ------------- | --------------------------- |
| `target="_top"`            | turbo-frame   | フレーム内の全リンクでページ全体を遷移         |
| `target="他のid"`            | turbo-frame   | 別のフレームを書き換える                |
| `data-turbo-frame="_top"`  | a・form・button | その要素だけページ全体を遷移              |
| `data-turbo-frame="id"`    | a・form・button | フレーム外の要素から特定フレームを書き換える      |
| `data-turbo-frame="_self"` | a・form        | フレームのtarget指定を打ち消して自分のフレームへ |

8.0.23のソースでは、`data-turbo-frame` は送信ボタン、フォームやリンク、フレームの `target` の順に探され、最初に見つかった値が使われます。`"_parent"` も解釈され、1つ外側の `<turbo-frame>` を指します。

フレーム内の遷移は既定でURLを変えません。ページ送りのように、再読み込みやURL共有で同じ状態に戻したい場合は `data-turbo-action="advance"` を付けると、フレームの `src` とブラウザのURLの両方が更新されます。

```
<turbo-frame id="articles" data-turbo-action="advance">
  <!-- 記事一覧 -->
  <a href="/articles?page=2" rel="next">次のページ</a>
</turbo-frame>
```

このとき公式ハンドブックが注意しているとおり、`/articles?page=2` を直接開いたときに2ページ目を描画するのはアプリ側の責任です。フレームの応答だけ2ページ目に対応させ、ページ全体の描画が `page` パラメーターを無視していると、再読み込みで1ページ目に戻ります。

## Content missingの原因と対処

応答HTMLに同じidのフレームが無いと、Turbo 8.0.23はフレームの中身を `<strong class="turbo-frame-error">Content missing</strong>` に置き換え、次のメッセージで例外を投げます。jsdomでidの違うフレームを返したときも、この文言がそのまま出ました。

```
The response (200) did not contain the expected <turbo-frame id="detail"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.
```

原因はほぼ次の4つに分かれます。

- **idの不一致**：`turbo_frame_tag post, "comments"` と手書きのidの混在、パーシャルの受け取り変数違いなど
- **成功時のリダイレクト先にフレームが無い**：前章の `redirect_to` のケース
- **ログインページへのリダイレクト**：セッション切れで認証ページが返り、そこにはフレームが無い
- **エラーページ**：500や404の応答は通常アプリのレイアウトで描かれ、フレームを含まない

ログインページやエラーページのように、フレームの中ではなくページ全体で見せたい応答には、`<meta name="turbo-visit-control" content="reload">` を入れます。turbo-railsでは、そのビューで `turbo_page_requires_reload` を呼べば同じタグが `head` に入ります。この指定がある応答は、フレームからのリクエストでもページ全体の遷移に切り替わります。

個別に扱いたい場合は `turbo:frame-missing` を受け取ります。このイベントは取り消し可能で、`preventDefault()` すると既定の処理（Content missingの表示と例外）が止まります。jsdomでも、取り消したフレームは元の中身が残りました。応答をそのままページ遷移に使うなら、`event.detail.visit` に `Response` を渡せます。

```
document.addEventListener("turbo:frame-missing", (event) => {
  const { response, visit } = event.detail
  // "/login" は自分のアプリの認証ページのパスに置き換える
  if (response.redirected && new URL(response.url).pathname === "/login") {
    event.preventDefault()
    visit(response)
  }
})
```

すべてのフレームでこの処理を有効にすると、idの書き間違いまで黙ってページ遷移に化けて見逃します。適用はログイン切れなどリダイレクトを伴う応答に限り、idの不一致はContent missingのまま表に出して直すほうが、不具合を早く見つけられます。

## フレームの再読み込みと描画の制御

`src` を持つフレームは、要素の `reload()` を呼ぶと同じURLを取り直します。このとき `refresh="morph"` が付いていれば、中身を丸ごと入れ替えずにIdiomorphで差分だけを当てます。Turbo 8のページリフレッシュでモーフィングを使っている場合も、`src` と `refresh="morph"` を持つフレームは同じ方法で取り直されます。

```
<turbo-frame id="notifications" src="/notifications" refresh="morph"></turbo-frame>

<script>
  document.getElementById("notifications").reload()
</script>
```

描画そのものを差し替えたいときは `turbo:before-frame-render` を使います。`event.detail.render` を上書きすれば独自の差し替え処理に、`preventDefault()` したあと `event.detail.resume()` を呼べば退場アニメーションを挟んでから描画、といった制御ができます。ページ全体のモーフィングとの関係は、前述のTurboの記事にあるTurbo 8の章で扱っています。

## Turbo Frames・Turbo Streams・Turbo Driveの使い分け

3つは排他ではなく、Turbo Driveが有効な画面の一部をFramesで区切り、必要な箇所だけStreamsで更新する、という重ね方をします。どれを使うかは「1回の操作で何か所を、誰の操作をきっかけに更新するか」で決まります。

| 観点      | Turbo Drive | Turbo Frames    | Turbo Streams        |
| ------- | ----------- | --------------- | -------------------- |
| 更新範囲    | body全体      | 1つのフレーム         | 複数のid要素              |
| きっかけ    | リンク・フォーム    | フレーム内の操作・src    | フォーム応答・WebSocket・SSE |
| サーバーの応答 | 通常のHTML     | 同じidを含むHTML     | turbo-stream要素       |
| URLの変化  | あり          | 既定なし（advanceで可） | なし                   |

インライン編集・タブ・ページ送り・遅延表示のように「操作した場所だけが変わる」画面はFramesで足ります。保存したら一覧と件数とフラッシュを同時に変えたい、他のユーザーの操作をリアルタイムで反映したい、となった時点でStreamsを足します。リアルタイム配信の土台は[Action Cableとは？Rails 8.1のWebSocket実装とSolid Cable・Redisの選び方](/tech/details/5809/)、Hotwire全体での位置づけは[Hotwireとは？Turbo・Stimulus・Hotwire Nativeの役割とRails 8.1での使い方](/tech/details/6745/)にまとめています。

## Turbo Framesで設計を誤りやすい場面

Framesは「サーバーがHTMLを返し、同じidで差し替える」以上のことをしません。この前提から外れる要件では、無理にFramesで組むと分岐とidの管理が増えていきます。

- **1つの操作で離れた複数箇所が変わる画面**：フレームを入れ子にして `target` を張り巡らせるより、Streamsで必要な要素を名指しして更新するほうが追いやすい
- **入力途中の状態を保ちたいフォーム**：フレームの差し替えは既定で中身を丸ごと置き換えるため、差し替えたフレーム内の未保存の入力は残らない。複数ステップの入力はフレームを分けず1つのフォームにまとめる
- **ブラウザの戻るで状態を戻したい一覧**：`data-turbo-action="advance"` を付け、URLからページ全体を再現できる作りにしてから使う。付け忘れると、戻るで一覧の外まで戻ってしまう
- **クライアント側の状態が主役のUI**：ドラッグ操作中の並び順やキャンバス描画のように、サーバー往復を待てない操作はStimulusやReact側で持つ

HTMLを差し替える発想はhtmxとも共通しますが、htmxは `hx-target` で差し替え先を明示する（親要素に書いて子へ継承させることもできる）のに対し、Turbo Framesは応答側に同じidのフレームを置くことを規約にしています。Rails以外のサーバーで同じ発想を取りたい場合の比較材料として、[htmxの使い方：hx-属性の実装手順からFastAPI連携まで解説【2026年版】](/tech/details/15371/)も参考になります。

## Turbo Framesに関するよくある質問

### Turbo Framesとiframeの違いは？

`<turbo-frame>` は同じ文書の中のカスタム要素で、CSSもJavaScriptも親ページと共有します。サーバーから取ってきたHTMLのうち同じidの部分をDOMに差し込むだけで、別の文書や別オリジンのページを隔離して表示する `<iframe>` とは仕組みが異なります。

### data-turbo-frameは何に使う？

リンク・フォーム・送信ボタンごとに遷移先を指定する属性です。`_top` でページ全体、フレームのidでそのフレームを書き換え、フレームの外にあるリンクから特定のフレームだけを更新するときにも使えます。

### Rails以外でもTurbo Framesは使える？

使えます。npmの `@hotwired/turbo` を読み込めば `<turbo-frame>` が動き、サーバー側は同じidのフレームを含むHTMLを返すだけで足ります。`turbo_frame_tag` やレイアウトの差し替え、`turbo_page_requires_reload` はturbo-railsの機能なので、他のフレームワークでは自前で書くか、各フレームワーク向けの統合パッケージを使います。

### フレーム内のリンクを押してもURLが変わらないのはなぜ？

既定ではフレームの `src` だけが変わり、ブラウザのURLと履歴には触れないためです。反映したい場合はフレームかリンクに `data-turbo-action="advance"` を付けます。

### idは一致しているのにContent missingになるのは？

最終的に返ってきた応答を確認してください。フォーム送信後のリダイレクト先、セッション切れで返るログインページ、500のエラーページは、送信元のフレームを含まないことが多くあります。ブラウザの開発者ツールで `Turbo-Frame` ヘッダー付きリクエストの応答本文を開き、同じidの `<turbo-frame>` が入っているかを見ると原因を絞れます。

## 関連記事

- [Turboとは？Hotwireの高速化フレームワークの仕組みとRailsでの使い方【Turbo 8.0.23】](/tech/details/3624/)
- [Hotwireとは？Turbo・Stimulus・Hotwire Nativeの役割とRails 8.1での使い方](/tech/details/6745/)
- [htmxの使い方：hx-属性の実装手順からFastAPI連携まで解説【2026年版】](/tech/details/15371/)
- [Action Cableとは？Rails 8.1のWebSocket実装とSolid Cable・Redisの選び方](/tech/details/5809/)
- [iframeとは？sandbox属性14種とCookie分割の実装判断を解説](/tech/details/17529/)

---

出典: [Turbo Framesとは？turbo\_frame\_tagの使い方とContent missingの対処【Turbo 8.0.23】](<https://www.issoh.co.jp/tech/details/6746/>)（株式会社一創）
