<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>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "form"]
static values = {
delay: { type: Number, default: 300 },
minLength: { type: Number, default: 2 }
}
connect() {
this.timeout = null
this.abortController = null
}
search() {
clearTimeout(this.timeout)
const query = this.inputTarget.value.trim()
if (query.length < this.minLengthValue) {
// Clear results if query is too short
this.clearResults()
return
}
this.timeout = setTimeout(() => {
this.submitSearch()
}, this.delayValue)
}
submitSearch() {
// Abort previous request if still pending
if (this.abortController) {
this.abortController.abort()
}
this.abortController = new AbortController()
// Programmatically trigger form submission
this.formTarget.requestSubmit()
}
clearResults() {
const resultsFrame = document.getElementById('search_results')
if (resultsFrame) {
resultsFrame.innerHTML = ''
}
}
}
class PostsController < ApplicationController
def index
@posts = if params[:q].present?
Post.published
.search(params[:q])
.includes(:author)
.page(params[:page])
else
Post.none
end
respond_to do |format|
format.html
format.turbo_stream
end
end
end
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.