Skip to main content

Overview

The Supabase client utilities provide SSR-compatible authentication and database access across different Next.js 15 execution contexts. Three separate client factories handle browser components, server components, and middleware.

Environment Variables

NEXT_PUBLIC_SUPABASE_URL
string
required
Your Supabase project URL (e.g., https://xxxxx.supabase.co)
NEXT_PUBLIC_SUPABASE_ANON_KEY
string
required
Public anonymous key for client-side access. Alternative: NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
The project uses .env.local for environment variables. Both keys must be set, or the application will use placeholder values during build (causing runtime errors).

Browser Client

File: utils/supabase/client.ts

supabase (Browser Instance)

supabase
SupabaseClient
Pre-configured browser client for Client Components and client-side code
Type:
import { createBrowserClient } from "@supabase/ssr"

const supabase: SupabaseClient
Features:
  • Uses createBrowserClient from @supabase/ssr
  • Stores session in cookies (SSR-compatible)
  • Safe to use in Client Components, event handlers, and browser APIs
  • Automatically handles auth state synchronization
Import:
import { supabase } from "@/utils/supabase/client"

hasSupabaseClientEnv

hasSupabaseClientEnv
boolean
Flag indicating whether required environment variables are present
Usage:
import { hasSupabaseClientEnv } from "@/utils/supabase/client"

if (!hasSupabaseClientEnv) {
  console.warn("Supabase not configured - using placeholder")
}

Usage Examples

Registration Form Submission

From app/registration/page.tsx:
"use client"

import { supabase } from "@/utils/supabase/client"
import { useState } from "react"

export default function RegistrationPage() {
  const [isSubmitting, setIsSubmitting] = useState(false)

  const onSubmit = async (data: FormData) => {
    setIsSubmitting(true)

    const dbData = {
      first_name: data.firstName,
      middle_name: data.middleName || null,
      last_name: data.lastName,
      age: parseInt(data.age),
      ghaam: data.ghaam,
      country: data.country,
      mandal: data.mandal,
      email: data.email,
      phone_country_code: data.phoneCountryCode,
      mobile_number: data.phone,
      arrival_date: data.dateRange.start?.toString(),
      departure_date: data.dateRange.end?.toString()
    }

    // Check if record exists
    const { data: existingRecord } = await supabase
      .from('registrations')
      .select('id')
      .eq('first_name', dbData.first_name)
      .eq('age', dbData.age)
      .eq('email', dbData.email)
      .eq('mobile_number', dbData.mobile_number)
      .maybeSingle()

    if (existingRecord) {
      // Update existing
      const { error } = await supabase
        .from('registrations')
        .update(dbData)
        .eq('id', existingRecord.id)

      if (error) throw error
    } else {
      // Insert new
      const { error } = await supabase
        .from('registrations')
        .insert([dbData])

      if (error) throw error
    }

    setIsSubmitting(false)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
    </form>
  )
}

Authentication Check

From components/organisms/navigation.tsx:
"use client"

import { supabase, hasSupabaseClientEnv } from "@/utils/supabase/client"
import { useEffect, useState } from "react"

export function Navigation() {
  const [isAuthenticated, setIsAuthenticated] = useState(false)

  useEffect(() => {
    if (!hasSupabaseClientEnv) return

    const checkAuth = async () => {
      const { data: { session } } = await supabase.auth.getSession()
      setIsAuthenticated(!!session)
    }

    checkAuth()

    // Listen for auth changes
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setIsAuthenticated(!!session)
      }
    )

    return () => subscription.unsubscribe()
  }, [])

  return (
    <nav>
      {isAuthenticated ? (
        <Link href="/admin">Admin Dashboard</Link>
      ) : (
        <Link href="/login">Sign In</Link>
      )}
    </nav>
  )
}

Real-time Subscriptions

"use client"

import { supabase } from "@/utils/supabase/client"
import { useEffect, useState } from "react"

type Registration = {
  id: string
  first_name: string
  last_name: string
  created_at: string
}

