React + Supabase: Build a Full-Stack App Without a Custom Backend

Every full-stack app needs the same three things: a way to log users in, a place to store and query data, and somewhere to put files. Traditionally you'd build all three yourself — an Express server with Passport or JWT auth, a MongoDB or Postgres database, and an S3 bucket wired up with Multer. That's a significant amount of infrastructure before you've written a single line of actual product code.
Supabase gives you all three as managed services that you talk to directly from React — no custom backend required. In this guide we'll cover exactly how each piece works, then build a file sharing app that uses Supabase Auth to protect routes, a Postgres database to track uploaded files, and Supabase Storage to store and serve them. By the end you'll have a complete full-stack app with zero Express routes.
What is Supabase — and How Does It Compare?
Supabase is an open-source Firebase alternative built on top of PostgreSQL. It wraps a real Postgres database with a REST and real-time API, adds an authentication system, a file storage service, and Edge Functions — and exposes all of it through a JavaScript SDK that you can call directly from your React app.
Compared to Firebase, Supabase gives you a relational database instead of a document store. This matters enormously as your data grows — you get proper foreign keys, joins, transactions, and SQL queries instead of nested JSON documents. Firebase's Firestore is fast to get started with but becomes difficult to query and restructure at scale. Supabase's Postgres is the same database used by companies like Instagram and Shopify.
Compared to building your own backend, Supabase saves you from writing auth logic, session management, file upload handling, database connection pooling, and API endpoints for basic CRUD operations. You still write your own backend when you need custom business logic — payment processing, third-party integrations, complex data transformations — but for the common 80% of what most apps need, Supabase handles it completely.
How Supabase Auth Works — JWTs, Sessions, and Row Level Security
When a user signs up or logs in via Supabase Auth, Supabase issues a JSON Web Token (JWT). This token is stored in localStorage by the Supabase SDK and automatically attached to every subsequent request as a Bearer token in the Authorization header. Supabase's servers verify this token on every request — so it knows exactly which user is making each call.
The session is managed entirely by the Supabase SDK. It handles token storage, automatic token refresh before expiry, and session persistence across page reloads. You don't write any of this — you just call supabase.auth.getSession() to check if a user is logged in, and the SDK handles the rest.
Row Level Security (RLS) is where Supabase's approach really shines. RLS is a PostgreSQL feature that lets you write policies controlling which rows a user can read, insert, update, or delete — at the database level. For example: "users can only read rows where user_id equals their own auth ID." This policy is enforced by Postgres itself, not by your application code. Even if a bug in your React app sent the wrong user ID in a request, the database would reject it.
RLS policies use a special Supabase function called auth.uid() that returns the ID of the currently authenticated user from the JWT. A policy that says "users can only see their own files" looks like this in SQL:
-- RLS policy: users can only select their own rows
CREATE POLICY "Users can view own files"
ON files
FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert rows where user_id matches their auth ID
CREATE POLICY "Users can insert own files"
ON files
FOR INSERT
WITH CHECK (auth.uid() = user_id);How the Real-Time Database Works — Postgres + Subscriptions
Supabase's real-time feature is built on top of PostgreSQL's logical replication system. When you enable real-time on a table, Supabase monitors the database's change stream and broadcasts INSERT, UPDATE, and DELETE events to any connected clients that have subscribed to that table.
In your React app, you subscribe to a table using supabase.channel(). When a row is inserted, updated, or deleted — by any user, from any client — your subscription callback fires with the changed data. This means if two users have your file sharing app open simultaneously, when one uploads a file the other sees it appear in their list instantly — no page refresh required.
Subscriptions use WebSockets under the hood. The Supabase SDK manages the WebSocket connection, handles reconnection on network drops, and cleans up automatically when you unsubscribe. In React, you set up the subscription in a useEffect and return the cleanup function to unsubscribe when the component unmounts — preventing memory leaks.
// Pattern for a real-time subscription in React
useEffect(() => {
const channel = supabase
.channel("files-changes")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "files" },
(payload) => {
// payload.new contains the newly inserted row
setFiles((prev) => [...prev, payload.new]);
}
)
.subscribe();
// Cleanup: unsubscribe when component unmounts
return () => {
supabase.removeChannel(channel);
};
}, []);How File Storage Works — Buckets and Public URLs
Supabase Storage is organised into buckets — similar to S3 buckets or Cloudinary folders. Each bucket has an access policy: public (anyone can read files) or private (only authenticated users can access files, and only via signed URLs). For a file sharing app where users upload files for others to download, a public bucket is the right choice. For private user documents, you'd use a private bucket with time-limited signed URLs.
When you upload a file, Supabase stores it and returns a path. From that path you can generate two kinds of URL. A public URL is a permanent, freely accessible link — suitable for images, downloadable files, or anything you want to share openly. A signed URL is a temporary link (you set the expiry) that grants access to a private file for a limited time — suitable for invoices, private documents, or anything sensitive.
Files in Supabase Storage also have their own RLS-style access policies, configured in the Supabase dashboard. You can restrict uploads to authenticated users only, restrict downloads to the file owner, or make a bucket completely public. These storage policies work independently of your database RLS policies, so you configure them separately.
Project Setup — Supabase Dashboard and React
Step 1: Create Your Supabase Project
- Go to supabase.com and sign up for a free account
- Click 'New Project', give it a name, set a database password, and choose a region
- Wait about 60 seconds for the project to provision
- Go to Project Settings > API to find your Project URL and anon public key — you'll need both
Step 2: Create the Files Table
In the Supabase dashboard, go to the SQL Editor and run this to create the files table:
-- Create the files table
CREATE TABLE files (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
type TEXT NOT NULL,
path TEXT NOT NULL,
url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable Row Level Security
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
-- Policy: users can only read their own files
CREATE POLICY "Users can view own files"
ON files FOR SELECT
USING (auth.uid() = user_id);
-- Policy: users can only insert their own files
CREATE POLICY "Users can insert own files"
ON files FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Policy: users can only delete their own files
CREATE POLICY "Users can delete own files"
ON files FOR DELETE
USING (auth.uid() = user_id);
-- Enable real-time on the files table
ALTER PUBLICATION supabase_realtime ADD TABLE files;Step 3: Create a Storage Bucket
- In the Supabase dashboard, go to Storage in the left sidebar
- Click 'New Bucket', name it 'user-files', and check 'Public bucket'
- Go to Storage > Policies and add a policy: authenticated users can upload (INSERT) to the bucket
- Add another policy: public read access so anyone with the URL can download files
Step 4: Set Up Your React App
npm create vite@latest file-share-app -- --template react
cd file-share-app
npm install @supabase/supabase-js# .env
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-public-keyCreate the Supabase client — src/lib/supabase.js:
// src/lib/supabase.js
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY
);This single client instance is imported everywhere in your app. The anon key is safe to expose in the browser — it's a public key that only grants access based on your RLS policies. It is not your secret service key.
Part 1: Authentication
Create a custom hook to manage auth state across the app — src/hooks/useAuth.js:
// src/hooks/useAuth.js
import { useState, useEffect } from "react";
import { supabase } from "../lib/supabase";
export function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get initial session on mount
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
setLoading(false);
});
// Listen for auth state changes (login, logout, token refresh)
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, []);
return { user, loading };
}Now build the Auth form — src/components/AuthForm.jsx. We'll handle both sign up and login in the same component:
// src/components/AuthForm.jsx
import { useState } from "react";
import { supabase } from "../lib/supabase";
function AuthForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isSignUp, setIsSignUp] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const handleSubmit = async () => {
if (!email || !password) return;
setLoading(true);
setMessage("");
const { error } = isSignUp
? await supabase.auth.signUp({ email, password })
: await supabase.auth.signInWithPassword({ email, password });
if (error) {
setMessage(error.message);
} else if (isSignUp) {
setMessage("Check your email to confirm your account.");
}
setLoading(false);
};
return (
<div className="auth-form">
<h2>{isSignUp ? "Create an account" : "Sign in"}</h2>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
<button onClick={handleSubmit} disabled={loading}>
{loading ? "Please wait..." : isSignUp ? "Sign Up" : "Sign In"}
</button>
{message && <p className="auth-message">{message}</p>}
<button className="toggle-btn" onClick={() => setIsSignUp((prev) => !prev)}>
{isSignUp ? "Already have an account? Sign in" : "Need an account? Sign up"}
</button>
</div>
);
}
export default AuthForm;Part 2: File Upload and Database Storage
The upload flow has two steps: upload the binary file to the storage bucket, then save the file's metadata (name, size, type, URL) to the files table. Both steps happen in sequence — if the storage upload fails, we don't write to the database.
// src/components/FileUpload.jsx
import { useState } from "react";
import { supabase } from "../lib/supabase";
function FileUpload({ user }) {
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState("");
const handleUpload = async (e) => {
const file = e.target.files[0];
if (!file || uploading) return;
setUploading(true);
setMessage("");
// Step 1: Upload binary to Supabase Storage
// Path: {user_id}/{timestamp}-{filename} — keeps files organised per user
const filePath = `${user.id}/${Date.now()}-${file.name}`;
const { error: storageError } = await supabase.storage
.from("user-files")
.upload(filePath, file, {
cacheControl: "3600",
upsert: false, // Don't overwrite if file exists
});
if (storageError) {
setMessage(`Upload failed: ${storageError.message}`);
setUploading(false);
return;
}
// Step 2: Get the public URL for the uploaded file
const { data: { publicUrl } } = supabase.storage
.from("user-files")
.getPublicUrl(filePath);
// Step 3: Save file metadata to the database
const { error: dbError } = await supabase.from("files").insert({
user_id: user.id,
name: file.name,
size: file.size,
type: file.type,
path: filePath,
url: publicUrl,
});
if (dbError) {
setMessage(`Database error: ${dbError.message}`);
} else {
setMessage("File uploaded successfully!");
}
setUploading(false);
// Reset the file input
e.target.value = "";
};
return (
<div className="file-upload">
<label className={`upload-label ${uploading ? "disabled" : ""}`}>
{uploading ? "Uploading..." : "Choose a file to upload"}
<input
type="file"
onChange={handleUpload}
disabled={uploading}
style={{ display: "none" }}
/>
</label>
{message && <p className="upload-message">{message}</p>}
</div>
);
}
export default FileUpload;Part 3: Real-Time File List
The file list fetches existing files on mount and subscribes to new inserts in real time. When the user uploads a new file, the database insert triggers the subscription and the file appears in the list instantly — without any additional fetch call.
// src/components/FileList.jsx
import { useState, useEffect } from "react";
import { supabase } from "../lib/supabase";
function FileList({ user }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Initial fetch — load existing files
const fetchFiles = async () => {
const { data, error } = await supabase
.from("files")
.select("*")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
if (!error) setFiles(data);
setLoading(false);
};
fetchFiles();
// Real-time subscription — listen for new inserts
const channel = supabase
.channel("user-files-changes")
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "files",
filter: `user_id=eq.${user.id}`, // Only listen to this user's files
},
(payload) => {
// Prepend new file to the top of the list
setFiles((prev) => [payload.new, ...prev]);
}
)
.subscribe();
// Cleanup subscription on unmount
return () => supabase.removeChannel(channel);
}, [user.id]);
const deleteFile = async (file) => {
// Delete from storage first
await supabase.storage.from("user-files").remove([file.path]);
// Then delete from database
await supabase.from("files").delete().eq("id", file.id);
// Update local state
setFiles((prev) => prev.filter((f) => f.id !== file.id));
};
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
if (loading) return <p className="loading-text">Loading your files...</p>;
if (files.length === 0) {
return <p className="empty-text">No files yet. Upload your first file above.</p>;
}
return (
<div className="file-list">
<h3>Your Files ({files.length})</h3>
{files.map((file) => (
<div key={file.id} className="file-item">
<div className="file-info">
<p className="file-name">{file.name}</p>
<p className="file-meta">{formatSize(file.size)} · {file.type}</p>
</div>
<div className="file-actions">
<a href={file.url} target="_blank" rel="noopener noreferrer" className="file-download">
Download
</a>
<button className="file-delete" onClick={() => deleteFile(file)}>
Delete
</button>
</div>
</div>
))}
</div>
);
}
export default FileList;Part 4: Wiring It All Together in App.jsx
The top-level App.jsx uses the useAuth hook to decide what to render. If there's no user, show the AuthForm. If there is a user, show the file sharing dashboard. This is the simplest possible protected route pattern — no React Router required for a single-page app like this.
// src/App.jsx
import { supabase } from "./lib/supabase";
import { useAuth } from "./hooks/useAuth";
import AuthForm from "./components/AuthForm";
import FileUpload from "./components/FileUpload";
import FileList from "./components/FileList";
function App() {
const { user, loading } = useAuth();
if (loading) {
return <div className="app-loading">Loading...</div>;
}
if (!user) {
return (
<div className="app">
<h1>FileShare</h1>
<AuthForm />
</div>
);
}
return (
<div className="app">
<header className="app-header">
<h1>FileShare</h1>
<div className="header-right">
<span className="user-email">{user.email}</span>
<button onClick={() => supabase.auth.signOut()}>Sign out</button>
</div>
</header>
<main className="app-main">
<FileUpload user={user} />
<FileList user={user} />
</main>
</div>
);
}
export default App;The auth flow is completely handled by Supabase and the useAuth hook. When the user signs in, onAuthStateChange fires, the user state updates, and React re-renders from the AuthForm to the dashboard. When they sign out, the same thing happens in reverse. No manual token handling, no localStorage manipulation, no API calls to your own server.
Protecting Routes with React Router
If your app has multiple pages using React Router, you'll want a proper protected route component rather than the conditional render pattern above. Here's how to build one that works with Supabase auth:
// src/components/ProtectedRoute.jsx
import { Navigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
// Don't redirect while session is being checked
if (loading) return <div>Loading...</div>;
// Redirect to login if not authenticated
if (!user) return <Navigate to="/login" replace />;
return children;
}
export default ProtectedRoute;
// Usage in App.jsx with React Router
// <Route path="/dashboard" element={
// <ProtectedRoute>
// <Dashboard />
// </ProtectedRoute>
// } />When You Still Need a Custom Backend
Supabase handles the vast majority of what most apps need, but there are cases where you'll still want a Node.js backend running alongside it:
- Payment processing — Stripe webhooks must be received by a server you control. You can't safely process Stripe events directly in Supabase without Edge Functions.
- Third-party API calls requiring secret keys — if you need to call an API like OpenAI, fal.ai, or Twilio, those keys must live on a server. Supabase Edge Functions (Deno-based serverless functions) can handle these cases without a full Express server.
- Complex business logic — multi-step operations that span multiple tables, external services, and need to be atomic are easier to reason about in a dedicated backend function.
- Heavy data processing — image resizing, PDF generation, large file processing — these are better handled server-side than via Supabase's client SDK.
Supabase Edge Functions are worth exploring as a middle ground. They're TypeScript functions that run on Supabase's infrastructure and have access to your database with elevated permissions — good for server-side logic without spinning up an entire Express server.
Final Thoughts
Supabase genuinely changes what's possible in a React app without a custom backend. Authentication that would take a full day to implement correctly is four lines of code. Real-time updates that would require WebSocket infrastructure are a single subscription call. File storage that would need S3, Multer, and a proxy endpoint is handled end to end by the SDK.
The important mindset shift is trusting RLS. Once you understand that Row Level Security enforces data access at the database level — independently of your application code — calling Supabase directly from React stops feeling risky and starts feeling powerful. Your data is protected by the database itself, not by middleware you have to maintain.
Want to see how to add Google OAuth sign-in with Supabase, or how to use Supabase Edge Functions to handle Stripe webhooks without an Express server? Drop a comment in LiveChat and I'll cover it in a follow-up guide.