testimonials
Testimonials, reviews and NPS for Rails. Text and video, collected inside your own app, stored in your own database. Self-hosted alternative to Testimonial.to / Senja / Delighted.

Install
# Gemfile
gem "testimonials"
bundle install
bin/rails generate testimonials:install
bin/rails db:migrate
<%# app/views/layouts/application.html.erb %>
<%= testimonials_tag %>
The generator writes the initializer, the migration, and mounts the engine at
/testimonials. Nothing renders until the widget is opened.
[!IMPORTANT] The dashboard defaults to development only. Set
authorize_adminbefore you deploy — see Configure.
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want video/avatar uploads.
What you get
| | |
| --------------- | ----------------------------------------------------------------------- |
| Widget | iOS-style star prompt → rating, guiding questions, text, video, consent |
| Video | Recorded in-browser (MediaRecorder) with a review step, or uploaded |
| NPS | 0–10 + comment. Promoters routed into the testimonial ask |
| Public page | /testimonials/new — a shareable link for customers outside the app |
| Dashboard | Pending → approved → archived, search, feature toggle, best-line picker |
| Display | Headless. Your markup, via the models or a JSON API |
| Deps | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step |
| Auth | Lambdas over the raw request — Devise, Rails 8 auth, anything |
| i18n | 26 languages, including localized guiding questions |
| Turbo/CSP | Turbo Drive and strict nonce-based CSP out of the box |
Why self-host it
testimonials |
A third-party embed | |
|---|---|---|
| Cost | Free, MIT | Monthly subscription |
| Where testimonials live | Your database | The vendor's |
| Attribution to your user records | Server-side, from the session | Whatever the visitor types |
| Prompting at in-app success moments | One method call in a controller | Not reachable from your backend |
| Page weight | One <script>, no framework, no CDN |
Third-party bundle + requests |
| Consent record | Stored text snapshot, in your rows | Vendor's terms |
| Multi-tenant | Built in, one resolver | Usually a plan upgrade |
| Rendering | Your markup, your CSS | Their widget, their branding |
| If the vendor disappears | Nothing happens | You lose the wall of love |
The whole flow
| 1. A star card appears at a success moment | 2. Tapping a star expands the full form |
|---|---|
![]() |
![]() |
| Nothing renders until it's eligible. "Not now" is always one tap away. | Guiding questions fight blank-page paralysis. Video is offered, never required. |
| 3. Camera check before recording | 4. Review it before sending |
![]() |
![]() |
| A countdown and a preview, so nobody is caught mid-blink. | Record again as often as they like. The blob stays in the browser until Send. |
| 5. Attached, written, consented | 6. Lands in your dashboard as pending |
![]() |
![]() |
| Consent is an explicit choice in the customer's own words. | Nothing is public until you approve it. |
Prompting users
Call it at a success moment. Throttling is built in, so call it liberally:
testimonial_prompt! # ask for a testimonial
testimonial_prompt!(:nps) # ask for an NPS score instead
class InvoicesController < ApplicationController
def create
# ...
testimonial_prompt! if current_user.invoices.count == 10
redirect_to invoices_path
end
end
The widget auto-opens on the next rendered page if the throttle allows:
| Situation | Result |
|---|---|
| Already submitted | Never auto-prompted again |
| Dismissed | Not again within reprompt_after (90 days) |
Auto-prompted max_prompts times (3) |
Never again |
Explicit opens bypass throttling — any element with
data-testimonial-prompt (or ="nps"), or window.Testimonials.open() /
window.Testimonials.openNps().
Configure
Everything is optional — a fresh install works with zero config. In
config/initializers/testimonials.rb:
| Option | Default | What it does |
|---|---|---|
authorize_admin |
development only | Who can read the dashboard. Override before deploying |
app_name |
Rails app name | Shown as "Enjoying %app?" and in the questions |
enabled |
everyone | Who gets the widget. false hides it and rejects posts |
current_user |
nil |
Attribute a submission to a user. Receives the request |
user_display |
{ name:, email: } |
Turns that user into stored attribution |
tenant |
nil |
One collection per tenant — see Multi-tenancy |
questions |
localized built-ins | Guiding prompts. Array, callable, or [] to hide |
consent_text |
localized default | The wording customers agree to |
video |
true |
Video recording and upload (needs Active Storage) |
max_video_seconds |
120 |
Recording cap |
max_video_size |
50.megabytes |
Enforced server-side |
avatars |
true |
Headshot upload for guests on the public page |
max_avatar_size |
5.megabytes |
Enforced server-side |
reprompt_after |
90.days |
Cooldown after a dismissal |
max_prompts |
3 |
Lifetime auto-prompt cap per user |
public_collection |
true |
The shareable /testimonials/new page |
public_api |
false |
Serve the read API without auth (CORS *) |
nps |
true |
The 0–10 NPS flow |
nps_reprompt_after |
90.days |
Cooldown between NPS asks |
rate_limit |
{ to: 5, within: 1.minute } |
Per-IP throttle (Rails 7.2+). nil disables |
mount_path |
"/testimonials" |
Keep in sync with mount in routes.rb |
on_submit |
no-op | Runs after each save — Slack, email, CRM |
on_detractor |
no-op | Runs for NPS 0–6 |
A typical initializer:
Testimonials.configure do |config|
config.app_name = "SupeRails"
config.current_user = ->(request) { request.env["warden"]&.user }
config.user_display = ->(user) { { name: user.name, email: user.email } }
config. = ->(request) { request.env["warden"]&.user&.admin? }
config.on_submit = ->(record) { SlackNotifier.ping(record) }
config.on_detractor = ->(nps) { Ideasbugs::Feedback.create!(kind: "other", message: nps.comment.to_s) }
end
Gates receive the raw request, so they work with any auth:
# Devise / Warden
config.current_user = ->(request) { request.env["warden"]&.user }
# Rails 8 built-in auth (bin/rails generate authentication)
config.current_user = lambda do |request|
token = request.["session_token"]
Session.find_signed(token)&.user if token
end
Guiding questions, localized
The questions above the form ("How has %app helped you?") fight blank-page paralysis — they are prompts, not form fields. By default they come from the gem's locale files, so all 26 languages get good questions with your app name interpolated.
config.questions = ["What convinced you to try %{app}?", "What changed since?"]
config.questions = -> { I18n.t("reviews.questions") } # your own locale keys
config.questions = [] # hide the section
Dashboard
Pending → approved → archived tabs, search, quick-approve, inline video playback, feature toggle, and a Best line picker.
Every submission keeps its provenance — who sent it, the exact consent text they agreed to, the page, locale, browser, timestamp. Best line is the only thing you write: the customer's words stay verbatim, you just choose which sentence your landing page pulls out.
The read API
| Endpoint | Returns |
|---|---|
GET /testimonials/api/testimonials |
Approved + publicly-consented records. Filters: featured=1, min_rating=4, kind=video, limit=12 |
GET /testimonials/api/stats |
count, average_rating, ratings_count, nps_score |
Admin-only by default. config.public_api = true serves them without auth
(CORS *) — ideal for a static marketing site rendering your wall of love at
build time. Emails and author ids are never serialized. Video and avatar
files go through the same gate, with Range support so <video> just works.
Rendering what you collect
The gem ships no display UI on purpose. examples/ has
copy-paste starting points:
| Example | What it renders |
|---|---|
wall_of_love.html.erb |
A responsive grid of text + video testimonials |
testimonial_card.html.erb |
A single quote card for a landing/pricing page |
badge.html.erb |
The "★ 4.9 from 87 reviews" chip |
json_ld.html.erb |
schema.org markup for Google rich snippets |
static_site.md |
Rendering on a separate marketing site (Astro & friends) |
NPS
testimonial_prompt!(:nps) asks the classic 0–10 question. Promoters (9–10)
are offered the testimonial form immediately — but only if the testimonial
throttle would allow it. Detractors (0–6) trigger on_detractor, which pairs
well with ideasbugs. Your score and
every response live at /testimonials/nps_responses.
The public collection page
/testimonials/new is a standalone, self-styled page for customers outside the
app — drop the link into an email or DM. Guests leave name, email, optional
title/company and photo. Disable with config.public_collection = false.
Multi-tenancy
Scope testimonials to a tenant — each Organization (or Product, Course, Store) with its own collection, dashboard, widget and read API — with one resolver:
config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
Return an opaque key: a GlobalID, an id, a subdomain, a slug. The gem never
takes a foreign key into your models and never needs to know what an
Organization is. It stamps the key on each submission and scopes every read —
dashboard, /api/testimonials, /api/stats, NPS, the public page. nil — the
default — is a single global collection, so single-tenant apps need none of
this.
Authorization composes: authorize_admin says who is an admin, tenant says
which tenant they're in. "One review per user" becomes one per user per
tenant.
Optional model sugar, and upgrading an existing install
A veneer over the string key, not a new coupling:
class Organization < ApplicationRecord
has_testimonials # keyed by to_gid.to_s (match config.tenant)
end
organization.testimonials.approved # a normal Active Record relation
organization.testimonials_nps # NPS responses for this tenant
Apps that skip the concern still get full multi-tenancy from the resolver
alone. Keying by something other than GlobalID? Pass a matching resolver to
both: has_testimonials(key: ->(o){ o.subdomain }) and the same in
config.tenant.
Upgrading: the tenant column is additive and nullable. Run
bin/rails generate testimonials:tenant && bin/rails db:migrate (a fresh
install already includes it). Existing rows keep a nil tenant — the global
collection — so nothing changes until you set config.tenant.
Who's using it
- PaidCollabs — the screenshots above
Shipping it? Open a PR and add yourself.
Development
bundle exec rake test
bundle exec rubocop
Bug reports and pull requests welcome. The fastest way to help is to install it in a real app and tell me where it hurt.
Also by the same author
- ideasbugs — in-app bug reports and feature requests. Pairs with this gem's NPS detractor hook.
- SupeRails — Rails screencasts.
License
MIT. If it saved you a subscription, a ⭐ is a fair trade.