export function LiveRegistrations() {
  const [registrations, setRegistrations] = useState<Registration[]>([])

  useEffect(() => {
    // Fetch initial data
    const fetchRegistrations = async () => {
      const { data } = await supabase
        .from('registrations')
        .select('id, first_name, last_name, created_at')
        .order('created_at', { ascending: false })
        .limit(10)

      if (data) setRegistrations(data)
    }

    fetchRegistrations()

    // Subscribe to new inserts
    const channel = supabase
      .channel('registrations')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'registrations'
        },
        (payload) => {
          setRegistrations(prev => [payload.new as Registration, ...prev])
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [])

  return (
    <ul>
      {registrations.map(reg => (
        <li key={reg.id}>
          {reg.first_name} {reg.last_name}
        </li>
      ))}
    </ul>
  )
}

Server Client

File: utils/supabase/server.ts

createClient (Server Factory)

createClient
() => Promise<SupabaseClient>
Async factory function that creates a Supabase client for server-side use
Signature:
export async function createClient(): Promise<SupabaseClient>
Features:
  • Uses createServerClient from @supabase/ssr
  • Reads session from Next.js cookies
  • Must be called within a request scope (Server Components, Server Actions, Route Handlers)
  • Throws error if environment variables are missing
Import:
import { createClient } from "@/utils/supabase/server"

Usage Examples

Server Component Data Fetching

From app/admin/registrations/page.tsx:
import { createClient } from "@/utils/supabase/server"
import { redirect } from "next/navigation"

export default async function AdminRegistrationsPage() {
  const supabase = await createClient()

  // Check authentication
  const { data: { session } } = await supabase.auth.getSession()
  
  if (!session) {
    redirect('/login')
  }

  // Fetch data server-side
  const { data: registrations, error } = await supabase
    .from('registrations')
    .select('*')
    .order('created_at', { ascending: false })

  if (error) {
    console.error('Error fetching registrations:', error)
    return <div>Error loading data</div>
  }

  return (
    <div>
      <h1>Registrations ({registrations.length})</h1>
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
            <th>Phone</th>
            <th>Arrival</th>
          </tr>
        </thead>
        <tbody>
          {registrations.map(reg => (
            <tr key={reg.id}>
              <td>{reg.first_name} {reg.last_name}</td>
              <td>{reg.email}</td>
              <td>{reg.mobile_number}</td>
              <td>{reg.arrival_date}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

Server Action

"use server"

import { createClient } from "@/utils/supabase/server"
import { revalidatePath } from "next/cache"

export async function deleteRegistration(id: string) {
  const supabase = await createClient()

  // Verify authentication
  const { data: { session } } = await supabase.auth.getSession()
  if (!session) {
    throw new Error('Unauthorized')
  }

  // Delete record
  const { error } = await supabase
    .from('registrations')
    .delete()
    .eq('id', id)

  if (error) {
    throw new Error(`Failed to delete: ${error.message}`)
  }

  // Revalidate the page
  revalidatePath('/admin/registrations')
  
  return { success: true }
}

Route Handler

import { createClient } from "@/utils/supabase/server"
import { NextResponse } from "next/server"

export async function GET() {
  const supabase = await createClient()

  const { data, error } = await supabase
    .from('registrations')
    .select('count')

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ count: data[0].count })
}

export async function POST(request: Request) {
  const supabase = await createClient()
  const body = await request.json()

  const { data, error } = await supabase
    .from('registrations')
    .insert([body])
    .select()

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 400 })
  }

  return NextResponse.json({ data }, { status: 201 })
}

Middleware Client

File: utils/supabase/middleware.ts

updateSession

updateSession
(request: NextRequest) => Promise<NextResponse>
Middleware function that refreshes Supabase auth session and updates cookies
Signature:
export async function updateSession(request: NextRequest): Promise<NextResponse>
Features:
  • Refreshes auth tokens before server code executes
  • Updates response cookies with refreshed session
  • Adds cache control headers for admin routes
  • Safe to call even if environment variables are missing
Import:
import { updateSession } from "@/utils/supabase/middleware"

Implementation

In middleware.ts:
import { type NextRequest } from "next/server"
import { updateSession } from "@/utils/supabase/middleware"

export async function middleware(request: NextRequest) {
  // Update session and get response
  return await updateSession(request)
}

export const config = {
  matcher: [
    /*
     * Match all request paths except static files and images
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

How It Works

  1. Session Refresh:
    await supabase.auth.getClaims()
    
    This triggers session refresh if needed
  2. Cookie Updates:
    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)
        )
      },
    }
    
  3. Cache Control 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")
    }
    

Authentication Patterns

Sign In (Client-side)

"use client"

import { supabase } from "@/utils/supabase/client"
import { useState } from "react"
import { useRouter } from "next/navigation"

export function SignInForm() {
  const router = useRouter()
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
  const [error, setError] = useState("")

  const handleSignIn = async (e: React.FormEvent) => {
    e.preventDefault()
    setError("")

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    })

    if (error) {
      setError(error.message)
    } else {
      router.push("/admin")
      router.refresh()
    }
  }

  return (
    <form onSubmit={handleSignIn}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <p className="text-red-500">{error}</p>}
      <button type="submit">Sign In</button>
    </form>
  )
}

Sign Out (Client-side)

"use client"

import { supabase } from "@/utils/supabase/client"
import { useRouter } from "next/navigation"

export function SignOutButton() {
  const router = useRouter()

  const handleSignOut = async () => {
    await supabase.auth.signOut()
    router.push("/")
    router.refresh()
  }

  return (
    <button onClick={handleSignOut}>
      Sign Out
    </button>
  )
}

Protected Server Component

import { createClient } from "@/utils/supabase/server"
import { redirect } from "next/navigation"

export default async function ProtectedPage() {
  const supabase = await createClient()
  
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login')
  }

  return (
    <div>
      <h1>Welcome, {user.email}</h1>
      {/* Protected content */}
    </div>
  )
}

