Live search with Turbo Frames and debouncing

Jordan Lee Jan 2026
3 tabs
<div class="search-page">
  <div class="search-header mb-6">
    <h1 class="text-2xl font-bold mb-4">Search Posts</h1>

    <%= form_with url: posts_path,
        method: :get,
        data: {
          controller: "search",
          search_target: "form",
          turbo_frame: "search_results"
        } do |f| %>

      <div class="relative">
        <i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
        <%= f.search_field :q,
            value: params[:q],
            placeholder: "Search posts...",
            class: "form-input pl-10",
            autocomplete: "off",
            data: {
              search_target: "input",
              action: "input->search#search"
            } %>
      </div>
    <% end %>
  </div>

  <%= turbo_frame_tag "search_results" do %>
    <% if params[:q].present? %>
      <div class="search-results">
        <% if @posts.any? %>
          <p class="text-gray-600 mb-4">
            Found <%= pluralize @posts.total_count, 'post' %> for "<%= params[:q] %>"
          </p>
          <div class="space-y-4">
            <%= render @posts %>
          </div>
        <% else %>
          <div class="text-center py-12">
            <i class="fas fa-search text-4xl text-gray-300 mb-4"></i>
            <p class="text-gray-600">No posts found for "<%= params[:q] %>"</p>
            <p class="text-sm text-gray-500">Try different keywords</p>
          </div>
        <% end %>
      </div>
    <% end %>
  <% end %>
</div>
3 files · erb, javascript, ruby Explain with highlit

Real-time search enhances discoverability but naive implementations hammer the server with requests. I use Turbo Frames to scope search results and a Stimulus controller to debounce input events, only sending requests after typing pauses. The search frame loads results from a dedicated endpoint that returns just the results partial. Empty searches clear results gracefully. I also show loading states and handle empty results with helpful messages. For large datasets, I implement minimum query length requirements and abort in-flight requests when new queries arrive. This pattern works for any filtered list—products, users, documents—and degrades gracefully to traditional form submission without JavaScript.