supabase-rb-rb
Inertia + React starter

Customization

Inertia + React recipes for extending the starter — add a new Inertia page, add a shadcn component, and wire a Supabase Realtime channel into a React component.

The kit is small on purpose. These three recipes cover the extensions you'll reach for first, and they're shaped around the kit's stack — Inertia pages, shadcn primitives, and Supabase Realtime through @supabase/supabase-js.

Recipe 1 — Add a new Inertia page

Let's add a /projects page that lists the signed-in user's projects from a Supabase table. We'll do it end-to-end: a Rails controller, a typed route, a React page, and a sidebar link.

1. Add the Supabase table

supabase migration new create_projects

In the generated SQL file:

create table public.projects (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null default auth.uid() references auth.users(id) on delete cascade,
  name       text not null,
  created_at timestamptz not null default now()
);

alter table public.projects enable row level security;

create policy "Users can read own projects"   on public.projects for select using (auth.uid() = user_id);
create policy "Users can insert own projects" on public.projects for insert with check (auth.uid() = user_id);
create policy "Users can update own projects" on public.projects for update using (auth.uid() = user_id);
create policy "Users can delete own projects" on public.projects for delete using (auth.uid() = user_id);

Then apply locally:

supabase db reset       # rebuilds the local DB from migrations

For Cloud, link the project and supabase db push, or paste the SQL into the dashboard SQL editor.

2. Add the Rails route + controller

# config/routes.rb (add inside the existing block)
resources :projects, only: %i[index create]
# app/controllers/projects_controller.rb
class ProjectsController < InertiaController
  def index
    response = current_supabase_client.from("projects").select("id,name,created_at").order("created_at", desc: true).execute
    render inertia: "projects/index", props: { projects: response.data || [] }
  end

  def create
    response = current_supabase_client
      .from("projects")
      .insert({ name: params.expect(project: [ :name ])[:name] })
      .select("id,name,created_at")
      .execute

    if (project = Array(response.data).first)
      redirect_to projects_path, notice: "Project created."
    else
      redirect_to projects_path, alert: "Couldn't create the project."
    end
  end

  private

  def current_supabase_client
    request.env[Supabase::Rails::CONTEXT_KEY].supabase
  end
end

request.env[Supabase::Rails::CONTEXT_KEY].supabase returns a Supabase client whose Authorization header carries the signed-in user's access token. Every PostgREST call through it is RLS-scoped to Current.user — the select only returns the user's own rows, the insert defaults user_id to auth.uid().

3. Regenerate typed routes

Restart bin/dev (or run bin/rails typelizer:generate directly) so Typelizer re-reads routes.rb. After it runs, app/javascript/routes/ will contain a new ProjectsController.ts, and app/javascript/routes/index.ts will re-export projects.

4. Add the React page

// app/javascript/pages/projects/index.tsx
import { Form, Head, usePage } from "@inertiajs/react"

import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import AppLayout from "@/layouts/app-layout"
import { projects } from "@/routes"
import type { BreadcrumbItem } from "@/types"

interface Project {
  id: string
  name: string
  created_at: string
}

interface ProjectsPageProps {
  projects: Project[]
  errors: Record<string, string[] | undefined>
}

const breadcrumbs: BreadcrumbItem[] = [
  { title: "Projects", href: projects.index().url },
]

export default function ProjectsIndex() {
  const { projects: rows } = usePage<ProjectsPageProps>().props

  return (
    <AppLayout breadcrumbs={breadcrumbs}>
      <Head title="Projects" />

      <div className="flex flex-col gap-6 p-4">
        <Form action={projects.create()} resetOnSuccess={["project[name]"]} className="flex gap-2">
          {({ processing, errors }) => (
            <>
              <FieldGroup className="flex-1">
                <Field>
                  <FieldLabel htmlFor="project_name" className="sr-only">Name</FieldLabel>
                  <Input id="project_name" name="project[name]" required placeholder="New project name" />
                  <FieldError errors={errors["project.name"]?.map((message) => ({ message }))} />
                </Field>
              </FieldGroup>
              <Button type="submit" disabled={processing}>
                {processing && <Spinner />} Add
              </Button>
            </>
          )}
        </Form>

        <ul className="flex flex-col gap-2">
          {rows.map((p) => (
            <li key={p.id} className="rounded-md border border-zinc-200 p-3 dark:border-zinc-700">
              {p.name}
            </li>
          ))}
        </ul>
      </div>
    </AppLayout>
  )
}

