Customization
Hotwire-flavored recipes for extending the starter — inline Turbo Frame editing, a Stimulus controller wired to Supabase Realtime, and a new ViewComponent in the dashboard chrome.
The kit is small on purpose. These three recipes cover the extensions you'll reach for first, and they're shaped around Hotwire — Turbo Frames, Stimulus, ViewComponent — rather than around generic Rails patterns.
Recipe 1 — Add an inline Turbo Frame for editing notes
The shipping /notes view is a read-only list. Let's add inline editing through a Turbo Frame: clicking a note swaps it for a form, submitting the form swaps it back for the updated text — no full page nav, no Stimulus.
1. Wrap each note in a Turbo Frame
<%# app/views/notes/index.html.erb %>
<% if @notes.any? %>
<ul data-test="notes-list" class="flex flex-col gap-2">
<% @notes.each do |note| %>
<%= turbo_frame_tag dom_id(note, :frame), class: "block" do %>
<li data-test="note-item" class="rounded-md border border-zinc-200 p-3 dark:border-zinc-700">
<%= note["content"] %>
<%= link_to "Edit", edit_note_path(note["id"]), class: "ml-2 text-sm text-blue-600 hover:underline" %>
</li>
<% end %>
<% end %>
</ul>
<% end %>dom_id(note, :frame) produces a deterministic id like note_<uuid>_frame. Turbo intercepts the Edit link and replaces the Frame's contents with whatever the response renders inside a matching <turbo-frame>.
2. Add the route + controller action
# config/routes.rb
resources :notes, only: %i[index update destroy] do
get :edit, on: :member
end# app/controllers/notes_controller.rb (add this)
def edit
response = current_supabase_client.from("notes").select("id,content").eq("id", params[:id]).execute
@note = (response.data || []).first
redirect_to notes_path, alert: NOT_FOUND_MESSAGE if @note.nil?
end3. Render the form inside a matching Frame
<%# app/views/notes/edit.html.erb %>
<%= turbo_frame_tag dom_id(@note, :frame) do %>
<%= form_with url: note_path(@note["id"]), method: :patch, class: "flex gap-2" do |f| %>
<%= f.text_field "note[content]", value: @note["content"],
class: "flex-1 rounded-md border border-zinc-300 p-2 dark:border-zinc-700 dark:bg-zinc-900" %>
<%= f.submit "Save", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white" %>
<%= link_to "Cancel", notes_path, class: "rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium" %>
<% end %>
<% end %>4. Re-render the list item from #update
The existing NotesController#update redirects to notes_path after a successful write. For a Turbo Frame round-trip you want to render just the updated <turbo-frame> instead:
# app/controllers/notes_controller.rb (replace the update action)
def update
response = current_supabase_client
.from("notes")
.update({ content: params.expect(note: [ :content ])[:content] })
.eq("id", params[:id])
.select("id,content,created_at")
.execute
@note = Array(response.data).first
if @note.nil?
redirect_to notes_path, alert: NOT_FOUND_MESSAGE
else
render :show_frame # renders app/views/notes/show_frame.html.erb
end
end<%# app/views/notes/show_frame.html.erb %>
<%= turbo_frame_tag dom_id(@note, :frame) do %>
<li data-test="note-item" class="rounded-md border border-zinc-200 p-3 dark:border-zinc-700">
<%= @note["content"] %>
<%= link_to "Edit", edit_note_path(@note["id"]), class: "ml-2 text-sm text-blue-600 hover:underline" %>
</li>
<% end %>Now clicking Edit swaps in the form, submitting swaps in the new content, and the rest of the page stays put.
The whole exchange is still RLS-scoped — current_supabase_client.from("notes").update(...).eq("id", params[:id]) will return zero rows if the note doesn't belong to Current.user, and the redirect_to notes_path, alert: NOT_FOUND_MESSAGE branch handles the cross-user case the same way it does for the non-Frame path.
Recipe 2 — Add a Stimulus controller wired to Supabase Realtime
Let's make the /notes list live-update when another tab (or another device) inserts a row. We'll add a Stimulus controller that opens a Supabase Realtime channel on the public.notes table and uses Turbo Stream's <turbo-stream> to prepend new rows.
1. Pin the Supabase JS client in Importmap
bin/importmap pin @supabase/supabase-jsThis adds a pin to config/importmap.rb. The first run downloads the package and writes it under vendor/javascript/ — Importmap doesn't need Node, but it does cache the file locally so production isn't hitting jsdelivr at runtime.
2. Expose the anon key to the browser
Realtime auth uses the anon key to bootstrap the WebSocket connection, then the user's JWT for RLS. The anon key is public; expose it via a <meta> tag in layouts/_head.html.erb:
<%# app/views/layouts/_head.html.erb (add inside <head>) %>
<meta name="supabase-url" content="<%= ENV['SUPABASE_URL'] %>">
<meta name="supabase-anon" content="<%= ENV['SUPABASE_ANON_KEY'] %>">The access token is harder — it lives inside the encrypted cookie, not on the page. The simplest approach is to render it server-side into a data- attribute on the page that needs Realtime:
<%# app/views/notes/index.html.erb (top of the file) %>
<div
data-controller="realtime-notes"
data-realtime-notes-token-value="<%= Current.session.access_token %>"
class="flex h-full w-full flex-1 flex-col gap-4"
>Current.session.access_token is the verified access token the gem unwrapped from sb-session. It's safe to embed on a page that's already gated to the signed-in user — same threat surface as a cookie.
3. Add the Stimulus controller
// app/javascript/controllers/realtime_notes_controller.js
import { Controller } from "@hotwired/stimulus"
import { createClient } from "@supabase/supabase-js"
export default class extends Controller {
static values = { token: String }
connect() {
const url = document.querySelector('meta[name="supabase-url"]').content
const anonKey = document.querySelector('meta[name="supabase-anon"]').content
this.client = createClient(url, anonKey, {
global: { headers: { Authorization: `Bearer ${this.tokenValue}` } },
realtime: { params: { eventsPerSecond: 5 } }
})
this.client.realtime.setAuth(this.tokenValue)
this.channel = this.client
.channel("notes-feed")
.on("postgres_changes",
{ event: "INSERT", schema: "public", table: "notes" },
(payload) => this.handleInsert(payload.new))
.subscribe()
}
disconnect() {
if (this.channel) this.client.removeChannel(this.channel)
}
handleInsert(note) {
const list = document.querySelector('[data-test="notes-list"]')
if (!list) return
const li = document.createElement("li")
li.dataset.test = "note-item"
li.className = "rounded-md border border-zinc-200 p-3 dark:border-zinc-700"
li.textContent = note.content
list.prepend(li)
}
}Two things make this RLS-safe:
createClientis initialised with the user's access token in theAuthorizationheader. Realtime postgres_changes events fired by Postgres run through the same RLS policies as PostgREST — the client only sees inserts that the user is permitted to read.client.realtime.setAuth(this.tokenValue)reinforces the auth for the WebSocket transport. Without it the channel falls back to the anon role and you'd see other users' inserts (which the RLS policy onpublic.notescorrectly blocks anyway, but it's clearer to set auth up front).
The controller auto-registers because eagerLoadControllersFrom in app/javascript/controllers/index.js picks up any *_controller.js. No further wiring.
4. Enable Realtime on the table
In the Supabase dashboard → Database → Replication, toggle the notes table on. Or via SQL:
alter publication supabase_realtime add table public.notes;After that, an insert from any signed-in browser will fan out to every connected client whose JWT lets them read it.
Where to take this next
- Replace the imperative DOM manipulation with
Turbo.renderStreamMessage("<turbo-stream action='prepend' target='notes-list'>...")if you want the new row to come back from the server (so it can use the same partial as the index view). - Cap the access-token's lifetime on the page — when it expires (default 1 hour), the channel's
setAuthcall will start failing. Add a Stimulus disconnect/reconnect onturbo:before-cacheand re-read a fresh token from a server-side endpoint that returns the current session's access token. - Add a presence channel for "who's looking at this note" UX.
Recipe 3 — Add a ViewComponent and wire it into the sidebar
The kit uses ViewComponent for every UI primitive — adding a new one is the natural way to extend the chrome. Let's add a BadgeComponent for unread-count indicators and wire it into the sidebar.
1. Generate the component
bin/rails generate component Badge label countThat writes app/components/badge_component.rb and app/components/badge_component.html.erb. Edit them:
# app/components/badge_component.rb
class BadgeComponent < ApplicationComponent
def initialize(label:, count: nil)
@label = label
@count = count
end
def show_count?
@count.is_a?(Integer) && @count.positive?
end
end<%# app/components/badge_component.html.erb %>
<span class="inline-flex items-center gap-1 rounded-full bg-zinc-200 px-2 py-0.5 text-xs font-medium text-zinc-800 dark:bg-zinc-700 dark:text-zinc-200">
<%= @label %>
<% if show_count? %>
<span class="rounded-full bg-zinc-900 px-1.5 text-xs text-white dark:bg-zinc-200 dark:text-zinc-900"><%= @count %></span>
<% end %>
</span>Inheriting from ApplicationComponent (rather than ViewComponent::Base) gives the template the kit's icon helper for free if you later want to add a Lucide icon to the badge.
2. Pass an unread-count into SidebarComponent
# app/components/sidebar_component.rb (modify nav_items)
def nav_items
[
{ label: "Dashboard", href: helpers.dashboard_path, icon: "layout-grid" },
{ label: "Notes", href: helpers.notes_path, icon: "sticky-note", badge: notes_badge },
{ label: "Settings", href: "/settings/profile", icon: "settings" }
]
end
private
def notes_badge
# Replace with a real count — e.g. cache an unread count in Current.user metadata,
# or read from a per-user view. The signature is the BadgeComponent constructor.
BadgeComponent.new(label: "new", count: @user&.app_metadata&.dig("unread_notes"))
end3. Render the badge in the sidebar template
<%# app/components/sidebar_component.html.erb — wherever the nav item is rendered %>
<%= link_to item[:href], class: link_classes(active: current?(item[:href])), data: { test: "sidebar-#{item[:label].downcase}" } do %>
<%= icon item[:icon], class: "size-4" %>
<span class="flex-1"><%= item[:label] %></span>
<% if item[:badge] %>
<%= render item[:badge] %>
<% end %>
<% end %>The chrome now picks up a coloured badge for any nav item with a :badge key. Add a notes_badge_component_test.rb under test/components/ if you want unit coverage — render_inline from view_component/test_helpers is the canonical entrypoint.
When to reach for a ViewComponent vs a partial
| Need | Reach for |
|---|---|
| Reusable primitive with state, helper methods, or unit tests | ViewComponent |
| One-off chunk of markup inside a view | Partial |
| Frame-level Turbo content that changes per-action | Partial in a <turbo-frame> |
| Stimulus-attached widget with both server- and client-rendered states | ViewComponent that emits data-controller="…" |
The kit's existing components (Button, Avatar, UserMenu, Sidebar) are all in the first row — make new primitives by following the same pattern.
Architecture
How supabase-rails plugs into the Hotwire starter — the cookie session lifecycle, the Turbo + Stimulus surfaces, the per-request RLS-scoped Supabase client, and how the supabase/ directory is used.
Deployment
Production-readiness checklist for the Hotwire starter — secrets, SQLite vs Postgres, TLS, cache, health checks, observability.