Overview
The platform uses Supabase as its backend-as-a-service, providing:
PostgreSQL Database - Event registrations, seva submissions, and admin data
Google OAuth Authentication - Domain-restricted admin access
Row Level Security (RLS) - Database-level access control
Real-time subscriptions - Live data updates (optional)
Environment Setup
Required Variables
Add these to your .env.local file:
NEXT_PUBLIC_SUPABASE_URL = https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY = eyJhbGc...
# Alternative naming (both are supported):
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = sb_publishable_xxx
The client utilities support both NEXT_PUBLIC_SUPABASE_ANON_KEY and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY for backward compatibility.
Client Creation
Browser Client (Client Components)
For use in React Client Components:
utils/supabase/client.ts
Usage Example
import { createBrowserClient } from "@supabase/ssr"
const hasSupabaseUrl = Boolean ( process . env . NEXT_PUBLIC_SUPABASE_URL )
const hasSupabaseKey = Boolean (
process . env . NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ??
process . env . NEXT_PUBLIC_SUPABASE_ANON_KEY
)
export const hasSupabaseClientEnv = hasSupabaseUrl && hasSupabaseKey
const supabaseUrl =
process . env . NEXT_PUBLIC_SUPABASE_URL ?? "https://placeholder.supabase.co"
const supabaseKey =
process . env . NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ??
process . env . NEXT_PUBLIC_SUPABASE_ANON_KEY ??
"placeholder-key-for-build"
/** Browser Supabase client; uses cookies for auth (SSR-compatible). */
export const supabase = createBrowserClient ( supabaseUrl , supabaseKey )
Server Client (Server Components & API Routes)
For Server Components, Server Actions, and Route Handlers:
utils/supabase/server.ts
app/api/admin/stats/route.ts
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
/**
* Creates a Supabase client for server-side use.
* Reads session from cookies. Must be called within a request scope.
*/
export async function createClient () {
const cookieStore = await cookies ()
const supabaseUrl = process . env . NEXT_PUBLIC_SUPABASE_URL
const supabaseKey =
process . env . NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ??
process . env . NEXT_PUBLIC_SUPABASE_ANON_KEY
if ( ! supabaseUrl || ! supabaseKey ) {
const missing = [
! supabaseUrl && "NEXT_PUBLIC_SUPABASE_URL" ,
! supabaseKey && "NEXT_PUBLIC_SUPABASE_ANON_KEY or NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY" ,
]
. filter ( Boolean )
. join ( ", " )
throw new Error ( `Missing Supabase env: ${ missing } ` )
}
return createServerClient ( supabaseUrl , supabaseKey , {
cookies: {
getAll () {
return cookieStore . getAll ()
},
setAll ( cookiesToSet ) {
try {
cookiesToSet . forEach (({ name , value , options }) =>
cookieStore . set ( name , value , options )
)
} catch {
// setAll called from Server Component; middleware handles refresh
}
},
},
})
}
Middleware (Session Refresh)
Middleware refreshes auth sessions on every request:
utils/supabase/middleware.ts
middleware.ts
import { createServerClient } from "@supabase/ssr"
import { NextResponse , type NextRequest } from "next/server"
/**
* Refreshes Supabase auth session and passes updated cookies to the response.
* Must run before any server code that reads the session.
*/
export async function updateSession ( request : NextRequest ) {
let supabaseResponse = NextResponse . next ({ request })
const supabaseUrl = process . env . NEXT_PUBLIC_SUPABASE_URL
const supabaseKey =
process . env . NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ??
process . env . NEXT_PUBLIC_SUPABASE_ANON_KEY
if ( ! supabaseUrl || ! supabaseKey ) {
return supabaseResponse
}
const supabase = createServerClient ( supabaseUrl , supabaseKey , {
cookies: {
getAll () {
return request . cookies . getAll ()
},
setAll ( cookiesToSet ) {
cookiesToSet . forEach (({ name , value }) =>
request . cookies . set ( name , value )
)
supabaseResponse = NextResponse . next ({ request })
cookiesToSet . forEach (({ name , value , options }) =>
supabaseResponse . cookies . set ( name , value , options )
)
},
},
})
await supabase . auth . getClaims ()
// Disable caching for admin routes
if (
request . nextUrl . pathname . startsWith ( "/admin" ) ||
request . nextUrl . pathname === "/api/registrations/export"
) {
supabaseResponse . headers . set ( "Cache-Control" , "no-store, max-age=0" )
}
return supabaseResponse
}
The middleware must run on every request to keep sessions fresh. Excluded paths (static files, images) are defined in the matcher config.
Row Level Security (RLS)
Overview
RLS enforces data access at the database level, ensuring users only see authorized data. The registrations table uses the following policies:
Anonymous INSERT
Public registration form submissions are allowed via the Allow anonymous inserts policy.
Authenticated SELECT (Admin Domain Only)
Only authenticated users with @nj.sgadi.us emails can read registration data via the admin_domain_select policy.
Anonymous UPDATE
Public users can update their own registrations (for edit workflows).
RLS Policy Migration
docs/migrations/rls_tighten_registrations_admin_domain.sql
-- Drop the permissive anon SELECT policy
DROP POLICY IF EXISTS "anon can read" ON public . registrations ;
-- Add SELECT policy for authenticated users with @nj.sgadi.us email domain
-- Uses (select auth.email()) to avoid per-row function calls (RLS performance best practice)
CREATE POLICY "admin_domain_select"
ON public . registrations
FOR SELECT
TO authenticated
USING (
( select auth . email ()):: text ilike '%@nj.sgadi.us'
);
-- Keep: "Allow anonymous inserts" (anon INSERT)
-- Keep: "anon can update" (public UPDATE)
-- Keep: "daily-cron-job-read" (keep-alive-cron-role SELECT)
The (select auth.email()) pattern is used instead of auth.email() to avoid per-row function calls, which improves RLS performance by 5-10x.
Testing RLS Policies
Verify policies work correctly using SQL role switching:
-- Test 1: Anonymous users cannot read
SET ROLE anon;
SELECT COUNT ( * ) AS anon_row_count FROM public . registrations ;
-- Expected: 0
RESET ROLE ;
-- Test 2: Authenticated @nj.sgadi.us can read
SET ROLE authenticated;
SET request . jwt .claims = '{"email": "[email protected] ", "sub": "test-uuid"}' ;
SELECT id, first_name, email FROM public . registrations LIMIT 1 ;
-- Expected: Returns data
RESET request . jwt .claims;
RESET ROLE ;
-- Test 3: Authenticated wrong domain cannot read
SET ROLE authenticated;
SET request . jwt .claims = '{"email": "[email protected] ", "sub": "test-uuid"}' ;
SELECT COUNT ( * ) AS denied_row_count FROM public . registrations ;
-- Expected: 0
RESET request . jwt .claims;
RESET ROLE ;
Database Operations
Insert Data
const { data , error } = await supabase
. from ( 'registrations' )
. insert ([{
first_name: 'Jane' ,
last_name: 'Smith' ,
email: '[email protected] ' ,
mobile_number: '+11234567890' ,
age: 28 ,
ghaam: 'Maninagar' ,
mandal: 'Yuva'
}])
Query with Filters
const { data , error } = await supabase
. from ( 'registrations' )
. select ( '*' )
. eq ( 'ghaam' , 'Maninagar' )
. gte ( 'arrival_date' , '2026-07-29' )
. lte ( 'departure_date' , '2026-08-02' )
. order ( 'created_at' , { ascending: false })
. limit ( 50 )
Update Existing Record
// Check if record exists first
const { data : existingRecord } = await supabase
. from ( 'registrations' )
. select ( 'id' )
. eq ( 'email' , '[email protected] ' )
. maybeSingle ()
if ( existingRecord ) {
// Update existing
const { error } = await supabase
. from ( 'registrations' )
. update ({ mobile_number: '+19876543210' })
. eq ( 'id' , existingRecord . id )
} else {
// Insert new
const { error } = await supabase
. from ( 'registrations' )
. insert ([{ email: '[email protected] ' , mobile_number: '+19876543210' }])
}
Call RPC Functions
const { data , error } = await supabase . rpc ( 'get_registrations_stats' , {
p_start_date: '2026-07-29' ,
p_end_date: '2026-08-02'
})
Resources
Supabase Documentation Official Supabase guides for authentication, database, and RLS
Next.js SSR Guide Server-side authentication with Next.js App Router
RLS Performance Optimize Row Level Security policies for production