A few things to notice:

  • usePage<ProjectsPageProps>() is the typed entrypoint to the props the controller passed. The cast happens once, at the page boundary.
  • <Form action={projects.create()}> — the typed route helper from @/routes returns { url: "/projects", method: "POST" }. Inertia's <Form> knows what to do with it; no string URLs leaking through.
  • errors["project.name"] is what params.expect(project: [ :name ]) returns on a missing field. Match the dotted form to read it.
  • resetOnSuccess={["project[name]"]} clears the input after a successful submit (Inertia merges the new server props in, so the list updates without a full reload).
// app/javascript/components/app-sidebar.tsx (modify mainNavItems)
import { Folder as ProjectsIcon, LayoutGrid } from "lucide-react"

import { dashboard, projects } from "@/routes"

const mainNavItems: NavItem[] = [
  { title: "Dashboard", href: dashboard.index().url, icon: LayoutGrid },
  { title: "Projects",  href: projects.index().url,  icon: ProjectsIcon },
]

That's it. Restart bin/dev if you haven't, refresh /dashboard, and the sidebar now shows Projects. Click it and Inertia performs a JSON-only navigation to /projects — no full page reload.

Recipe 2 — Add a shadcn component

The kit's components.json is checked in, so the shadcn CLI works out of the box. Let's add the tabs primitive (not shipped by default) and use it to split the dashboard into two views.

1. Run the CLI

npx shadcn@latest add tabs

