Supabase Integration Guide¶
Complete reference for integrating Supabase with Next.js applications in the homelab infrastructure.
Prerequisites: - Next.js 16+ application created - Supabase CLI installed - Understanding of App Router patterns
See Also: - App Conventions - Overall application structure - Development Environment - Local vs K8s Supabase - Database Migrations - Schema management
Client Setup Pattern¶
Always use three separate clients:
-
Browser Client (
lib/supabase/client.ts): -
Server Client (
lib/supabase/server.ts):import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' export async function createClient() { const cookieStore = await cookies() return createServerClient( 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 { // Ignore - setAll called from Server Component } } } } ) } -
Middleware Helper (
lib/supabase/middleware.ts):import { createServerClient, type CookieOptions } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' export async function updateSession(request: NextRequest) { let response = NextResponse.next({ request: { headers: request.headers } }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return request.cookies.get(name)?.value }, set(name: string, value: string, options: CookieOptions) { request.cookies.set({ name, value, ...options }) response = NextResponse.next({ request: { headers: request.headers } }) response.cookies.set({ name, value, ...options }) }, remove(name: string, options: CookieOptions) { request.cookies.set({ name, value: '', ...options }) response = NextResponse.next({ request: { headers: request.headers } }) response.cookies.set({ name, value: '', ...options }) } } } ) await supabase.auth.getUser() return response }
Middleware Pattern¶
Always include auth middleware (middleware.ts):
import { updateSession } from '@/lib/supabase/middleware'
import { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'
]
}
Environment Configuration¶
Required environment variables:
.env.local (gitignored):
# Local Supabase (Development)
NEXT_PUBLIC_SUPABASE_URL=http://10.89.97.XXX:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-actual-anon-key
# OR K8s Supabase (Production)
# NEXT_PUBLIC_SUPABASE_URL=http://10.89.97.214:8000
# NEXT_PUBLIC_SUPABASE_ANON_KEY=your-k8s-anon-key
.env.local.example (committed):
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
Authentication Patterns¶
⚠️ CRITICAL: All new apps MUST include authentication. This is not optional. Skipping auth setup leads to "permission denied" errors and hours of debugging.
Complete implementation requires 5 components: 1. Middleware route protection 2. Login page and auth callback 3. Database role grants (PostgREST access) 4. RLS policies with proper clauses 5. Protected API routes
1. Middleware Route Protection¶
IMPORTANT: The skeleton provides session refresh only. You MUST add route protection.
Pattern (based on home-portal, money-tracker):
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: 'your_app_schema' }, // e.g., 'money_tracker'
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Refresh session if expired
const {
data: { user },
} = await supabase.auth.getUser()
// Protect routes that require authentication (exclude /login and /auth)
if (!user && !request.nextUrl.pathname.startsWith('/login') && !request.nextUrl.pathname.startsWith('/auth')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// Redirect authenticated users away from login page
if (user && request.nextUrl.pathname.startsWith('/login')) {
const url = request.nextUrl.clone()
url.pathname = '/' // Or your main route
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
2. Login Page and Auth Callback¶
All new apps should include authentication by default. This ensures proper RLS policies and user data isolation.
1. Login Page (app/login/page.tsx):
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const supabase = createClient()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const { error: signInError } = await supabase.auth.signInWithPassword({
email,
password,
})
if (signInError) {
setError(signInError.message)
setLoading(false)
return
}
router.push('/')
router.refresh()
} catch (err) {
setError('An unexpected error occurred')
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-md bg-white rounded-lg shadow-sm p-6">
<h1 className="text-2xl font-bold text-center mb-6">Sign In</h1>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
<button
type="submit"
className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
2. Auth Callback Route (app/auth/callback/route.ts):
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
const forwardedHost = request.headers.get('x-forwarded-host')
const isLocalEnv = process.env.NODE_ENV === 'development'
if (isLocalEnv) {
return NextResponse.redirect(`${origin}${next}`)
} else if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`)
} else {
return NextResponse.redirect(`${origin}${next}`)
}
}
}
return NextResponse.redirect(`${origin}/login?error=auth_error`)
}
3. Protected API Routes Pattern:
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
const supabase = await createClient()
// Verify authentication
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return new Response("Unauthorized", { status: 401 })
}
// User is authenticated, proceed with request
// ...
}
3. Database Role Grants (PostgREST Access)¶
⚠️ CRITICAL: PostgREST uses authenticated, anon, and service_role roles, not postgres. Without these grants, you get "permission denied for table" errors.
Run these commands for EVERY table in your schema:
-- Set search path to your app's schema
SET search_path TO your_app_schema;
-- Grant table permissions to authenticated users (full access)
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA your_app_schema TO authenticated;
-- Grant table permissions to anon users (read-only)
GRANT SELECT ON ALL TABLES IN SCHEMA your_app_schema TO anon;
-- Grant table permissions to service_role (admin access)
GRANT ALL ON ALL TABLES IN SCHEMA your_app_schema TO service_role;
-- Set default privileges for future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA your_app_schema
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO authenticated;
ALTER DEFAULT PRIVILEGES IN SCHEMA your_app_schema
GRANT SELECT ON TABLES TO anon;
ALTER DEFAULT PRIVILEGES IN SCHEMA your_app_schema
GRANT ALL ON TABLES TO service_role;
Verify grants were applied:
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'your_app_schema' AND table_name = 'your_table'
ORDER BY grantee, privilege_type;
Expected output:
- anon should have SELECT
- authenticated should have SELECT, INSERT, UPDATE, DELETE
- service_role should have all privileges
4. RLS Policies with Proper Clauses¶
Enable RLS:
SELECT policy:
INSERT policy (WITH CHECK is REQUIRED):
UPDATE policy:
DELETE policy:
Or use a single ALL policy (simpler):
Common mistakes:
- ❌ Missing WITH CHECK clause on INSERT policies → "permission denied" on inserts
- ❌ Using USING without WITH CHECK for INSERT → policy doesn't apply
- ❌ No role grants → "permission denied for table" even with correct policies
For MVP/Development (not recommended for production):
If auth is temporarily not implemented:
-- Temporary dev policy (TODO: Replace with proper auth)
CREATE POLICY my_table_dev_policy ON my_table
FOR ALL
USING (true)
WITH CHECK (true);
5. Protected API Routes¶
See "Protected API Routes Pattern" in section 2.3 above.
Auth Implementation Checklist¶
- [ ] Middleware has route protection (redirects to /login if not authenticated)
- [ ] Login page created (
app/login/page.tsx) - [ ] Auth callback created (
app/auth/callback/route.ts) - [ ] Database grants applied to authenticated/anon/service_role
- [ ] RLS policies have proper USING and WITH CHECK clauses
- [ ] API routes verify authentication
- [ ] Tested: login works, routes protected, CRUD succeeds
Reference implementations: money-tracker, home-portal
Multi-Schema Configuration¶
When using the shared K8s Supabase instance, each app uses its own schema for data isolation.
Configure schema in all Supabase client instances:
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: 'your_app_schema' } // e.g., 'money_tracker'
}
)
}
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: 'your_app_schema' }, // e.g., 'money_tracker'
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Ignore - setAll called from Server Component
}
}
}
}
)
}
See also: Supabase Multi-App Architecture
External OIDC Provider Integration (Authentik)¶
When using an external identity provider like Authentik instead of Supabase's built-in auth, you need a different approach since Supabase's signInWithIdToken() doesn't support custom OIDC providers.
Use case: Apps using NextAuth + Authentik for SSO, but still need Supabase RLS for database access control.
Problem: Supabase's signInWithIdToken() only supports specific providers (Google, Apple, Keycloak), not arbitrary OIDC like Authentik.
Solution: Create Supabase-compatible JWTs directly, signed with the same secret PostgREST uses.
Architecture¶
User → NextAuth/Authentik → NextAuth Session → JWT Bridge → Supabase RLS
↓
Signs JWT with SUPABASE_JWT_SECRET
↓
PostgREST validates & trusts JWT
↓
RLS policies use auth.jwt() claims
Implementation: Auth Bridge¶
Create lib/supabase/auth-bridge.ts:
import { auth } from '@/lib/auth'
import { createClient } from '@supabase/supabase-js'
import jwt from 'jsonwebtoken'
// Cache for generated JWTs
const tokenCache = new Map<string, { token: string; expiresAt: number }>()
/**
* Create a Supabase-compatible JWT from NextAuth session.
*/
function createSupabaseJWT(userId: string, email: string | null, groups: string[]): string {
const jwtSecret = process.env.SUPABASE_JWT_SECRET
if (!jwtSecret) {
throw new Error('SUPABASE_JWT_SECRET is not configured')
}
const now = Math.floor(Date.now() / 1000)
const expiresIn = 60 * 60 // 1 hour
const payload = {
iss: 'your-app-name', // Issuer - identifies your app
sub: userId, // Subject - the user ID from Authentik
aud: 'authenticated', // Audience - required by Supabase
iat: now,
exp: now + expiresIn,
role: 'authenticated', // Required for RLS
email: email,
groups: groups, // Authentik groups for RBAC
}
return jwt.sign(payload, jwtSecret, { algorithm: 'HS256' })
}
/**
* Get or create a Supabase JWT for the current user.
*/
async function getSupabaseJWT(): Promise<string | null> {
const nextAuthSession = await auth()
if (!nextAuthSession?.user?.id) return null
const userId = nextAuthSession.user.id
const email = nextAuthSession.user.email || null
const groups = nextAuthSession.user.groups || []
// Check cache (use if valid for at least 5 more minutes)
const cached = tokenCache.get(userId)
if (cached && cached.expiresAt > Date.now() + 5 * 60 * 1000) {
return cached.token
}
try {
const token = createSupabaseJWT(userId, email, groups)
tokenCache.set(userId, {
token,
expiresAt: Date.now() + 55 * 60 * 1000,
})
return token
} catch (err) {
console.error('[auth-bridge] Failed to create JWT:', err)
return null
}
}
/**
* Create a Supabase client authenticated with the user's JWT.
* This client respects RLS policies.
*/
export async function createAuthenticatedSupabaseClient() {
const token = await getSupabaseJWT()
if (!token) return null
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: 'your_app_schema' },
global: { headers: { Authorization: `Bearer ${token}` } },
}
)
}
/**
* Get auth context with both NextAuth user info and Supabase client.
*/
export async function getAuthContextWithRLS() {
const nextAuthSession = await auth()
if (!nextAuthSession?.user?.id) {
return { user: null, supabase: null }
}
const supabase = await createAuthenticatedSupabaseClient()
return {
user: nextAuthSession.user,
supabase,
rlsEnabled: !!supabase,
}
}
Environment Variables¶
Add to .env.local:
# JWT Secret for signing Supabase-compatible tokens
# Must match the secret PostgREST uses (from K8s supabase-secrets)
SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
Get the secret from K8s:
RLS Policies with Groups¶
When using Authentik groups for RBAC:
-- Helper function to extract groups from JWT
CREATE OR REPLACE FUNCTION your_schema.user_groups()
RETURNS TEXT[] AS $$
SELECT COALESCE(
(SELECT array_agg(value::TEXT)
FROM jsonb_array_elements_text(auth.jwt()->'groups')),
ARRAY[]::TEXT[]
);
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
-- Admin check function
CREATE OR REPLACE FUNCTION your_schema.is_admin()
RETURNS BOOLEAN AS $$
SELECT 'admin-group-name' = ANY(your_schema.user_groups());
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
-- RLS policy using groups
CREATE POLICY admin_full_access ON your_schema.your_table
FOR ALL
USING (your_schema.is_admin());
-- User-specific access
CREATE POLICY user_own_data ON your_schema.your_table
FOR ALL
USING (auth.jwt()->>'sub' = user_id);
API Route Pattern¶
import { getAuthContextWithRLS } from '@/lib/supabase/auth-bridge'
export async function GET(request: Request) {
const { user, supabase } = await getAuthContextWithRLS()
if (!user || !supabase) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// RLS automatically filters data based on JWT claims
const { data, error } = await supabase
.from('your_table')
.select('*')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(data)
}
Key Differences from Standard Supabase Auth¶
| Aspect | Standard Supabase Auth | NextAuth + Authentik Bridge |
|---|---|---|
| Auth Provider | GoTrue (built-in) | External OIDC (Authentik) |
| Session Storage | Supabase cookies | NextAuth session |
| JWT Source | GoTrue issues JWT | App signs JWT directly |
| User ID | auth.uid() |
auth.jwt()->>'sub' |
| Groups/Roles | GoTrue claims | Authentik groups in JWT |
Important Notes¶
- JWT Secret must match - Use the same secret PostgREST uses, otherwise RLS won't work
- No GoTrue token exchange - Skip
signInWithIdToken(), create JWTs directly - Cache tokens - Avoid re-signing for every request
- Use
auth.jwt()in RLS - Notauth.uid()since we're not using GoTrue
Common Issues¶
"permission denied for table"¶
Cause: Database role grants not applied
Solution: Run the role grant commands for authenticated/anon/service_role (see section 3)
"permission denied" on INSERT despite policy¶
Cause: Missing WITH CHECK clause on INSERT policy
Solution: Add WITH CHECK (auth.uid() = user_id) to your INSERT policy
Session not persisting after login¶
Cause: Middleware not configured correctly
Solution: Verify middleware.ts uses the updateSession pattern (see section 1)
Redirected to login even when authenticated¶
Cause: Middleware redirect logic missing user check
Solution: Add if (!user &&!request.nextUrl.pathname.startsWith('/login')) check (see section 2.1)
See Also¶
- Supabase Multi-App Architecture - Schema isolation patterns
- Database Migrations - Managing schema changes
- Development Environment - Local vs K8s Supabase setup
- App Conventions - Overall application standards