supabase-rb-rb
Hotwire starter

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

3. 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-scopedcurrent_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-js

This 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:

  • createClient is initialised with the user's access token in the Authorization header. 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 on public.notes correctly 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 setAuth call will start failing. Add a Stimulus disconnect/reconnect on turbo:before-cache and 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 count

That 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"))
end

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

NeedReach for
Reusable primitive with state, helper methods, or unit testsViewComponent
One-off chunk of markup inside a viewPartial
Frame-level Turbo content that changes per-actionPartial in a <turbo-frame>
Stimulus-attached widget with both server- and client-rendered statesViewComponent 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.

On this page