The CLI reads components.json, pulls the tabs source from the shadcn registry, transforms the imports against the alias map (@/lib/utils, @/components/ui/*), and writes:

app/javascript/components/ui/tabs.tsx

If tabs depends on a primitive you don't have, the CLI prompts to install it too. Accept — the kit already covers the common dependencies (button, card, etc.).

2. Use it on the dashboard

// app/javascript/pages/dashboard/index.tsx (replace the body)
import { Head } from "@inertiajs/react"

import { PlaceholderPattern } from "@/components/placeholder-pattern"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import AppLayout from "@/layouts/app-layout"
import { dashboard } from "@/routes"
import type { BreadcrumbItem } from "@/types"

const breadcrumbs: BreadcrumbItem[] = [
  { title: "Dashboard", href: dashboard.index().url },
]

export default function Dashboard() {
  return (
    <AppLayout breadcrumbs={breadcrumbs}>
      <Head title="Dashboard" />

      <div className="flex h-full flex-1 flex-col gap-4 p-4">
        <Tabs defaultValue="overview" className="w-full">
          <TabsList>
            <TabsTrigger value="overview">Overview</TabsTrigger>
            <TabsTrigger value="activity">Activity</TabsTrigger>
          </TabsList>

          <TabsContent value="overview" className="grid auto-rows-min gap-4 md:grid-cols-3">
            <Card />
            <Card />
            <Card />
          </TabsContent>

          <TabsContent value="activity">
            <Card large />
          </TabsContent>
        </Tabs>
      </div>
    </AppLayout>
  )
}

function Card({ large = false }: { large?: boolean }) {
  return (
    <div
      className={
        large
          ? "border-sidebar-border/70 dark:border-sidebar-border relative min-h-[60vh] overflow-hidden rounded-xl border"
          : "border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
      }
    >
      <PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
    </div>
  )
}

The kit already owns Tabs's source — it's just a file in your repo. Editing components/ui/tabs.tsx lets you change padding, animation, or variants without forking the dependency.

3. Things to know about the shadcn workflow

  • The CLI is one-way. Once a primitive is in your tree, the CLI doesn't keep it in sync with upstream. To pull an upstream change, re-run npx shadcn@latest add tabs --overwrite and diff the result.
  • No registry config. components.json doesn't list a custom registry URL, so the CLI fetches from https://ui.shadcn.com/r/styles/new-york/<name>.json. If you want a private registry (e.g. internal design system), set "registry": "https://your-registry/r/<style>" in components.json.
  • The cn(...) helper. Every shadcn primitive uses cn(...) from @/lib/utils to merge variant classes with caller-supplied className. Don't re-implement — extend by passing your own className and trust tailwind-merge to resolve conflicts.

Recipe 3 — Wire a Supabase Realtime channel into a React component

Let's make the /projects list (from Recipe 1) update live when a new row is inserted — from another browser tab, another device, or even a server-side import. We'll use the official @supabase/supabase-js client through a small hook.

1. Install the client

npm install @supabase/supabase-js

Add it to package.json (the CLI does this automatically); commit package.json + package-lock.json.

2. Expose the publishable key + URL to React

Realtime auth bootstraps with the publishable key and then upgrades to the user's JWT for RLS. The publishable key is safe to ship to the browser; pass it through the Inertia controller and read it on the React side:

# app/controllers/inertia_controller.rb (add to inertia_share)
inertia_share auth: -> { { user: current_user_props } },
              supabase: -> {
                {
                  url:               ENV.fetch("SUPABASE_URL"),
                  publishable_key:   ENV.fetch("SUPABASE_PUBLISHABLE_KEY"),
                  access_token:      Current.session&.access_token
                }
              }

Current.session.access_token is the verified access token the gem unwrapped from the sb-session cookie. It's safe to embed on a page that's already gated to the signed-in user — same threat surface as the cookie.

Update the type definition so React knows about the new shared prop:

// app/javascript/types/index.ts (add)
export interface SupabaseConfig {
  url: string
  publishable_key: string
  access_token: string | null
}

export interface SharedProps {
  auth: Auth
  supabase: SupabaseConfig
}

3. Add a useSupabaseClient hook

// app/javascript/hooks/use-supabase-client.ts
import { usePage } from "@inertiajs/react"
import { createClient, type SupabaseClient } from "@supabase/supabase-js"
import { useMemo } from "react"

import type { SharedProps } from "@/types"

export function useSupabaseClient(): SupabaseClient {
  const { supabase } = usePage<SharedProps>().props

  return useMemo(() => {
    const client = createClient(supabase.url, supabase.publishable_key, {
      auth: { persistSession: false, autoRefreshToken: false },
      global: supabase.access_token
        ? { headers: { Authorization: `Bearer ${supabase.access_token}` } }
        : undefined,
    })

    if (supabase.access_token) {
      client.realtime.setAuth(supabase.access_token)
    }

    return client
  }, [supabase.url, supabase.publishable_key, supabase.access_token])
}

Two choices worth flagging:

  • persistSession: false and autoRefreshToken: false — the cookie is the session of record. We don't want supabase-js to also try to manage one in localStorage; that's where the two cookies (the gem's and supabase-js's) drift out of sync.
  • realtime.setAuth(access_token) — without it, the Realtime WebSocket would connect as anon, and the postgres_changes events would still respect RLS but using the anon policy (typically: nothing). Setting auth means events run through the user's policies.

4. Subscribe to inserts from the React page

// app/javascript/pages/projects/index.tsx (modify the component body)
import { router, usePage } from "@inertiajs/react"
import { useEffect } from "react"

import { useSupabaseClient } from "@/hooks/use-supabase-client"
// … existing imports

export default function ProjectsIndex() {
  const { projects: rows } = usePage<ProjectsPageProps>().props
  const supabase = useSupabaseClient()

  useEffect(() => {
    const channel = supabase
      .channel("projects-feed")
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "projects" },
        () => {
          // Re-fetch the page's props through Inertia so the server-rendered
          // list is the source of truth. Cheap because props are JSON.
          router.reload({ only: ["projects"] })
        },
      )
      .subscribe()

    return () => {
      void supabase.removeChannel(channel)
    }
  }, [supabase])

  // … render
}

router.reload({ only: ["projects"] }) is Inertia's partial-reload feature — it asks the server for just the projects prop, leaving everything else (auth, errors, flash) alone. The controller re-runs index, the RLS-scoped PostgREST call returns the fresh list, and React swaps the rendered rows.

5. Enable Realtime on the table

In the Supabase dashboard → Database → Replication, toggle the projects table on. Or via SQL (commit it as a migration so the local stack picks it up too):

alter publication supabase_realtime add table public.projects;

After that, an insert from any signed-in browser fans out to every connected client whose JWT lets them read it.

Where to take this next

  • Render the new row client-side instead of re-fetching, by reading payload.new from the postgres_changes event and prepending to a local state array. Faster, but you have to handle the dedupe between the optimistic UI and the eventual router.reload.
  • Per-page channels. The hook above creates a new client on every page mount (because the useMemo deps include the page-prop access token). If you want a single long-lived connection, lift createClient into entrypoints/inertia.tsx and pass it through React context.
  • Refresh the access token. Supabase access tokens expire after one hour. When that happens, realtime.setAuth calls start failing silently — the channel keeps running on the stale token, RLS rejects new events. Re-derive the token via a server endpoint and call client.realtime.setAuth(newToken) on focus or on a timer.
  • Presence. supabase.channel("project-room", { config: { presence: { key: user.id } } }) is the natural extension for "who's looking at this row" UX.

On this page