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).
@supabase/supabase-js v2+ installed@supabase/ssr package for Next.js SSR (v0.5+)supabase gen types typescript)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.
// 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 },
}
)
}
// 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.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)$).*)'],
}
// 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>
)
}
// 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}`)
}
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')
}
})
// 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'] })
},
})
}
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
},
}
)
// 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
}
}
}
{
"expo": {
"scheme": "myapp",
"plugins": [
[
"expo-linking",
{
"scheme": "myapp"
}
]
]
}
}
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.
| Issue | Cause | Solution |
|---|---|---|
AuthSessionMissingError in Server Component |
Cookies not passed to Supabase client | Use createServerClient from @supabase/ssr with cookie handlers |
| OAuth redirect fails in React Native | Missing deep link scheme | Add scheme to app.json and configure Supabase redirect URL |
| service_role key in client bundle | Wrong env var prefix (NEXT_PUBLIC_) |
Remove NEXT_PUBLIC_ 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 tenant_members |
Edge Function auth.getUser() returns null |
Missing Authorization header | Forward user's JWT from the client call |
| Session not persisting on mobile | AsyncStorage not configured | Pass AsyncStorage in auth config; ensure package is installed |
// 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))
}
-- 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;
For common mistakes and anti-patterns to avoid, see supabase-known-pitfalls.