Rails 8 Full-Text Search with MySQL

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
end

Creating 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
end

The 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
end

The 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
end

Handling 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
end

Include 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.

10 claps
← Back to Blog