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_projectsIn 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 migrationsFor 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
endrequest.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@/routesreturns{ url: "/projects", method: "POST" }. Inertia's<Form>knows what to do with it; no string URLs leaking through.errors["project.name"]is whatparams.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).
5. Wire the sidebar link
// 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 tabsThe 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.tsxIf 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 --overwriteand diff the result. - No registry config.
components.jsondoesn't list a custom registry URL, so the CLI fetches fromhttps://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>"incomponents.json. - The
cn(...)helper. Every shadcn primitive usescn(...)from@/lib/utilsto merge variant classes with caller-suppliedclassName. Don't re-implement — extend by passing your ownclassNameand trusttailwind-mergeto 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-jsAdd 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: falseandautoRefreshToken: false— the cookie is the session of record. We don't want supabase-js to also try to manage one inlocalStorage; 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 asanon, 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.newfrom 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 eventualrouter.reload. - Per-page channels. The hook above creates a new client on every page mount (because the
useMemodeps include the page-prop access token). If you want a single long-lived connection, liftcreateClientintoentrypoints/inertia.tsxand pass it through React context. - Refresh the access token. Supabase access tokens expire after one hour. When that happens,
realtime.setAuthcalls start failing silently — the channel keeps running on the stale token, RLS rejects new events. Re-derive the token via a server endpoint and callclient.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.
Architecture
The Inertia request lifecycle, the Vite + React frontend layer, shadcn/ui wiring, and the Supabase integration points in the Inertia + React starter.
Deployment
Production-readiness checklist for the Inertia + React starter — secrets, SSR build flag, database, TLS, cache, health checks, observability.