Claude-code-plugins-plus-skills supabase-architecture-variants
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/supabase-pack/skills/supabase-architecture-variants" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-skills-supabase-architecture-variants && rm -rf "$T"
manifest:
plugins/saas-packs/supabase-pack/skills/supabase-architecture-variants/SKILL.mdsource content
Supabase Architecture Variants
Overview
Different application architectures require fundamentally different Supabase
createClient configurations. The critical distinction is where the client runs (browser vs server) and which key it uses (anon key respects RLS; service_role bypasses it). This skill provides production-ready patterns for five architectures: Next.js SSR (server components with service_role, client components with anon), SPA (React/Vue with browser-only client), Mobile (React Native with deep link auth), Serverless (Edge Functions with per-request clients), and Multi-tenant (RLS-based or schema-per-tenant isolation).
Prerequisites
v2+ installed@supabase/supabase-js
package for Next.js SSR (v0.5+)@supabase/ssr- Supabase project with URL, anon key, and service role key
- TypeScript project with generated database types (
)supabase gen types typescript - For mobile: React Native with Expo or bare workflow
Step 1 — Next.js SSR (App Router with Server and Client Components)
Next.js App Router requires two separate clients: a server-side client using cookies for auth (with
@supabase/ssr) and a browser client for client components. Never expose service_role to the client.
Server-Side Client (for Server Components, Route Handlers, Server Actions)
// lib/supabase/server.ts import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' import type { Database } from '../database.types' export async function createSupabaseServer() { const cookieStore = await cookies() return createServerClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ) } catch { // Called from Server Component — cookies are read-only } }, }, } ) } // Admin client for server-only operations (bypasses RLS) // NEVER import this in client components or expose to the browser import { createClient } from '@supabase/supabase-js' export function createSupabaseAdmin() { return createClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, // NOT NEXT_PUBLIC_ — server only { auth: { autoRefreshToken: false, persistSession: false }, } ) }
Client-Side Client (for Client Components)
// lib/supabase/client.ts 'use client' import { createBrowserClient } from '@supabase/ssr' import type { Database } from '../database.types' let client: ReturnType<typeof createBrowserClient<Database>> | null = null export function createSupabaseBrowser() { if (client) return client client = createBrowserClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // anon key only — respects RLS ) return client }
Middleware for Auth Session Refresh
// middleware.ts import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' export async function middleware(request: NextRequest) { let response = NextResponse.next({ request }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return request.cookies.getAll() }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value) ) response = NextResponse.next({ request }) cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options) ) }, }, } ) // Refresh session — this is the critical call await supabase.auth.getUser() return response } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'], }
Server Component Usage
// app/dashboard/page.tsx import { createSupabaseServer } from '@/lib/supabase/server' import { redirect } from 'next/navigation' export default async function DashboardPage() { const supabase = await createSupabaseServer() const { data: { user } } = await supabase.auth.getUser() if (!user) redirect('/login') const { data: projects, error } = await supabase .from('projects') .select('id, name, status, created_at') .eq('user_id', user.id) .order('created_at', { ascending: false }) if (error) throw new Error(`Failed to load projects: ${error.message}`) return ( <div> <h1>My Projects</h1> {projects.map(p => <ProjectCard key={p.id} project={p} />)} </div> ) }
Server Action with Admin Client
// app/actions/admin.ts 'use server' import { createSupabaseAdmin } from '@/lib/supabase/server' export async function deleteUserAccount(userId: string) { const supabase = createSupabaseAdmin() // Admin operation — bypasses RLS const { error: deleteError } = await supabase .from('user_data') .delete() .eq('user_id', userId) if (deleteError) throw new Error(`Data deletion failed: ${deleteError.message}`) // Delete auth user const { error: authError } = await supabase.auth.admin.deleteUser(userId) if (authError) throw new Error(`Auth deletion failed: ${authError.message}`) }
Step 2 — SPA (React/Vue) and Mobile (React Native)
SPA Architecture (React with Vite)
SPAs use a single browser client with the anon key. All authorization is enforced via RLS. The service_role key is never present in the SPA bundle.
// src/lib/supabase.ts import { createClient } from '@supabase/supabase-js' import type { Database } from './database.types' // Singleton client — one instance for the entire SPA export const supabase = createClient<Database>( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY, { auth: { autoRefreshToken: true, persistSession: true, detectSessionInUrl: true, // handles OAuth redirects storage: window.localStorage, }, } ) // Auth state listener — call once at app initialization supabase.auth.onAuthStateChange((event, session) => { if (event === 'SIGNED_OUT') { // Clear local caches queryClient.clear() // React Query } if (event === 'TOKEN_REFRESHED') { console.log('Token refreshed') } })
React Hook for Auth-Protected Queries
// src/hooks/useSupabaseQuery.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { supabase } from '../lib/supabase' export function useTodos() { return useQuery({ queryKey: ['todos'], queryFn: async () => { const { data, error } = await supabase .from('todos') .select('id, title, is_complete, created_at') .order('created_at', { ascending: false }) if (error) throw new Error(`Failed to load todos: ${error.message}`) return data }, }) } export function useCreateTodo() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (title: string) => { const { data, error } = await supabase .from('todos') .insert({ title }) .select('id, title, is_complete, created_at') .single() if (error) throw new Error(`Failed to create todo: ${error.message}`) return data }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }) }, }) }
Mobile Architecture (React Native with Expo)
React Native needs
AsyncStorage for session persistence and deep link handling for OAuth.
// lib/supabase.ts (React Native) import { createClient } from '@supabase/supabase-js' import AsyncStorage from '@react-native-async-storage/async-storage' import type { Database } from './database.types' export const supabase = createClient<Database>( process.env.EXPO_PUBLIC_SUPABASE_URL!, process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, // disabled for React Native }, } )
Mobile OAuth with Deep Links
// lib/auth.ts (React Native) import { supabase } from './supabase' import * as Linking from 'expo-linking' import * as WebBrowser from 'expo-web-browser' const redirectUrl = Linking.createURL('auth/callback') export async function signInWithGoogle() { const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: redirectUrl, skipBrowserRedirect: true, // handle manually for RN }, }) if (error) throw new Error(`OAuth failed: ${error.message}`) if (!data.url) throw new Error('No OAuth URL returned') // Open in-app browser const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUrl) if (result.type === 'success') { const url = new URL(result.url) const params = new URLSearchParams(url.hash.substring(1)) const accessToken = params.get('access_token') const refreshToken = params.get('refresh_token') if (accessToken && refreshToken) { const { error: sessionError } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken, }) if (sessionError) throw sessionError } } }
App.json Deep Link Configuration (Expo)
{ "expo": { "scheme": "myapp", "plugins": [ [ "expo-linking", { "scheme": "myapp" } ] ] } }
Step 3 — Serverless (Edge Functions) and Multi-Tenant
See serverless and multi-tenant patterns for Edge Function per-request clients, admin operation escalation, RLS-based multi-tenant isolation with schema, and tenant-scoped SDK queries.
Output
- Next.js SSR setup with server client (cookies-based auth), browser client, and middleware
- Server Actions using admin client with service_role for privileged operations
- SPA pattern with singleton client, React Query integration, and auth state listener
- React Native setup with AsyncStorage, deep link OAuth, and in-app browser
- Edge Function patterns for per-request auth and admin escalation
- Multi-tenant RLS isolation with tenant_members lookup and scoped queries
- Decision matrix for choosing the right architecture per stack
Error Handling
| Issue | Cause | Solution |
|---|---|---|
in Server Component | Cookies not passed to Supabase client | Use from with cookie handlers |
| OAuth redirect fails in React Native | Missing deep link scheme | Add to app.json and configure Supabase redirect URL |
| service_role key in client bundle | Wrong env var prefix () | Remove prefix; only server code should access it |
| Multi-tenant data leak | Missing RLS policy or missing tenant_id filter | Verify RLS is enabled and policies check |
Edge Function returns null | Missing Authorization header | Forward user's JWT from the client call |
| Session not persisting on mobile | AsyncStorage not configured | Pass in auth config; ensure package is installed |
Examples
Test Auth Flow End-to-End (Next.js)
// app/auth/callback/route.ts import { createSupabaseServer } from '@/lib/supabase/server' import { NextResponse } from 'next/server' export async function GET(request: Request) { const { searchParams } = new URL(request.url) const code = searchParams.get('code') if (code) { const supabase = await createSupabaseServer() const { error } = await supabase.auth.exchangeCodeForSession(code) if (error) { return NextResponse.redirect(new URL('/login?error=auth_failed', request.url)) } } return NextResponse.redirect(new URL('/dashboard', request.url)) }
Verify Tenant Isolation
-- Test that RLS properly isolates tenants SET request.jwt.claims = '{"sub": "user-uuid-1"}'; -- Should only return projects for user-uuid-1's tenant SELECT * FROM public.projects;
Resources
- Supabase SSR (Next.js)
- Supabase React Native
- Supabase Edge Functions
- Multi-Tenant with RLS
- Supabase Auth Deep Linking
- @supabase/ssr Package
Next Steps
For common mistakes and anti-patterns to avoid, see
supabase-known-pitfalls.