EmpireUI
Get Pro
← Blog9 min read#supabase#react#database

Supabase + React in 2026: Auth, Realtime, Storage From Scratch

Wire up Supabase auth, realtime subscriptions, and file storage into a React app from zero — no hand-waving, just code that actually runs.

Terminal screen showing React and database code in dark theme

Why Supabase Is Worth Your Attention in 2026

Supabase hit general availability in 2022, and by 2026 it's not a scrappy Firebase alternative anymore — it's a proper production platform that teams at mid-sized companies are betting real money on. The hosted Postgres, the auth layer, the storage buckets, the realtime engine: it all works well enough that you can ship a full-stack app without touching a custom backend at all.

That said, the docs can be a maze. You'll find three different ways to initialize the client, older snippets using @supabase/supabase-js v1 patterns that quietly break on v2, and auth examples that assume you're using Next.js server components when you're just trying to wire up plain React. This guide cuts through all that.

Honestly, the biggest value proposition is time-to-realtime. Getting a live-updating feed in a traditional stack means spinning up WebSockets, Redis pub/sub, or a dedicated service. With Supabase you're looking at maybe 15 lines of code. That matters.

We'll cover four things: project setup, email auth (including protected routes), a realtime chat-style subscription, and file uploads to Storage. By the end you'll have a working mental model of how the pieces connect — not just copy-paste snippets.

Project Setup: Client Config You Won't Regret

Install the v2 client. Nothing else, nothing fancy.

npm install @supabase/supabase-js

Create a single src/lib/supabase.ts file and export one client from it. This matters more than it sounds. If you initialize the client in multiple places you'll end up with race conditions in realtime subscriptions and duplicate auth state listeners that are a nightmare to debug.

// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    persistSession: true,
    autoRefreshToken: true,
  },
})

Worth noting: if you're on Next.js App Router, you need @supabase/ssr instead and a different setup — this guide targets a Vite/CRA React SPA. The pattern is the same conceptually, but the cookie handling is different. Quick aside: never commit .env.local — your anon key is public-safe but your service role key absolutely is not.

One more thing — put your env vars in .env.local and prefix them with VITE_ (or REACT_APP_ if you're still on CRA). Your project URL looks like https://xyzabc123.supabase.co and you grab both values from the Supabase dashboard under Settings > API.

Auth: Email Signup, Login, and Protected Routes

Supabase auth in v2 is built around supabase.auth.signUp(), signInWithPassword(), and the onAuthStateChange listener. The listener is the key piece — it fires on every session change including token refreshes, so you track auth state in one place and everything downstream reacts to it.

Here's a minimal auth context you'd drop in src/context/AuthContext.tsx:

import { createContext, useContext, useEffect, useState } from 'react'
import { Session } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'

const AuthContext = createContext<{ session: Session | null }>({
  session: null,
})

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null)

  useEffect(() => {
    supabase.auth.getSession().then(({ data }) => {
      setSession(data.session)
    })

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => setSession(session)
    )

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

  return (
    <AuthContext.Provider value={{ session }}>
      {children}
    </AuthContext.Provider>
  )
}

export const useAuth = () => useContext(AuthContext)

Then a signup form that actually tells you when things go wrong — because Supabase returns errors instead of throwing, so you have to actually check them:

async function handleSignUp(email: string, password: string) {
  const { error } = await supabase.auth.signUp({ email, password })
  if (error) {
    console.error(error.message) // 'User already registered', rate limit, etc.
    return
  }
  // Supabase sends a confirmation email by default
  alert('Check your inbox!')
}

For protected routes, keep it dead simple. A <ProtectedRoute> wrapper that reads from useAuth() and redirects to /login when session is null. In 2026 you could do this with React Router v7's data layer or just a component check — either works fine for most apps. In practice, don't overthink this: session null means redirect, session exists means render.

Querying Postgres: The supabase-js Query Builder

The Supabase JS client ships with a PostgREST-backed query builder that covers 90% of what you need without writing raw SQL. The syntax is chainable and pretty readable once you know the patterns.

// Fetch all public posts, newest first, limit 20
const { data, error } = await supabase
  .from('posts')
  .select('id, title, created_at, author:profiles(username)')
  .eq('published', true)
  .order('created_at', { ascending: false })
  .limit(20)

if (error) throw error

That author:profiles(username) syntax does a join — PostgREST expands foreign keys automatically if your schema has them wired up. You can go multiple levels deep: author:profiles(username, avatar:avatars(url)). It looks weird at first but you get used to it fast.

Worth noting: Row Level Security (RLS) is disabled by default but you should turn it on before you ship anything. A table without RLS is readable by anyone with your anon key. Enable RLS, then write policies. A basic "users can only read their own rows" policy looks like this in the Supabase SQL editor:

-- Enable RLS
alter table posts enable row level security;

-- Users can read their own posts
create policy "own posts" on posts
  for select using (auth.uid() = user_id);

One more thing — if you need full-text search, Supabase exposes .textSearch('content', 'keyword') which hits Postgres's built-in tsvector. It's not Algolia, but for most apps it's more than good enough. No extra service, no extra bill.

Realtime Subscriptions: Live Data in 15 Lines

This is where Supabase genuinely shines. You subscribe to database changes over WebSockets and your UI updates automatically. Think of it like a live query — you define a filter, and every INSERT, UPDATE, or DELETE that matches fires a callback.

