Use SupaHost with React (Vite)

Set up a React single-page app built with Vite against a SupaHost project. Install supabase-js, add env vars, query a table, track the session and add email sign-in.

A React app built with Vite runs entirely in the browser, so it talks to your SupaHost project directly with supabase-js and the public anon key. Row Level Security in the database decides what each user can read and write.

The examples use the recipes table from the quickstart.

Install

npm create vite@latest recipe-box -- --template react-ts
cd recipe-box
npm install @supabase/supabase-js

Add your project keys

Vite exposes variables that start with VITE_ to your code. Create .env.local with the values from your project's Connect tab, subtab App:

VITE_SUPABASE_URL=https://app-one.acme.supahost.space
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

Everything with the VITE_ prefix ends up in the JavaScript bundle that anyone can download. That's expected for the anon key. The service_role key must never go here.

For TypeScript, declare the variables in src/vite-env.d.ts:

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_SUPABASE_URL: string
  readonly VITE_SUPABASE_ANON_KEY: string
}

Create the client

Make one client and import it wherever you need it:

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

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
)

The client stores the session in localStorage and refreshes the access token in the background.

Track the signed-in user

A small hook keeps the current session in React state:

// src/useSession.ts
import { useEffect, useState } from 'react'
import type { Session } from '@supabase/supabase-js'
import { supabase } from './lib/supabase'

export function useSession() {
  const [session, setSession] = useState<Session | null>(null)

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

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, newSession) => setSession(newSession)
    )
    return () => subscription.unsubscribe()
  }, [])

  return session
}

Query and insert

// src/App.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from './lib/supabase'
import { useSession } from './useSession'
import { SignIn } from './SignIn'

type Recipe = { id: number; title: string; minutes: number | null }

export default function App() {
  const session = useSession()
  const [recipes, setRecipes] = useState<Recipe[]>([])

  async function load() {
    const { data, error } = await supabase
      .from('recipes')
      .select('id, title, minutes')
      .order('created_at', { ascending: false })
    if (!error) setRecipes(data)
  }

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

  async function addRecipe(e: FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const title = new FormData(e.currentTarget).get('title') as string
    const { error } = await supabase.from('recipes').insert({ title })
    if (error) alert(error.message)
    else load()
  }

  return (
    <main>
      {session ? (
        <>
          <p>Signed in as {session.user.email}</p>
          <button onClick={() => supabase.auth.signOut()}>Sign out</button>
          <form onSubmit={addRecipe}>
            <input name="title" placeholder="New recipe" required />
            <button>Add</button>
          </form>
        </>
      ) : (
        <SignIn />
      )}
      <ul>
        {recipes.map((r) => <li key={r.id}>{r.title}</li>)}
      </ul>
    </main>
  )
}

Anyone can read recipes because of the quickstart's select policy. Inserting only works while signed in, because the insert policy is limited to authenticated and checks user_id.

Add sign-in

// src/SignIn.tsx
import { useState, type FormEvent } from 'react'
import { supabase } from './lib/supabase'

export function SignIn() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [message, setMessage] = useState('')

  async function signIn(e: FormEvent) {
    e.preventDefault()
    const { error } = await supabase.auth.signInWithPassword({ email, password })
    if (error) setMessage(error.message)
  }

  async function signUp() {
    const { error } = await supabase.auth.signUp({
      email,
      password,
      options: { emailRedirectTo: window.location.origin },
    })
    setMessage(error ? error.message : 'Check your inbox to confirm your email.')
  }

  return (
    <form onSubmit={signIn}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
      <button type="submit">Sign in</button>
      <button type="button" onClick={signUp}>Create account</button>
      {message && <p>{message}</p>}
    </form>
  )
}

When the user clicks the link in the confirmation email, they come back to your app and supabase-js picks up the session from the URL on load.

Allow your redirect URLs

The confirmation link only returns to addresses Auth trusts. On the project's Auth tab, set Site URL to your production URL and list your other origins under Redirect URLs:

http://localhost:5173/**
https://recipes.example.com/**

Click Save Auth settings. Changes apply within about a minute.

Next steps

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us