| name | supabase-patterns |
| description | Enforces correct Supabase usage in carwash-saas. Use this when writing any Supabase query, mutation, RPC call, auth check, migration, or RLS policy. Covers client selection, RPC-only writes, JWT-based multi-tenancy, and migration conventions. |
Supabase Patterns
Client selection
Always pick the right client for the context. Using the wrong one causes auth failures or stale sessions.
| Context | Import | Notes |
|---|
| Dashboard Server Component / Route Handler | createClient() from @/lib/supabase/server | Cookie-based, @supabase/ssr. Must be awaited. |
Dashboard 'use client' component | createClient() from @/lib/supabase/client | Browser singleton via createBrowserClient. |
Mobile (apps/mobile) | supabase singleton from @/lib/supabase | AsyncStorage persistence, URL polyfill already set. |
const supabase = await createClient();
const supabase = createClient();
import { supabase } from "@/lib/supabase";
Auth: always getUser(), never getSession()
getSession() reads from local storage / cookies and can return a stale JWT.
getUser() makes a network round-trip to validate the token with the Supabase Auth server.
const {
data: { session },
} = await supabase.auth.getSession();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) redirect("/login");
Exception: onAuthStateChange in the mobile root layout uses getSession() only to bootstrap initial local state from AsyncStorage before the first network call resolves. This is the one documented exception.
Reading data
Pattern for Server Components
const supabase = await createClient();
const { data, error } = await supabase
.from("customers")
.select("id, name, phone")
.eq("merchant_id", merchantId)
.order("name");
if (error) throw error;
Pattern for TanStack Query (client)
function useTransactions() {
const supabase = createClient();
return useQuery<TransactionRow[]>({
queryKey: ["transactions", "today"],
queryFn: async () => {
const { data, error } = await supabase
.from("wash_transactions")
.select(
"id, wash_type, amount_paid, created_at, customers(name, phone)",
)
.gte("created_at", `${today}T00:00:00.000Z`)
.is("voided_at", null)
.order("created_at", { ascending: false });
if (error) throw error;
return (data ?? []) as TransactionRow[];
},
});
}
Key rules:
Writing data: RPC only for transactional operations
Direct inserts to wash_transactions are blocked by RLS (WITH CHECK (false)).
Direct inserts to loyalty_cards will bypass the loyalty counter logic.
Always use the log_wash RPC for recording a wash:
await supabase.from('wash_transactions').insert({ ... });
const { data, error } = await supabase.rpc('log_wash', {
p_branch_id: branchId,
p_customer_id: customerId,
p_staff_id: staffId,
p_wash_type: washType,
p_amount_paid: amountPaid,
p_is_free_redemption: isRedemption,
});
if (error) throw error;
For QR token rotation (admin only):
const { data: newToken, error } = await supabase.rpc("rotate_qr_token", {
p_staff_id: staffId,
});
if (error) throw error;
RPC error messages are intentional and user-facing — surface them directly:
"Branch not found or inactive" — bad branch ID
"Unauthorized: caller does not belong to branch merchant" — wrong tenant
"Cannot redeem: X / Y washes completed" — redemption not yet eligible
"Unauthorized: only admin or owner can rotate QR tokens" — role check failed
Multi-tenancy: how RLS works
Every table has a merchant_id column. RLS policies call two SQL helper functions:
merchant_id() — reads auth.jwt() -> 'app_metadata' ->> 'merchant_id' (no DB round-trip)
user_role() — reads auth.jwt() -> 'app_metadata' ->> 'role'
JWT claims are populated by a Postgres trigger (on_profile_created) when a profile row is inserted and updated by on_profile_role_updated when the role changes.
Implication: if you add a new user and their queries immediately fail with empty results, their JWT may not yet include the claims. Ask them to sign out and back in to refresh the token.
Never add merchant_id filters in application code as the sole security control. RLS is the enforcement layer — app-level filters are optional UX aids only.
Voiding a transaction
Wash transactions are immutable. Voiding is a soft-delete via voided_at:
const { error } = await supabase
.from("wash_transactions")
.update({
voided_at: new Date().toISOString(),
voided_by: userId,
void_reason: reason,
})
.eq("id", transactionId);
if (error) throw error;
Always filter voided_at in read queries: .is('voided_at', null).
Migration conventions
When writing a new migration file:
Common anti-patterns to reject
| Anti-pattern | Correct approach |
|---|
supabase.auth.getSession() for auth guard | supabase.auth.getUser() |
Direct insert to wash_transactions | supabase.rpc('log_wash', { ... }) |
Direct insert/update to loyalty_cards | supabase.rpc('log_wash', { ... }) handles this |
Using server client in 'use client' component | Import from @/lib/supabase/client |
| Using browser client in Server Component | Import from @/lib/supabase/server |
import { supabase } from '@/lib/supabase' in dashboard | Only for mobile; dashboard uses createClient() |
Silently ignoring error from Supabase | Always if (error) throw error; |
Manually filtering by merchant_id as sole security | RLS enforces it; app filter is optional UX only |
Editing database.types.ts by hand | Run pnpm db:types after migrations |
| Writing a new migration by editing an existing file | Always create a new NNNN_description.sql |