Error Handling

Client-side Query

import { supabase } from "@/utils/supabase/client"

const { data, error } = await supabase
  .from('registrations')
  .select('*')

if (error) {
  console.error('Supabase error:', error.message)
  toast.error('Failed to load data')
  return
}

// Use data
console.log(data)

Server-side Query with Try-Catch

import { createClient } from "@/utils/supabase/server"

try {
  const supabase = await createClient()
  
  const { data, error } = await supabase
    .from('registrations')
    .select('*')
  
  if (error) throw error
  
  return data
} catch (error) {
  console.error('Database error:', error)
  throw new Error('Failed to fetch registrations')
}

TypeScript Types

import { SupabaseClient } from '@supabase/supabase-js'
import { Database } from '@/types/database' // Generated from Supabase CLI

// Typed client
const supabase: SupabaseClient<Database> = createBrowserClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// Typed query
type Registration = Database['public']['Tables']['registrations']['Row']

const { data } = await supabase
  .from('registrations')
  .select('*')
  .returns<Registration[]>()

Best Practices

When to use supabase from client.ts:
  • Client Components ("use client")
  • Event handlers (onClick, onSubmit)
  • React hooks (useEffect, useState)
  • Real-time subscriptions
  • Browser-only features
Example:
"use client"
import { supabase } from "@/utils/supabase/client"

Common Pitfalls

Don’t mix client and server imports:
// ❌ Wrong - using browser client in Server Component
import { supabase } from "@/utils/supabase/client"

export default async function ServerComponent() {
  const { data } = await supabase.from('table').select() // Won't work!
}

// ✅ Correct
import { createClient } from "@/utils/supabase/server"

export default async function ServerComponent() {
  const supabase = await createClient()
  const { data } = await supabase.from('table').select()
}
Don’t forget to await createClient():
// ❌ Wrong
const supabase = createClient() // Missing await!

// ✅ Correct
const supabase = await createClient()
Always check for errors:
// ❌ Wrong - ignoring errors
const { data } = await supabase.from('table').select()

// ✅ Correct
const { data, error } = await supabase.from('table').select()
if (error) {
  console.error(error)
  // Handle error
}

Build docs developers (and LLMs) love