Supabase Frontend Usage

Quick-start examples for connecting to Supabase, authenticating users, creating a simple SQL table, and displaying data in a Next.js + Tailwind component.

Supabase Basics

Quick-start examples for connecting to Supabase, authenticating users, creating a simple SQL table, and displaying data in a Next.js + Tailwind component.


0) Environment Variables (Client Safe)

Add these to your .env.local (and as GitHub Actions variables/secrets if you deploy via your usual prod.yml flow).

NEXT_PUBLIC_SUPABASE_URL="https://api-supabase.joemore.dev"
NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR_PUBLIC_ANON_KEY"

1) Connect to Supabase in a helper/lib file (public key)

src/lib/supabase/client.ts

import { createBrowserClient } from '@supabase/ssr'

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

if (!supabaseUrl) throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL')
if (!supabaseAnonKey) throw new Error('Missing NEXT_PUBLIC_SUPABASE_ANON_KEY')

export const supabase = createBrowserClient(supabaseUrl, supabaseAnonKey)

Install the Supabase client library

yarn add @supabase/ssr

2) Register a new user (Supabase Auth)

src/lib/supabase/auth.ts

import { supabase } from '@/lib/supabase/client'
// Register function
export async function register(email: string, password: string) {
  const { data, error } = await supabase.auth.signUp({ email, password })
  if (error) throw error
  return data
}
// Signin and signout functions (will be used later on in this demo)
export async function signIn(email: string, password: string) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })
  if (error) throw error
  return data
}
export async function signOut() {
  const { error } = await supabase.auth.signOut()
  if (error) throw error
}

src/components/Supabase/Register.tsx

'use client'
import { useState } from 'react'
import { register } from '@/lib/supabase/auth'
export default function RegisterBox() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [status, setStatus] = useState<string | null>(null)
  async function onSubmit(e: React.FormEvent) {
    e.preventDefault()
    setStatus('Creating account...')
    try {
      await register(email, password)
      setStatus('✅ Account created. Check your email if confirmation is enabled.')
    } catch (err: any) {
      setStatus(`❌ ${err.message ?? 'Failed to register'}`)
    }
  }
  return (
    <form onSubmit={onSubmit} className="max-w-md space-y-3 rounded-xl border p-4">
      <h3 className="text-lg font-semibold">Register</h3>
      <input
        className="w-full rounded-lg border px-3 py-2"
        placeholder="[email protected]"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        className="w-full rounded-lg border px-3 py-2"
        placeholder="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button className="rounded-lg bg-emerald-700 hover:bg-emerald-800  px-4 py-2 text-white">
        Create account
      </button>
      {status && <p className="text-sm opacity-80">{status}</p>}
    </form>
  )
}

Please note - emails are sent via Resend, and by default the user can click a link, or is given a 6 digit code.

src/lib/supabase/resendConfirmation.ts

import { supabase } from '@/lib/supabase/client'
export async function resendConfirmation(email: string) {
  const { data, error } = await supabase.auth.resend({
    type: 'signup',
    email,
  })
  if (error) throw error
  return data
}

src/components/Supabase/VerifyEmailCodeBox.tsx

'use client'
import { resendConfirmation } from '@/lib/supabase/resendConfirmation'
import { verifyEmailOtp } from '@/lib/supabase/verifyEmailOtp'
import { useMemo, useState } from 'react'
export default function VerifyEmailCodeBox() {
  const [email, setEmail] = useState('')
  const [code, setCode] = useState('')
  const [status, setStatus] = useState<string | null>(null)
  const [busy, setBusy] = useState(false)
  const cleanCode = useMemo(() => code.replace(/\D/g, '').slice(0, 6), [code])
  async function onVerify(e: React.FormEvent) {
    e.preventDefault()
    setBusy(true)
    setStatus('Verifying code…')

    try {
      await verifyEmailOtp(email.trim(), cleanCode)
      setStatus('✅ Email verified! You can now sign in.')
    } catch (err: any) {
      setStatus(`❌ ${err?.message ?? 'Verification failed'}`)
    } finally {
      setBusy(false)
    }
  }
  return (
    <form onSubmit={onVerify} className="max-w-md space-y-3 rounded-xl border p-4">
      <h3 className="text-lg font-semibold">Verify Email (Code)</h3>
      <input
        className="w-full rounded-lg border px-3 py-2"
        placeholder="[email protected]"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        autoComplete="email"
      />
      <input
        className="w-full rounded-lg border px-3 py-2 text-center text-lg tracking-[0.25em]"
        placeholder="6-digit code"
        inputMode="numeric"
        value={cleanCode}
        onChange={(e) => setCode(e.target.value)}
      />
      <button
        disabled={busy || !email.trim() || cleanCode.length !== 6}
        className="rounded-lg bg-emerald-700 hover:bg-emerald-800 px-4 py-2 text-white disabled:opacity-40 cursor-pointer"
        type="submit"
      >
        {busy ? 'Verifying…' : 'Verify'}
      </button>
      {status && <p className="text-sm opacity-80">{status}</p>}
      <p className="text-xs opacity-60">
        Paste the 6-digit code from the email. This calls{' '}
        <code className="rounded bg-black/5 px-1">supabase.auth.verifyOtp</code>.
      </p>
      {/* add somewhere under Verify button */}
      <button
        type="button"
        className="rounded-lg border px-4 py-2 text-sm"
        onClick={async () => {
          setBusy(true)
          setStatus('Resending code…')
          try {
            await resendConfirmation(email.trim())
            setStatus('📩 Sent a new confirmation email.')
          } catch (err: any) {
            setStatus(`❌ ${err?.message ?? 'Failed to resend'}`)
          } finally {
            setBusy(false)
          }
        }}
        disabled={busy || !email.trim()}
      >
        Resend code
      </button>
    </form>
  )
}

