「一覧ページを無限スクロールにしたい。でもそのためにJavaScriptのライブラリを入れて、IntersectionObserverを書いて……というのは正直おっくう」——そんなふうに感じたことはありませんか?
じつは、RailsのHotwire標準のTurbo Framesを使えば、専用のJavaScriptをほとんど書かずに無限スクロール(無限ページネーション)が作れます。ページ末尾の「見えないフレーム」が画面に入った瞬間に次ページを取りにいく、という仕組みです。
この記事では、自動で読み込む方式と、「もっと見る」ボタンにも流用できる方式の2つを、使い分けの判断基準つきで解説します。コードはすべてコピペできる形で載せます。TurboはRails 7以降は標準添付(turbo-rails)なので、Rails 7でも8でも追加のgemなしで動きます。
この記事で作るもの(完成イメージ)
今回作るのは、次のような動きの記事一覧です。
- 最初は1ページ分(たとえば10件)だけ表示される
- ページ末尾までスクロールすると、次のページが自動で下に積まれる
- 最後のページまで来たら、それ以上は読み込まれず静かに止まる

ポイントは、Turboが「同じIDのフレームを見つけて中身を差し替える」という性質を持っていることです。これを利用すると、ページネーションのリンクを踏まなくても、次ページのHTMLを裏で取ってきて一覧に継ぎ足せます。順番に見ていきましょう。
無限スクロールの2つの実装方式と使い分け
Turboで無限スクロールを作る方法は、大きく2つあります。どちらを選ぶかは「完全自動にしたいか」「DOMの増え方を抑えたいか」で決めるのが分かりやすいです。
- 方式A:
loading: :lazyフレーム方式 — 一覧の末尾に次ページ用の空フレームを置き、画面に入ると自動ロード。純粋なHTMLだけで完結する - 方式B:Turbo Stream で append する方式 — 次ページ取得時に
turbo_stream.appendで項目を追加。「もっと見る」ボタンにそのまま流用できる
違いを表で整理します。
| 観点 | 方式A(lazyフレーム) | 方式B(Turbo Stream) |
|---|---|---|
| 専用JS | 不要 | 不要 |
| スクロールで自動 | ◎(標準で自動) | △(ボタン向き。自動化は別途) |
| 「もっと見る」ボタン | △ | ◎(そのまま流用できる) |
| DOM構造 | フレームが入れ子に深くなる | フラットに積める |
| 実装の手数 | 少ない(ビューだけ) | やや多い(.turbo_stream.erbが要る) |
ざっくりの指針はこうです。「スクロールで勝手に続いてほしい」なら方式A、「もっと見るボタンにしたい・DOMをフラットに保ちたい」なら方式B。まずは共通の下準備(ページネーション)から進めます。
下準備:Pagyで「次ページがあるか」を判定する
無限スクロールに必要なのは「次のページがあるか/そのページ番号は何か」だけです。総件数を数える必要はありません。そこで今回は、Pagyのcountlessモードを使います。これは「件数 + 1件だけ余分に取得して次ページの有無を判定する」仕組みで、重い COUNT クエリを1回分まるごと省けます。
まず初期化ファイルで countless エクストラを読み込みます。
# config/initializers/pagy.rb
require "pagy/extras/countless"コントローラは、通常の pagy の代わりに pagy_countless を呼ぶだけです。1ページ10件にしてみます。
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@pagy, @posts = pagy_countless(
Post.order(created_at: :desc),
limit: 10
)
end
endここで limit: 10 が「1ページあたりの件数」です。Pagy 9系からキーワードが items: → limit: に変わっている点に注意してください(Pagy 8以前を使っている場合は items: 10 と書きます)。
これで、ビューから @pagy.next を見れば「次ページの番号(無ければ nil)」が取れます。この @pagy.next が、以降の実装の心臓部です。ルーティングは一覧アクションがあれば十分です。
# config/routes.rb
resources :posts, only: :index一覧の1件分を描画する部分テンプレートも用意しておきます。どちらの方式でも共通で使います。
<%# app/views/posts/_post.html.erb %>
<article class="post">
<h2><%= post.title %></h2>
<p><%= truncate(post.body, length: 120) %></p>
</article>ページネーションそのものの基礎(PagyとKaminariの違いや選び方)はRailsのページネーション入門で解説しています。Pagyをまだ入れていない方は先にそちらを読むとスムーズです。
方式A:loading: :lazy で自動読み込み
まずは、専用JSもTurbo Streamも使わない、いちばんシンプルな方式です。やることは1つだけ。一覧の末尾に「次ページを指す空フレーム」を置き、loading: :lazy を付けることです。
<%# app/views/posts/index.html.erb %>
<h1>記事一覧</h1>
<div id="posts">
<%= turbo_frame_tag "posts_page_#{@pagy.page}" do %>
<%= render @posts %>
<% if @pagy.next %>
<%= turbo_frame_tag "posts_page_#{@pagy.next}",
loading: :lazy,
src: posts_path(page: @pagy.next) do %>
<p class="loading">読み込み中...</p>
<% end %>
<% end %>
<% end %>
</div>仕組みを順に追うとこうなります。
- 最初の表示では、1ページ目の記事(
render @posts)と、2ページ目を指す空フレーム(posts_page_2)が描画される - スクロールして
posts_page_2が画面に入ると、Turboが/posts?page=2を裏で取得する - サーバーは同じ
index.html.erbを@pagy.page == 2の状態で描画し、同じIDのフレームposts_page_2を含むHTMLを返す - Turboはその同IDフレームの中身(2ページ目の記事+3ページ目の空フレーム)で、空フレームを置き換える

