We'll go over adding instant search through Meilisearch on top of an existing Rails 7 web application along with some changes you can make to improve the dev experience.
Requirements and Assumptions
- Ruby 3.3.5
- Node 23
- Rails 7
Flow
Completed Version
Don't wanna listen to me yap? Run the following commands to boot up the completed demo. If you're running docker on your local pc you'll be able to access it at http://localhost:3000
git clone https://github.com/tadghh/meidemo.git;
cd ./meidemo/;
docker build . -t mei_search_rails:demo;
docker run -p 3000:3000 mei_search_rails:demo;
# to exit container SIGTERM * 3 (crtl+c)
Setup
Initialize project
git clone [email protected]:tadghh/meidemo.git;
bundle install;
yarn install;
yarn build;
rails db:create;
rails db:migrate;
# once Meilisearch has been downloaded and configured, then you can run the seed file (rails db:seed)
Adding Meilisearch
Add the gem and run the installer
bundle add meilisearch-rails;
bin/rails meilisearch:install;
The installer creates the config file used to configure the Meilisearch Gem.
# /config/initializers/meilisearch.rb
MeiliSearch::Rails.configuration = {
meilisearch_url: ENV.fetch("MEILISEARCH_HOST", "http://localhost:7700"),
meilisearch_api_key: ENV.fetch("MEILISEARCH_API_KEY", "FALLBACK_VALUE")
}
Creating the Master Key
Copy the output of the following command, this will be our Master key
for Meilisearch.
openssl rand -hex 16
# 34528d9b5c9638642b48e810da7c0499
Swap the result of the previous command with FALLBACK_VALUE
. Don't use the Master key
like this in a production environment. Check the repos /Dockerfile
for info on implementation
MeiliSearch::Rails.configuration = {
#...
# meilisearch_api_key: ENV.fetch("MEILISEARCH_API_KEY", "FALLBACK_VALUE")
meilisearch_api_key: ENV.fetch("MEILISEARCH_API_KEY", "34528d9b5c9638642b48e810da7c0499")
}
Add the Master key
to /.env
MEILI_MASTER_KEY="34528d9b5c9638642b48e810da7c0499"
More info here if needed: Securing your project
Downloading the Server (Self Hosting)
Next we'll add the Meilisearch server to our /.gitignore
. Once you've done this, push your changes.
# /.gitignore
/meilisearch
AFTER you have pushed the latest /.gitignore
to your repo, then curl the installer. If you skip this GitHub will complain about the servers file size, preventing you from pushing future changes.
This guide assumes the server is located in the root of your project (same place as /.ruby-version
).
curl -L https://install.meilisearch.com | sh
Model Indexing
Model Index Setup
class Community < ApplicationRecord
include MeiliSearch::Rails
has_many :posts, dependent: :destroy
# This allows the communities name and description to be 'queried/searched'
meilisearch index_uid: "SiteCommunities" do
attribute [ :name, :description ]
end
# We can eagerload related objects too
scope :meilisearch_import, -> { includes(:posts) }
end
class Post < ApplicationRecord
include MeiliSearch::Rails
belongs_to :community
# index the title and content of posts
meilisearch index_uid: "SitePosts" do
attribute [ :title, :content ]
end
end
Indexing Behavior Customization
The below example would only index posts from public communities. This will run every time an item is indexed, if you're expecting high traffic consider how this will scale.
meilisearch index_uid: "SitePosts", if: :public_community? do
attribute [ :title, :content ]
end
Creating the Search Endpoint
Route
# config/routes
get "/search", to: "communities#search", as: "search"
Endpoint
Here is the method that will perform our multisearch. The official documentation showcases this by calling the Models themselves, this led to weird behavior resulting in un-needed DB queries. Using a custom index_uid solves this, we set these in the model files.
class CommunitiesController < ApplicationController
before_action :set_results, only: [ :search ]
def search
respond_to do |format|
format.json do
render json: @results
end
end
end
private
def set_results
## if query param is empty its set to 'nothing'
@search_query = params[:query] || ""
@results = perform_search(@search_query)
end
# performs a multisearch across communities and posts. returning json containing related data.
def perform_search(query)
multi_search_results = MeiliSearch::Rails.multi_search(
"SiteCommunities" => { q: query },
"SitePosts" => { q: query }
)
# These values are the searchable attributes from models
# This map is also our return
multi_search_results.map do |result|
if result["title"]
{ title: result["title"], content: result["content"] }
else
{ name: result["name"], description: result["description"] }
end
end.compact
end
end
Verification
Correct
Incorrect
Notice the extraneous DB queries.
Deleting indexes
curl -X DELETE 'http://127.0.0.1:7700/indexes/INDEX_NAME' -H 'Authorization: Bearer MASTER_KEY'
Instant Search
The following JS sends search queries to our endpoint. Assuming a successful response it will process the JSON, adding the results to the resultsContainer
.
JavaScript
Add this to your application.js
or wherever
// /app/javascript
// Runs on turbo load
document.addEventListener("turbo:load", () => {
const SEARCH_DELAY = 300; // In miliseconds
const searchInput = document.getElementById('searchinput');
const resultsContainer = document.getElementById('hits');
const progressBar = document.getElementById('progress-bar');
let debounceTimeout;
// Function to fetch search results
const fetchSearchResults = (query) => {
// If no search bail, you should check the Meilisearch docs they cover the default behaviour when given an empty search
// This isnt technically needed.
if (!query) {
return;
}
fetch(`/search.json?query=${query}`)
.then(response => response.json())
.then(data => {
// Create posts, add them all at once, less updates to the DOM
// If you wanted to animate them appearing as streamed you could put a opacity transition on the li elements
// ofc you would need to but the resultsContainer inside a loop
resultsContainer.innerHTML = data.length ?
data.map(post => createListItem(post)).join('') :
'<li>No results found.</li>';
})
.catch(error => {
console.error('Error fetching search results:', error);
resultsContainer.innerHTML = '<li>Error fetching results.</li>';
});
};
// Function to create list item
const createListItem = (search_result) => {
// We got a title field so it must be a post
if (search_result.title) {
return `<li>
<p>Post: ${search_result.title}</p>
<p class="text-sm text-neutral-200">${search_result.content.slice(0, 70)}</p>
</li>`;
}
return `<li>
<p>Community: ${search_result.name}</p>
<p class="text-sm text-neutral-200">${search_result.description.slice(0, 70)}</p>
</li>`;
};
// Animation | Search input delay, not everyone types fast
const debounce = (func, delay) => {
return (...args) => {
clearTimeout(debounceTimeout);
progressBar.style.transition = 'none';
progressBar.style.width = '0'; // Reset progress bar
progressBar.style.opacity = '1'; // Reset opacity
// Start the progress bar animation
setTimeout(() => {
progressBar.style.transition = `width ${delay}ms linear, opacity 0.3s ease-in`;
progressBar.style.width = '100%'; // Slide to 100%
}, 10); // Small delay to ensure the CSS transition works
debounceTimeout = setTimeout(() => {
func(...args); // Execute the search, we are 'consuming ourself' here, Goto fig: 1
progressBar.style.opacity = '0';
setTimeout(() => {
progressBar.style.width = '0';
}, SEARCH_DELAY);
}, delay);
};
};
// Event listener for input changes (search as you type) with debounce/delay
searchInput.addEventListener('input', debounce((event) => {
fetchSearchResults(event.target.value); // fig: 1
}, SEARCH_DELAY));
});
Search Box
<div class="relative z-10 px-12 my-2 w-full grow group focus:outline-none user-focus-item">
<div tabindex="0" id="search-cont">
<div id="progress-bar"></div>
<div class="flex w-full overflow-clip focus:outline-none">
<input id="searchinput" type="text" placeholder="Search..." />
</div>
</div>
<div id="searchresults">
<ul id="hits"></ul>
</div>
</div>
Improving DevEx
You can either leave the server running in the background, or start and stop it automatically by making the below changes.
Add the following line to your Procfile.dev
to start Meilisearch when running bin/dev s
.
# /Procfile.dev
search: ./meilisearch --no-analytics
Make sure the Master key
is set in your .env
file.
# /.env
MEILI_MASTER_KEY="34528d9b5c9638642b48e810da7c0499"
The dotenv-rails
gem will allow Meilisearch to pickup the .env
file when seeding the DB.
# /Gemfile
gem "dotenv-rails", groups: [ :development, :test ]
bundle install
Make the following edits to your /db/seeds.rb
if Rails.env.development?
# Make sure no previous instance is running
system("pkill -f meilisearch")
# Wait
sleep 1
# Start Meilisearch server
# Cant find the master key? has the 'dotenv-rails' gem been added and installed?
system("./meilisearch --no-analytics &")
# Wait for boot to complete
sleep 1
# Dump current indexes
Post.clear_index!
Community.clear_index!
# Remove current db content
Post.destroy_all
Community.destroy_all
end
# Seed data creation
if Rails.env.development?
system("pkill -f meilisearch")
end
Running in Production
When deploying publicly make sure that the restricted API key is used for MEILISEARCH_API_KEY
. You can retrieve it by hitting the route below.
curl -s -X GET "${MEILISEARCH_HOST}/keys"-H "Authorization: Bearer ${MEILI_MASTER_KEY}"
# Or use the following to get the api key directly (requires jq) and set the env for the current session
export MEILISEARCH_API_KEY=$(curl -s -X GET "${MEILISEARCH_HOST}/keys" \
-H "Authorization: Bearer ${MEILI_MASTER_KEY}" | \
jq -r '.results[] | select(.description == "Use it to search from the frontend") | .key')
You can also set the key here.
# /config/initializers/meilisearch.rb
MeiliSearch::Rails.configuration = {
meilisearch_url: ENV.fetch("MEILISEARCH_HOST", "http://0.0.0.0:7700"),
meilisearch_api_key: ENV.fetch("MEILISEARCH_API_KEY", "RESULT_OF_CURL_REQUEST")
}
Review the following if needed: Securing your project