4) Login a user (Supabase Auth)

src/components/Supabase/SignIn.tsx

'use client'
import { useState } from 'react'
import { signIn, signOut } from '@/lib/supabase/auth'
export default function SignInBox() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [status, setStatus] = useState<string | null>(null)
  async function onSignIn(e: React.FormEvent) {
    e.preventDefault()
    setStatus('Signing in...')
    try {
      await signIn(email, password)
      setStatus('✅ Signed in!')
    } catch (err: any) {
      setStatus(`❌ ${err.message ?? 'Failed to sign in'}`)
    }
  }
  async function onSignOut() {
    setStatus('Signing out...')
    try {
      await signOut()
      setStatus('👋 Signed out')
    } catch (err: any) {
      setStatus(`❌ ${err.message ?? 'Failed to sign out'}`)
    }
  }
  return (
    <div className="max-w-md space-y-3 rounded-xl border p-4">
      <h3 className="text-lg font-semibold">Sign In</h3>

      <form onSubmit={onSignIn} className="space-y-3">
        <input
          className="w-full rounded-lg border px-3 py-2"
          placeholder="[email protected]"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        <input
          className="w-full rounded-lg border px-3 py-2"
          placeholder="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        <button className="rounded-lg bg-emerald-700 hover:bg-emerald-800  px-4 py-2 text-white">
          Sign in
        </button>
      </form>
      <button
        onClick={onSignOut}
        className="rounded-lg border px-4 py-2"
        type="button"
      >
        Sign out
      </button>
      {status && <p className="text-sm opacity-80">{status}</p>}
    </div>
  )
}

5) Add a simple SQL “to-do list” table (SQL Editor)

In Supabase Dashboard → SQL Editor, run:

Run in Studio SQL Editor to create a "todos" table

CREATE TABLE IF NOT EXISTS public.todos (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  title TEXT NOT NULL,
  is_done BOOLEAN NOT NULL DEFAULT FALSE
);
-- Optional: allow reads for everyone (demo-friendly)
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;
-- Optional: allow reads for everyone (demo-friendly)
CREATE POLICY "Public read todos"
ON public.todos
FOR SELECT
TO PUBLIC
USING (TRUE);
-- Optional: allow inserts for everyone (demo-friendly)
CREATE POLICY "Public insert todos"
ON public.todos
FOR INSERT
TO PUBLIC
WITH CHECK (TRUE);

6) Component that fetches + displays to-do items

Example client function: src/lib/supabase/todos.ts

import { supabase } from '@/lib/supabase/client'
export type Todo = {
  id: string
  created_at: string
  title: string
  is_done: boolean
}
export async function fetchTodos(): Promise<Todo[]> {
  const { data, error } = await supabase
    .from('todos')
    .select('id, created_at, title, is_done')
    .order('created_at', { ascending: true })
    .limit(50)
  if (error) throw error
  return data ?? []
}

Tiny UI Example Component for displaying to-do items

'use client'

import { useEffect, useState } from 'react'
import { fetchTodos, type Todo } from '@/lib/supabase/todos'

