Skip to main content
Learn how to integrate Supabase with React to build modern single-page applications with authentication, real-time data, and file storage.

Why React + Supabase?

  • Hooks-based API for clean, reusable code
  • Real-time subscriptions for live updates
  • Type-safe with TypeScript
  • Zero backend configuration needed
  • Fast development with minimal boilerplate

Quick Start

1

Create a React App

Using Vite (recommended):
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
Or using Create React App:
npx create-react-app my-app --template typescript
cd my-app
2

Install Supabase

npm install @supabase/supabase-js
3

Set Up Environment Variables

Create .env (Vite) or .env.local (CRA):
VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_ANON_KEY=your-anon-key
For Create React App, use REACT_APP_ prefix instead of VITE_.
4

Create Supabase Client

Create 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)

Authentication

Auth Context

Create an auth context to manage user state:
// src/contexts/AuthContext.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { User, Session } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'

interface AuthContextType {
  user: User | null
  session: Session | null
  signIn: (email: string, password: string) => Promise<void>
  signUp: (email: string, password: string) => Promise<void>
  signOut: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

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

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session)
      setUser(session?.user ?? null)
      setLoading(false)
    })

    // Listen for auth changes
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session)
      setUser(session?.user ?? null)
    })

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

  const signIn = async (email: string, password: string) => {
    const { error } = await supabase.auth.signInWithPassword({ email, password })
    if (error) throw error
  }

  const signUp = async (email: string, password: string) => {
    const { error } = await supabase.auth.signUp({ email, password })
    if (error) throw error
  }

  const signOut = async () => {
    const { error } = await supabase.auth.signOut()
    if (error) throw error
  }

  return (
    <AuthContext.Provider value={{ user, session, signIn, signUp, signOut }}>
      {!loading && children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

Login Component

Create a login form:
// src/components/Auth.tsx
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'

export default function Auth() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const { signIn, signUp } = useAuth()

  const handleSignIn = async (e: React.FormEvent) => {
    e.preventDefault()
    try {
      setLoading(true)
      await signIn(email, password)
    } catch (error: any) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  const handleSignUp = async (e: React.FormEvent) => {
    e.preventDefault()
    try {
      setLoading(true)
      await signUp(email, password)
      alert('Check your email for the confirmation link!')
    } catch (error: any) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow">
      <h1 className="text-2xl font-bold mb-6">Welcome</h1>
      <form className="space-y-4">
        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-1">
            Email
          </label>
          <input
            id="email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 border rounded-lg"
            required
          />
        </div>
        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-1">
            Password
          </label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full px-3 py-2 border rounded-lg"
            required
          />
        </div>
        <div className="flex gap-2">
          <button
            type="button"
            onClick={handleSignIn}
            disabled={loading}
            className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
          >
            Sign In
          </button>
          <button
            type="button"
            onClick={handleSignUp}
            disabled={loading}
            className="flex-1 px-4 py-2 bg-gray-200 rounded-lg disabled:opacity-50"
          >
            Sign Up
          </button>
        </div>
      </form>
    </div>
  )
}

Database Operations

Fetching Data

Use hooks to fetch data:
import { useState, useEffect } from 'react'
import { supabase } from '@/lib/supabase'

interface Post {
  id: string
  title: string
  content: string
  created_at: string
}

export default function Posts() {
  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchPosts()
  }, [])

  const fetchPosts = async () => {
    try {
      const { data, error } = await supabase
        .from('posts')
        .select('*')
        .order('created_at', { ascending: false })

      if (error) throw error
      setPosts(data || [])
    } catch (error: any) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  if (loading) return <div>Loading...</div>

  return (
    <div className="space-y-4">
      {posts.map((post) => (
        <div key={post.id} className="p-4 border rounded">
          <h2 className="text-xl font-bold">{post.title}</h2>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  )
}

Custom Hook for Data Fetching

Create a reusable hook:
// src/hooks/useSupabaseQuery.ts
import { useState, useEffect } from 'react'
import { supabase } from '@/lib/supabase'
import { PostgrestError } from '@supabase/supabase-js'

export function useSupabaseQuery<T>(
  table: string,
  columns = '*',
  filters?: any
) {
  const [data, setData] = useState<T[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<PostgrestError | null>(null)

  useEffect(() => {
    fetchData()
  }, [table, columns, JSON.stringify(filters)])

  const fetchData = async () => {
    try {
      setLoading(true)
      let query = supabase.from(table).select(columns)

      if (filters) {
        Object.entries(filters).forEach(([key, value]) => {
          query = query.eq(key, value)
        })
      }

      const { data, error } = await query

      if (error) throw error
      setData(data || [])
    } catch (error: any) {
      setError(error)
    } finally {
      setLoading(false)
    }
  }

  return { data, loading, error, refetch: fetchData }
}

// Usage
function MyComponent() {
  const { data: posts, loading } = useSupabaseQuery('posts')
  
  if (loading) return <div>Loading...</div>
  
  return (
    <div>
      {posts.map((post: any) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}

Inserting Data

Create and insert records:
import { useState } from 'react'
import { supabase } from '@/lib/supabase'

export default function CreatePost() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')
  const [loading, setLoading] = useState(false)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    try {
      setLoading(true)
      const { error } = await supabase
        .from('posts')
        .insert({ title, content })

      if (error) throw error
      
      alert('Post created!')
      setTitle('')
      setContent('')
    } catch (error: any) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
        className="w-full px-3 py-2 border rounded"
        required
      />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Content"
        className="w-full px-3 py-2 border rounded"
        rows={4}
        required
      />
      <button
        type="submit"
        disabled={loading}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        {loading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

Real-time Subscriptions

Subscribe to database changes:
import { useState, useEffect } from 'react'
import { supabase } from '@/lib/supabase'

export default function RealtimePosts() {
  const [posts, setPosts] = useState<any[]>([])

  useEffect(() => {
    // Fetch initial data
    fetchPosts()

    // Subscribe to changes
    const channel = supabase
      .channel('posts')
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'posts' },
        (payload) => {
          setPosts((current) => [payload.new, ...current])
        }
      )
      .on(
        'postgres_changes',
        { event: 'DELETE', schema: 'public', table: 'posts' },
        (payload) => {
          setPosts((current) => current.filter((post) => post.id !== payload.old.id))
        }
      )
      .on(
        'postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'posts' },
        (payload) => {
          setPosts((current) =>
            current.map((post) => (post.id === payload.new.id ? payload.new : post))
          )
        }
      )
      .subscribe()

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

  const fetchPosts = async () => {
    const { data } = await supabase
      .from('posts')
      .select('*')
      .order('created_at', { ascending: false })
    setPosts(data || [])
  }

  return (
    <div className="space-y-4">
      <h2 className="text-2xl font-bold">Posts (Real-time)</h2>
      {posts.map((post) => (
        <div key={post.id} className="p-4 border rounded">
          <h3 className="font-bold">{post.title}</h3>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  )
}

File Upload

Upload files to Supabase Storage:
import { useState } from 'react'
import { supabase } from '@/lib/supabase'

export default function FileUpload() {
  const [uploading, setUploading] = useState(false)
  const [url, setUrl] = useState<string | null>(null)

  const uploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
    try {
      setUploading(true)

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('You must select a file to upload.')
      }

      const file = event.target.files[0]
      const fileExt = file.name.split('.').pop()
      const fileName = `${Math.random()}.${fileExt}`
      const filePath = `uploads/${fileName}`

      const { error: uploadError } = await supabase.storage
        .from('files')
        .upload(filePath, file)

      if (uploadError) throw uploadError

      // Get public URL
      const { data } = supabase.storage.from('files').getPublicUrl(filePath)
      setUrl(data.publicUrl)
    } catch (error: any) {
      alert(error.message)
    } finally {
      setUploading(false)
    }
  }

  return (
    <div className="space-y-4">
      <input
        type="file"
        onChange={uploadFile}
        disabled={uploading}
        className="block w-full"
      />
      {uploading && <p>Uploading...</p>}
      {url && (
        <div>
          <p>File uploaded successfully!</p>
          <a href={url} target="_blank" rel="noopener noreferrer" className="text-blue-500">
            View file
          </a>
        </div>
      )}
    </div>
  )
}

App Structure

Put it all together:
// src/App.tsx
import { AuthProvider, useAuth } from '@/contexts/AuthContext'
import Auth from '@/components/Auth'
import Dashboard from '@/components/Dashboard'

function AppContent() {
  const { user, signOut } = useAuth()

  if (!user) {
    return <Auth />
  }

  return (
    <div>
      <nav className="bg-white shadow px-4 py-3 flex justify-between items-center">
        <h1 className="text-xl font-bold">My App</h1>
        <button
          onClick={signOut}
          className="px-4 py-2 bg-red-500 text-white rounded"
        >
          Sign Out
        </button>
      </nav>
      <main className="container mx-auto p-4">
        <Dashboard />
      </main>
    </div>
  )
}

function App() {
  return (
    <AuthProvider>
      <AppContent />
    </AuthProvider>
  )
}

export default App

Best Practices

Use Context for Auth

Manage authentication state globally with React Context.

Custom Hooks

Create reusable hooks for common Supabase operations.

Error Handling

Always handle errors and provide user feedback.

Loading States

Show loading indicators during async operations.

Cleanup Subscriptions

Unsubscribe from real-time channels when components unmount.

Next Steps

Build a Todo App

Complete tutorial with authentication

User Management

Add profile management

File Uploads

Handle file storage

Examples

Explore more examples