Adding search to a Rails application often triggers a reach for Elasticsearch or Meilisearch. But MySQL's built-in full-text search handles most use cases remarkably well—and keeps the stack simple. This guide covers implementing robust search in Rails 8 using MySQL's native capabilities.
Understanding MySQL Full-Text Search
MySQL supports full-text indexing on CHAR, VARCHAR, and TEXT columns using InnoDB tables. Full-text search offers natural language mode (relevance-ranked results), boolean mode (operators like + and -), and query expansion (finding related terms). For applications searching articles, products, or user-generated content, these features provide production-ready search without external services.
Setting Up Full-Text Indexes
Start with a migration that adds a full-text index to searchable columns. The key is indexing multiple columns together when searches should span them.
# db/migrate/20260128000001_add_fulltext_index_to_articles.rb
class AddFulltextIndexToArticles < ActiveRecord::Migration[8.0]
def change
add_index :articles, [:title, :body], type: :fulltext, name: 'fulltext_articles_content'
add_index :articles, :title, type: :fulltext, name: 'fulltext_articles_title'
end
endCreating separate indexes allows searching just titles (for autocomplete) or full content (for deep search). MySQL handles the index maintenance automatically as records change.
Building the Search Query
MySQL's MATCH...AGAINST syntax powers full-text queries. Wrap this in a scope to keep controllers clean and make the search reusable throughout the application.
# app/models/article.rb
class Article < ApplicationRecord
belongs_to :author
has_many :comments, dependent: :destroy
scope :published, -> { where(published: true) }
scope :search, ->(query) {
return none if query.blank?
sanitized_query = sanitize_sql_like(query)
where(
"MATCH(title, body) AGAINST(? IN NATURAL LANGUAGE MODE)",
sanitized_query
).select(
"articles.*, MATCH(title, body) AGAINST(#{connection.quote(sanitized_query)} IN NATURAL LANGUAGE MODE) AS relevance_score"
).order("relevance_score DESC")
}
scope :search_titles, ->(query) {
return none if query.blank?
where(
"MATCH(title) AGAINST(? IN NATURAL LANGUAGE MODE)",
sanitize_sql_like(query)
)
}
def self.boolean_search(query)
return none if query.blank?
# Boolean mode allows operators: + (must include), - (exclude), * (wildcard)
formatted_query = query.split.map { |term| "+#{term}*" }.join(" ")
where(
"MATCH(title, body) AGAINST(? IN BOOLEAN MODE)",
formatted_query
)
end
endThe search scope uses natural language mode, which ranks results by relevance. MySQL considers word frequency, document length, and how common the term is across all documents. The boolean_search method adds flexibility—prefixing terms with + requires them, and trailing * enables prefix matching for partial words.
Creating a Search Controller
A dedicated search controller handles queries and can search across multiple models when needed. Using Turbo Frames makes the search feel instant without full page reloads.
# app/controllers/searches_controller.rb
class SearchesController < ApplicationController
def show
@query = search_params[:q].to_s.strip
if @query.present?
@articles = Article.published
.search(@query)
.includes(:author)
.limit(20)
@products = Product.active
.search(@query)
.limit(10)
else
@articles = Article.none
@products = Product.none
end
respond_to do |format|
format.html
format.turbo_stream
end
end
private
def search_params
params.permit(:q)
end
endThe includes(:author) call prevents N+1 queries when rendering results. Setting explicit limits prevents runaway queries on broad search terms.
Building the Search Interface with Hotwire
Combine Turbo Frames with Stimulus for a responsive search experience. The search updates results as users type, with debouncing to avoid excessive requests.
# app/views/searches/show.html.erb
<%= form_with url: search_path, method: :get, data: {
turbo_frame: "search-results",
action: "input->search#submit"
} do |f| %>
<%= f.search_field :q,
value: @query,
placeholder: "Search articles and products...",
autofocus: true,
data: { search_target: "input" } %>
<% end %>
<%= turbo_frame_tag "search-results" do %>
<% if @query.present? %>
<% if @articles.any? %>
Articles (<%= @articles.size %>)
<% @articles.each do |article| %>
<%= render partial: "articles/search_result", locals: { article: article, query: @query } %>
<% end %>
<% end %>
<% if @products.any? %>
Products (<%= @products.size %>)
<% @products.each do |product| %>
<%= render partial: "products/search_result", locals: { product: product, query: @query } %>
<% end %>
<% end %>
<% if @articles.empty? && @products.empty? %>
No results found for "<%= @query %>"
<% end %>
<% end %>
<% end %>
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
connect() {
this.timeout = null
}
submit(event) {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
const query = this.inputTarget.value.trim()
// Only search if query is 2+ characters
if (query.length >= 2) {
event.target.closest("form").requestSubmit()
}
}, 300)
}
disconnect() {
clearTimeout(this.timeout)
}
}The 300ms debounce prevents firing requests on every keystroke. Requiring at least 2 characters avoids overly broad searches that return too many results.
Highlighting Search Terms
Highlighting matched terms in results helps users scan quickly. A simple helper handles this without adding gem dependencies.
# app/helpers/search_helper.rb
module SearchHelper
def highlight_search_terms(text, query)
return text if query.blank? || text.blank?
terms = query.split.map { |t| Regexp.escape(t) }
pattern = /(#{terms.join('|')})/i
sanitized_text = sanitize(text)
highlighted = sanitized_text.gsub(pattern) do |match|
"#{match}"
end
highlighted.html_safe
end
def excerpt_with_highlight(text, query, radius: 100)
return "" if text.blank?
first_term = query.to_s.split.first
excerpt_text = excerpt(text, first_term, radius: radius, separator: " ")
excerpt_text ||= truncate(text, length: radius * 2)
highlight_search_terms(excerpt_text, query)
end
endHandling Edge Cases
MySQL full-text search has a minimum word length (default 3-4 characters for InnoDB) and ignores stopwords like "the" and "and". For applications needing to search shorter terms, adjust MySQL configuration or fall back to LIKE queries for short inputs:
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
class_methods do
def smart_search(query, columns:)
return none if query.blank?
terms = query.to_s.strip.split
short_terms = terms.select { |t| t.length < 3 }
long_terms = terms.reject { |t| t.length < 3 }
results = all
if long_terms.any?
fulltext_query = long_terms.join(" ")
results = results.where(
"MATCH(#{columns.join(', ')}) AGAINST(? IN NATURAL LANGUAGE MODE)",
fulltext_query
)
end
short_terms.each do |term|
like_conditions = columns.map { |col| "#{col} LIKE ?" }.join(" OR ")
like_values = columns.map { "%#{sanitize_sql_like(term)}%" }
results = results.where(like_conditions, *like_values)
end
results
end
end
endInclude this concern in any model requiring search, and call smart_search with the appropriate columns.
Summary
MySQL full-text search delivers solid search functionality without operational overhead. Natural language mode handles relevance ranking automatically, boolean mode enables advanced queries, and combining these with Hotwire creates a responsive search experience. For most Rails applications, this approach provides everything needed—reserve external search engines for truly complex requirements like fuzzy matching, faceted search, or searching millions of documents.