export default function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    let isMounted = true

    async function load() {
      try {
        setLoading(true)
        const items = await fetchTodos()
        if (isMounted) setTodos(items)
      } catch (err: any) {
        if (isMounted) setError(err.message ?? 'Failed to load todos')
      } finally {
        if (isMounted) setLoading(false)
      }
    }

    load()
    return () => {
      isMounted = false
    }
  }, [])

  return (
    <div className="max-w-xl rounded-xl border p-4">
      <div className="mb-3 flex items-center justify-between">
        <h3 className="text-lg font-semibold">Todos</h3>
        {loading && <span className="text-sm opacity-70">Loading…</span>}
      </div>

      {error && (
        <div className="mb-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm">
          ❌ {error}
        </div>
      )}

      <ul className="space-y-2">
        {todos.map((t) => (
          <li
            key={t.id}
            className="flex items-center justify-between rounded-lg border px-3 py-2"
          >
            <span className={t.is_done ? 'line-through opacity-60' : ''}>
              {t.title}
            </span>
            <span className="text-xs opacity-60">
              {new Date(t.created_at).toLocaleDateString()}
            </span>
          </li>
        ))}
      </ul>

      {!loading && !error && todos.length === 0 && (
        <p className="mt-3 text-sm opacity-70">No todos yet. Add one in SQL!</p>
      )}
    </div>
  )
}


Register


Verify Email (Code)

Paste the 6-digit code from the email. This calls supabase.auth.verifyOtp.



Todos (Public Viewable Table)

Loading…

    Please note this is a public table, so anyone can see the todos. We can insert some sample rows using something like this:

    Run in Studio SQL Editor to insert a todo

    INSERT INTO public.todos (title, is_done)
    VALUES
      ('Learn Supabase 🤓', TRUE),
      ('Build demo UI 🤖', TRUE),
      ('Deploy to Joe’s server 💪🏼', FALSE),
      ('Have a lovely tasy beer and get drunk! 🍺🍺🍺🍺🍺🍺', FALSE);
    

    7) Storage Image/Video Upload Demo

    Storage Demo: JPEG Upload + Signed URL

    Please note - CloudFlare tunnel has a 100Mb limit, technically our supabase is unlimited, but the tunnel is limited. We apply a front end 100Mb limit just to warn users in case they try to upload a large file. To re-create this, here's the SQL to create the bucket and policies:

    Run in Studio SQL Editor to create a "demo-jpegs" bucket

    -- Create a private bucket for testing
    INSERT INTO storage.buckets (id, name, public)
    VALUES ('demo-jpegs', 'demo-jpegs', FALSE)
    ON CONFLICT (id) DO NOTHING;
    

    Bucket policies

    -- Make sure RLS is enabled on the objects table
    ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
    
    -- Allow authenticated users to upload into their own folder: {user_id}/...
    CREATE POLICY "Users can upload to own folder"
    ON storage.objects
    FOR INSERT
    TO authenticated
    WITH CHECK (
      bucket_id = 'demo-jpegs'
      AND (storage.foldername(name))[1] = auth.uid()::text
    );
    
    -- Allow authenticated users to read their own files: {user_id}/...
    CREATE POLICY "Users can read own files"
    ON storage.objects
    FOR SELECT
    TO authenticated
    USING (
      bucket_id = 'demo-jpegs'
      AND (storage.foldername(name))[1] = auth.uid()::text
    );
    
    

    src/lib/supabase/storageDemo.ts

    
    import { supabase } from '@/lib/supabase/client'
    
    export async function uploadJpegForUser(file: File) {
      // Require signed-in user
      const { data: userData, error: userErr } = await supabase.auth.getUser()
      if (userErr) throw userErr
      const user = userData.user
      if (!user) throw new Error('You must be signed in to upload')
    
      // Basic file validation (demo)
      const isJpeg =
        file.type === 'image/jpeg' || file.name.toLowerCase().endsWith('.jpg') || file.name.toLowerCase().endsWith('.jpeg')
      if (!isJpeg) throw new Error('Please upload a JPEG (.jpg / .jpeg)')
    
      // Put the file into a user-owned folder: {userId}/{timestamp}-{filename}
      const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_')
      const path = `${user.id}/${Date.now()}-${safeName}`
    
      const { data, error } = await supabase.storage
        .from('demo-jpegs')
        .upload(path, file, {
          contentType: 'image/jpeg',
          upsert: false,
        })
    
      if (error) throw error
      return data.path // the object path
    }
    
    export async function createSignedViewUrl(path: string, expiresInSeconds = 60) {
      const { data, error } = await supabase.storage
        .from('demo-jpegs')
        .createSignedUrl(path, expiresInSeconds)
    
      if (error) throw error
      return data.signedUrl
    }
    

    src/components/Supabase/StorageJpegUploadDemo.tsx

    <StorageJpegUploadDemo /> //Large(ish) file, so just open the code to look at it!
    

    Was this page helpful?