Searchjoy: search analytics for Rails without a separate service
Search analytics made easy
At a glance
- What is it?
- Searchjoy is a Rails engine that records searches in your own database and serves a dashboard of them, so you can see what people typed, how many results they got, and which searches later converted. It stores rows in your database rather than shipping events to a vendor, which is the whole design decision.
- Who is it for?
- Adopt Searchjoy when you run a Rails app with search, you want to see actual queries and their conversion rate, and you would rather have that data in your own database than in a third-party analytics dashboard. Do not adopt it if you need cross-application reporting, a data warehouse pipeline, or a maintained release stream, since the repository has no GitHub releases attached and the last push was on 2026-04-08.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Activity is slowing. The repository last received commits 6 months ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Searches are rows in your database, not events shipped elsewhere
Searchjoy is a Rails engine for search analytics, and its central design choice is where the data lands. The README says to track searches by creating a record in the database, and the record model is a `Searchjoy::Search` with a handful of attributes.
A search record carries the search type, which the example says is typically the model name, the query string, the results count, and a user id. That is the entire schema the gem asks you to accept out of the box.
The alternative approach in this space is to forward search events to an external service, either a hosted analytics product or a pipeline into a data warehouse, and read the numbers back from a dashboard someone else renders. Searchjoy does the opposite. The searches are rows in the same database as the rest of your application, and the dashboard is a Rails engine mounted into your own app.
That choice has consequences in both directions. Queries stay in your infrastructure, they are joinable with your application tables in SQL, and there is no per-event cost or third-party rate limit. What you give up is the hosted tooling that comes with it: aggregation happens in your database, so a large search table needs the same care as any other growing table, and the README says as much in its data retention section.
The engine works with any search platform, including Elasticsearch, OpenSearch, Sphinx and Solr, and the README positions it as a companion to Searchkick, the gem that handles the search itself.
Installation is a Gemfile line, a generator, and a mounted engine
The install is four steps and the README gives each one explicitly.
First, the Gemfile:
gem "searchjoy"Second, the generator, which creates a migration to store searches, and the migration itself:
rails generate searchjoy:install
rails db:migrateThird, the dashboard is mounted in `config/routes.rb`:
mount Searchjoy::Engine, at: "searchjoy"And then the warning that matters most. The README says to be sure to protect the endpoint in production and points at its authentication section, because the engine ships with no authentication of its own. A mounted engine at a guessable path, containing a live feed of what your users are searching for, is exactly the kind of thing that leaks.
The live demo is at searchjoy.dokkuapp.com, and the README links it as a way to see it in action, along with a screenshot of the dashboard. The demo is a hosted instance, so it is the fastest way to understand the interface before you wire anything up.
For development, the README gives a clone and test sequence using bundler and rake:
git clone https://github.com/ankane/searchjoy.git
cd searchjoy
bundle install
bundle exec rake testTracking works manually, or automatically through Searchkick
Recording a search is a single model creation, and the README's example fills in the four attributes directly:
Searchjoy::Search.create(
search_type: "Item", # typically the model name
query: "apple",
results_count: 12,
user_id: 1
)The `results_count` field is the one that makes the data useful, because it lets the dashboard distinguish a query that matched a lot of things from one that matched nothing, and a zero-result query is the classic symptom of a search implementation that needs work.
If you use Searchkick, the recording can be automatic through its `track` option, so you do not have to remember to log every search:
Item.search("apple", track: {user_id: 1})Extra attributes are supported and the mechanism is additive. You add a column to the `searchjoy_searches` table, for example a `source` string column, and then pass the value through the same `track` option:
Item.search("apple", track: {user_id: 1, source: "web"})The same pattern works for a non-Searchkick setup, since the `track` hash is just a way of attaching attributes to the record. The important detail is that the column has to exist before the value is passed, which is an easy source of confusion when adding a field later.
Conversions need a metric chosen before the code is written
Tracking a conversion takes two steps, and the README puts the decision before the mechanics. The first step is to choose a conversion metric, and the example given is at Instacart, where an item added to the cart from the search results page counts as a conversion.
The mechanics follow the search. When a user searches, you keep track of the search id, and with Searchkick you get it from the results object:
results = Item.search("apple", track: true)
results.search.idWhen the user converts, you find the record and call `convert`:
search = Searchjoy::Search.find(params[:id])
search.convertAnd the better version records which model converted:
search.convert(item)That second form is what makes the data analysable, because it stores the converted model rather than a bare flag. The `search_type` and `results_count` fields on the search then describe the query that led to the conversion, and the dashboard can compare a search that converted often against one that never did.
The design assumes the search id survives the trip from the results page to the conversion action, which means it has to be carried in a session, a parameter, or both. The README's example reads it from `params[:id]`, so your conversion route is expected to receive it. That plumbing is yours to build, and it is the part of the integration with the most room to go subtly wrong.
Protecting the dashboard is the reader's job, and the README says so twice
Searchjoy's dashboard is a mounted Rails engine, and the README repeats the warning in two places, once at installation and again in a dedicated authentication section headed with a reminder to protect the dashboard in production.
Two mechanisms are documented. The first is Devise, wrapping the mount in an authenticate block that checks for an admin:
authenticate :user, ->(user) { user.admin? } do
mount Searchjoy::Engine, at: "searchjoy"
endThe second is basic authentication, with the credentials set in the environment or in an initializer:
ENV["SEARCHJOY_USERNAME"] = "andrew"
ENV["SEARCHJOY_PASSWORD"] = "secret"Both are thin. The Devise variant is a single route-level guard, which means the protection depends on your user model having an `admin?` method and on Devise being the authentication layer you already use. The basic authentication variant puts a password in an environment variable, which is fine for an internal tool and not much else, and the README does not describe HTTPS enforcement or credential rotation.
The search data itself is the sensitive part. The dashboard exposes the raw query strings your users typed, and people search for things that reveal intent, including things they would not want seen. A dashboard that is publicly reachable at a known path is therefore a different kind of problem from a generic admin page, and the two documented mechanisms are a starting point rather than a finished answer.
The library is MIT licensed, with the terms in the LICENSE.txt file at the repository root, and the top level of the repository shows the usual Rails gem structure with `app/`, `config/`, `lib/`, `test/`, a `CHANGELOG.md` and a `searchjoy.gemspec`.
Data retention is a delete job you write and schedule yourself
The README takes a direct position on retention: data should only be retained for as long as it is needed. That principle then becomes a piece of code you run.
The deletion snippet finds searches older than a year and removes them along with their conversions, in batches, so the delete does not load the whole set at once:
Searchjoy::Search.where("created_at < ?", 1.year.ago).find_in_batches do |searches|
search_ids = searches.map(&:id)
Searchjoy::Conversion.where(search_id: search_ids).delete_all
Searchjoy::Search.where(id: search_ids).delete_all
endThe ordering in that snippet is the point worth noticing. Conversions are deleted first, because they reference the search id, and then the searches go. Deleting the searches first would leave orphaned conversion rows.
The same pattern appears again for deleting the data of a single user, which is the snippet you want when a user requests erasure:
user_id = 123
search_ids = Searchjoy::Search.where(user_id: user_id).pluck(:id)
Searchjoy::Conversion.where(search_id: search_ids).delete_all
Searchjoy::Search.where(id: search_ids).delete_allBefore deleting, the README points at Rollup, another gem by the same author, to aggregate important data first. That is the sensible order of operations: roll up what you need to keep into a summary, then delete the raw rows.
None of this runs by itself. The snippet is a template you paste into a rake task or a job and schedule, and the README does not describe a built-in scheduler. A team that installs the gem and never revisits this will find a growing table of raw query strings, which is both a storage problem and a privacy problem.
Customization is a handful of setters in one initializer
Everything adjustable lives in `config/initializers/searchjoy.rb`, and the settings are direct assignments on the `Searchjoy` module rather than a configuration object with nesting.
The time zone is one of them, and it defaults to `Time.zone`:
Searchjoy.time_zone = "Pacific Time (US & Canada)" # defaults to Time.zoneThe number of top searches shown is another, defaulting to 100 and settable to 500:
Searchjoy.top_searches = 500 # defaults to 100The rest are lambdas that receive the search or the converted model, which is how the dashboard text and links are made to fit your application. `query_url` builds the link to the search results page:
Searchjoy.query_url = ->(search) { Rails.application.routes.url_helpers.items_path(q: search.query) }`query_name` controls what appears in the live stream, and the example appends the city to the query:
Searchjoy.query_name = ->(search) { "#{search.query} #{search.city}" }And `conversion_name` controls how the converted model is labelled:
Searchjoy.conversion_name = ->(model) { model.name }Two things are worth noting about this design. First, the lambdas are evaluated in the context of your app, so the query_url example reaches into your routes, which means the dashboard can produce working deep links to your own search results. Second, the `query_name` example references `search.city`, a field that is not in the default schema, so it assumes you have added it with the same add_column mechanism described earlier.
The README lists what the dashboard does at the top of the page: view searches in real-time, track conversion rate week over week, and monitor the performance of top searches. The customization options all serve those three views.
Editorial conclusion
Adopt Searchjoy when you run a Rails app with search, you want to see actual queries and their conversion rate, and you would rather have that data in your own database than in a third-party analytics dashboard. Do not adopt it if you need cross-application reporting, a data warehouse pipeline, or a maintained release stream, since the repository has no GitHub releases attached and the last push was on 2026-04-08. Verify first that you protect the mounted dashboard route in production, because the README is explicit that the endpoint must be protected and it ships with no authentication of its own.
Frequently asked questions
How do I install Searchjoy in a Rails app?
Add `gem "searchjoy"` to the Gemfile, run `rails generate searchjoy:install` followed by `rails db:migrate` to create the table for searches, and mount the dashboard with `mount Searchjoy::Engine, at: "searchjoy"` in config/routes.rb.
How do I record a search with Searchjoy?
Create a `Searchjoy::Search` record with the search type, query, results count and user id. With Searchkick the same thing happens through the `track` option, for example `Item.search("apple", track: {user_id: 1})`.
How do I track conversions in Searchjoy?
Choose a conversion metric first, then keep the search id when the user searches and call `convert` on the record when they convert, optionally passing the model that converted, for example `search.convert(item)`. The README uses an item added to the cart from the search results page as its example metric.
How do I protect the Searchjoy dashboard in production?
The engine ships without authentication, so you must protect the mounted route. The README shows wrapping the mount in a Devise `authenticate` block that checks `user.admin?`, or setting `ENV["SEARCHJOY_USERNAME"]` and `ENV["SEARCHJOY_PASSWORD"]` for basic authentication.
How should I delete old Searchjoy data?
The README says data should only be retained as long as it is needed, and gives a batched delete that removes conversions before the searches they reference. It also points at Rollup to aggregate important data before deleting, and suggests using Rollup to preserve key metrics.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/ankane-searchjoy)Was this useful?