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.
These examples use the public anon key on the client (safe for browser use). Anything privileged (admin actions, service role key) must stay on the server.
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>
)
}
3) Email verification, send link & 6 digit code (Supabase Auth)
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>
)
}
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!