この「2ページ目のフレームの中に、さらに3ページ目のフレームを入れる」という入れ子が、無限に続いていくイメージです。@pagy.next が nil になる最後のページでは空フレームを出さないので、そこで自然に止まります。
loading: :lazy は「フレームが画面に見える位置に入るまでリクエストを遅らせる」指定です。この性質を、重い部分を後から出す用途に使うのがTurbo Framesで遅延読み込みの記事でした。無限スクロールは、その lazy を「ページ末尾」に置いて繰り返しているだけ、と捉えると理解しやすいはずです。
「読み込み中…」の部分は、次ページを取得しているあいだだけ見えるプレースホルダです。ここをスピナーやスケルトンにすると、待ち時間の印象が良くなります。
方式B:Turbo Stream で append する
方式Aは手軽ですが、ページをめくるほどフレームが入れ子に深くなります。DOMをフラットに保ちたい、あるいは「もっと見る」ボタンにしたいときは、Turbo Streamで一覧に追記する方式が向いています。
一覧ビューは、記事を入れる #posts と、ページ送り用の #pager を分けて置きます。
<%# app/views/posts/index.html.erb %>
<h1>記事一覧</h1>
<div id="posts">
<%= render @posts %>
</div>
<%= render "pager" %>_pager 部分テンプレートには、「もっと見る」リンクを置きます。ポイントは data: { turbo_stream: true } です。Turboは既定ではGETリクエストでTurbo Stream形式(text/vnd.turbo-stream.html)を受け付けません。この指定を付けることで、GETリンクでもTurbo Streamのレスポンスを受け取れるようになります。
<%# app/views/posts/_pager.html.erb %>
<div id="pager">
<% if @pagy.next %>
<%= link_to "もっと見る",
posts_path(page: @pagy.next),
data: { turbo_stream: true },
class: "load-more" %>
<% end %>
</div>コントローラは方式Aと同じ pagy_countless のままでかまいません。あとは、Turbo Streamのレスポンス用テンプレートを追加します。記事を #posts に追記(append)し、#pager を次ページ用に差し替える(replace)だけです。
<%# app/views/posts/index.turbo_stream.erb %>
<%= turbo_stream.append "posts" do %>
<%= render @posts %>
<% end %>
<%= turbo_stream.replace "pager" do %>
<%= render "pager" %>
<% end %>これで、「もっと見る」を押すと次ページの記事が一覧の下にフラットに追記され、ボタン自身はさらに次のページを指すものに置き換わります。最後のページまで来ると @pagy.next が nil になり、#pager の中身が空になってボタンが消えます。
この方式を「スクロールで自動発火」にしたい場合は、ボタンを画面に入ったタイミングで自動クリックする小さなStimulusコントローラを足すのが定番です。自動の手軽さを取るなら方式A、フラットな構造と拡張性を取るなら方式B、と覚えておけば迷いません。
レスポンスを軽くする(方式A向けの最適化)
方式Aでは、次ページの取得リクエストにも通常のレイアウト(ヘッダー・フッター・ナビ)が丸ごと乗ってしまいます。Turboは同IDのフレーム部分しか使わないので、レイアウトを外しておくと転送量を減らせます。これは1行で対応できます。
def index
@pagy, @posts = pagy_countless(Post.order(created_at: :desc), limit: 10)
# フレームからの読み込みならレイアウトを外して軽くする
render layout: false if turbo_frame_request?
endturbo_frame_request? は「Turbo Frameからのリクエストかどうか」を判定するヘルパーです。これで、2ページ目以降の取得ではヘッダー等を再描画せず、必要な記事フレームだけを返せます。方式B(Turbo Stream)はそもそもレイアウトが適用されないので、この対策は不要です。
なお、一覧で post.user のような関連を表示するなら、includes でN+1を潰しておくのも忘れずに。詳しくはRailsのN+1問題入門を参照してください。無限スクロールは1リクエストあたりの件数が少ないぶん見落としがちですが、ページを重ねるほど効いてきます。
よくあるつまずきと対処
① 次ページが差し込まれない(フレームIDの不一致)
方式Aでいちばん多いのがこれです。リクエストした空フレームのIDと、返すHTMLに含まれるフレームのIDが一致していないと、Turboは差し替え先を見つけられません。
今回は posts_page_#{@pagy.page} と posts_page_#{@pagy.next} を使い、リクエスト側(next)と応答側(page)のページ番号が噛み合うように組んでいます。IDの組み立て式を変えるときは、両側がズレないか必ず確認してください。
② スクロールしても発火しない
loading: :lazy のフレームは「画面内に入ったら」読み込まれます。裏を返すと、ページ全体が短くて空フレームが最初から画面内にある場合は、スクロール前に即ロードされます(これは正常動作です)。
逆に「まったく発火しない」ときは、一覧を囲む要素に height 固定+ overflow: hidden がかかっていて、フレームが可視領域に入れないケースを疑います。まずは余計な overflow を外して確認しましょう。
③ 最後のページで空フレームが出続ける/二重読み込み
@pagy.next の nil 判定が抜けていると、最後のページでも空フレームを出し続け、存在しないページを叩いてしまいます。<% if @pagy.next %> で必ず囲むのが対策です。今回のコードはこの判定を入れてあります。
④ 「もっと見る」でページ全体に遷移してしまう(方式B)
方式Bで data: { turbo_stream: true } を付け忘れると、GETリンクがTurbo Streamとして扱われず、普通のページ遷移になってしまいます。リンクに data-turbo-stream が出力されているかを、ブラウザの開発者ツールで確認してください。
⑤ スクロールするほど重くなる
無限スクロールの宿命として、読み込むほどDOMの要素が増え続けます。方式Aはフレームの入れ子も深くなります。数百件を超えて積む可能性があるなら、一定件数で「もっと見る」ボタンに切り替える、または画面外に出た古い要素を間引く、といった設計を検討しましょう。
まとめ
Turbo Framesを使えば、専用のJavaScriptライブラリなしで無限スクロールが作れます。ポイントを振り返ります。
- 下準備は
pagy_countless。COUNTを省きつつ@pagy.nextで次ページ有無を判定する - 方式A(
loading: :lazy):末尾に次ページ用の空フレームを置くだけ。スクロールで自動。手軽だがDOMが入れ子に深くなる - 方式B(Turbo Stream):
appendでフラットに積む。「もっと見る」ボタンに流用できる - 方式Aは
turbo_frame_request?でレイアウトを外すと軽くなる @pagy.nextのnil判定を忘れず、最後のページで止める
まずは方式Aを一覧ページに置いてみて、スクロールで次ページが継ぎ足される動きを体験してみてください。「もっと見る」にしたくなったら方式Bへ、という順で育てていくのがおすすめです。Turboの「同IDフレームを差し替える」感覚は、遅延読み込みとまったく同じなので、両方を押さえるとHotwireの使いどころがぐっと広がります。
この記事が役に立ったら、Xもフォローしてください
CTO・取締役として実際にやっている「AIをチームの仕組みにする方法(Claude Code活用・AX)」と、Rails・Tailwindの実務Tipsを発信しています。
スポンサーリンク
