Skip to content

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:

  1. Browser Client (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!
      )
    }
    

  2. 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
              }
            }
          }
        }
      )
    }
    

  3. 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:

ALTER TABLE my_table ENABLE ROW LEVEL SECURITY;

SELECT policy:

CREATE POLICY my_table_select_policy ON my_table
  FOR SELECT
  USING (auth.uid() = user_id);

INSERT policy (WITH CHECK is REQUIRED):

CREATE POLICY my_table_insert_policy ON my_table
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

UPDATE policy:

CREATE POLICY my_table_update_policy ON my_table
  FOR UPDATE
  USING (auth.uid() = user_id);

DELETE policy:

CREATE POLICY my_table_delete_policy ON my_table
  FOR DELETE
  USING (auth.uid() = user_id);

Or use a single ALL policy (simpler):

CREATE POLICY my_table_user_policy ON my_table
  FOR ALL
  USING (auth.uid() = user_id);

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:

kubectl get secret -n supabase supabase-secrets -o jsonpath='{.data.JWT_SECRET}' | base64 -d

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

  1. JWT Secret must match - Use the same secret PostgREST uses, otherwise RLS won't work
  2. No GoTrue token exchange - Skip signInWithIdToken(), create JWTs directly
  3. Cache tokens - Avoid re-signing for every request
  4. Use auth.jwt() in RLS - Not auth.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