Here's a chat-style message feed that stays live:

import { useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'

type Message = { id: number; content: string; created_at: string }

export function LiveFeed({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([])

  useEffect(() => {
    // Initial fetch
    supabase
      .from('messages')
      .select('*')
      .eq('room_id', roomId)
      .order('created_at')
      .then(({ data }) => data && setMessages(data))

    // Subscribe to new inserts
    const channel = supabase
      .channel(`room-${roomId}`)
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
        (payload) => setMessages(prev => [...prev, payload.new as Message])
      )
      .subscribe()

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

  return (
    <ul>
      {messages.map(m => <li key={m.id}>{m.content}</li>)}
    </ul>
  )
}

A few things to know: you need RLS policies that allow SELECT on the table for realtime to work — the subscription goes through the same auth layer. Also, removeChannel on cleanup is non-negotiable; skipping it leaks WebSocket connections and you'll see duplicate events after hot reloads.

Look, realtime via Postgres changes works great for INSERT-heavy use cases like notifications and chat. For extremely high-frequency updates — think multiplayer game state at 60fps — you'd want Broadcast mode instead, which bypasses database writes entirely. But that's an edge case most apps never hit.

Storage: File Uploads Without the Pain

Supabase Storage is S3-compatible object storage with a simple JS API on top. You create a bucket (public or private), upload to it, and get back a URL. That's basically the whole mental model.

A file input that uploads to a avatars bucket and returns the public URL:

async function uploadAvatar(file: File, userId: string) {
  const filePath = `${userId}/${Date.now()}-${file.name}`

  const { error } = await supabase.storage
    .from('avatars')
    .upload(filePath, file, {
      cacheControl: '3600',
      upsert: false,
    })

  if (error) throw error

  const { data } = supabase.storage
    .from('avatars')
    .getPublicUrl(filePath)

  return data.publicUrl // https://xyzabc123.supabase.co/storage/v1/object/public/avatars/...
}

In practice, prefix the file path with the user's ID. It makes storage policies trivially simple: (storage.foldername(name))[1] = auth.uid()::text gives each user a private directory. No extra logic needed.

For image display, that public URL drops straight into an <img> tag. If you want on-the-fly transforms — like serving a 200×200px thumbnail — Supabase Storage supports image transformation via URL params on Pro plans: ?width=200&height=200&resize=cover. You'd build UI for this nicely with something like the glassmorphism components on Empire UI — avatar cards with backdrop blur look great against profile images.

One more thing — bucket policies default to private. A private bucket returns a signed URL that expires (you call .createSignedUrl(path, 60) for a 60-second URL). Public buckets skip that step entirely. Don't overthink the choice: user-facing assets like avatars go public, sensitive documents go private.

Putting It Together: App Structure and What to Watch Out For

A typical Supabase + React app in 2026 looks like this: one supabase.ts singleton, an AuthProvider at the root, data-fetching either in useEffect hooks or via TanStack Query (which pairs beautifully with Supabase — see the patterns in TanStack Query for React), and realtime subscriptions scoped to components that need live data.

The trap most developers fall into is not handling loading and error states from Supabase responses. Every call returns { data, error } — not a thrown error, an object. If you're used to fetch + throw, you'll miss errors silently the first few times. Make it a habit: always destructure both, always check error before using data.

// Good
const { data, error } = await supabase.from('posts').select('*')
if (error) { /* handle it */ return }
console.log(data)

// Bad — error is silently ignored
const { data } = await supabase.from('posts').select('*')
console.log(data) // might be null

For the UI layer, Empire UI's browse components work really well with Supabase-backed apps — especially if you're building dashboards. The data table components, loading skeletons, and notification patterns are all designed for exactly this kind of async, realtime-heavy interface. Pair a glassmorphism card from the glassmorphism generator with a live subscription and you've got a slick realtime dashboard in under an hour.

Watch out for TypeScript types — Supabase can generate them from your schema using the CLI (supabase gen types typescript --project-id your-id > src/types/supabase.ts). Do this early. Working with typed query results instead of any[] saves you significant debugging time on larger tables, and your editor's autocomplete actually becomes useful.

FAQ

Do I need a backend server when using Supabase with React?

Not for most apps. The anon key lets you query Supabase directly from the browser, and Row Level Security handles authorization at the database level. You'd add a backend only for sensitive operations like charging customers or running server-side secrets.

Is Supabase free tier usable for production?

The free tier gives you 500MB database, 1GB storage, and 50MB file uploads — enough to launch and validate an idea. Projects pause after 7 days of inactivity on free tier, so upgrade to Pro ($25/month) before going live with real users.

How does Supabase realtime compare to Firebase's onSnapshot?

Functionally similar, but Supabase realtime sits on top of Postgres CDC rather than a custom document store. You get the full power of SQL filters on subscriptions. Firebase's onSnapshot tends to be faster for very high-frequency updates; Supabase wins on query flexibility.

Can I use Supabase Storage with Next.js Server Components?

Yes, but use @supabase/ssr instead of @supabase/supabase-js directly. Server Components can call Storage APIs using a service role client — just never expose the service role key to the browser.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Authentication in Next.js 2026: NextAuth v5, Clerk, Better AuthVitest + React Testing Library: Full Setup From ZeroOTP Input in React: 6-Digit Code Entry With Auto-Focus and PasteSupabase vs Firebase in 2026: Which Backend Should You Use?