Supabase as a Postgres Backend: Auth, Row Level Security, Realtime, Storage and Edge Functions

Key takeaways

Supabase gives you a full backend — Postgres database, authentication, file storage, and realtime subscriptions — without managing infrastructure. This guide covers all four pillars with practical examples.

Supabase provides authentication, a managed PostgreSQL database, file storage, and realtime subscriptions — all accessible via a simple JavaScript SDK. This guide covers each feature with practical examples.

Why Supabase?

Supabase is usually described as an open-source Firebase alternative, but the architecture is quite different. Underneath is an ordinary PostgreSQL database; the other pieces are separate services around it — PostgREST generates a REST API from your schema, the Auth service issues JWTs, Realtime streams changes from Postgres’s write-ahead log, and Storage keeps file metadata in Postgres tables. The client SDK talks to those services over HTTP and WebSockets. The consequence that shapes everything else in this guide: the browser talks (almost) directly to your database, so authorization has to live in the database, as Row Level Security policies.

Why teams choose Supabase over building custom backends:

  • Launch in hours, not weeks — authentication, database, and file storage work out of the box
  • PostgreSQL, not NoSQL — use SQL, joins, transactions, and your existing database knowledge
  • Row Level Security — built-in authorization at the database level (not application layer)
  • Realtime by default — subscribe to database changes without WebSocket boilerplate
  • Open source — self-host if needed, no vendor lock-in

Supabase vs Firebase:

FeatureSupabaseFirebase
DatabasePostgreSQL (SQL)Firestore (NoSQL)
AuthOpen source (GoTrue)Proprietary
Self-hostingYesNo
QueryingSQL, joins, full-text searchDocument queries, limited joins
PricingFree tier plus usage-based plans; check current limitsFree “Spark” plan plus pay-as-you-go; check current limits

When to use Supabase:

  • You prefer SQL and relational data modeling
  • Need complex queries, joins, or full-text search
  • Want to self-host or avoid vendor lock-in
  • Building web apps (Next.js, Remix, SvelteKit)

When to use Firebase:

  • Need the most mature mobile SDK (Flutter, iOS, Android)
  • Require Google ecosystem integration (Firebase ML, Analytics)
  • Prefer NoSQL document structure
  • Need advanced real-time features (Firestore’s nested listeners)

Setup

npm install @supabase/supabase-js
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);

Get your URL and anon key from the Supabase dashboard under Project Settings → API.

The anon key (newer projects call it the publishable key) is meant to be public: it is embedded in your frontend bundle, and anyone can extract it from the browser. It identifies the project, not the user. What protects your data is that requests made with it run as the Postgres role anon (or authenticated, once a user signs in and the SDK attaches their JWT), and those roles can only do what your RLS policies allow. The service_role (secret) key is the opposite: it bypasses RLS entirely. It belongs only in server code and must never carry the NEXT_PUBLIC_ prefix, which makes Next.js inline the value into client JavaScript.


Authentication

Email & Password

// Sign up
const { data, error } = await supabase.auth.signUp({
  email: '[email protected]',
  password: 'secure-password',
});

// Sign in
const { data, error } = await supabase.auth.signInWithPassword({
  email: '[email protected]',
  password: 'secure-password',
});

// Sign out
await supabase.auth.signOut();

// Get current user
const { data: { user } } = await supabase.auth.getUser();

Two behaviors surprise people on the first day. With email confirmation enabled (the default on hosted projects), signUp returns a user but no session — the user cannot do anything authenticated until they click the link in the email, and the default email sender is rate-limited heavily, so configure your own SMTP provider before launch. And the SDK functions never throw for auth failures; they return { data, error }, so a wrong password shows up as error.message === 'Invalid login credentials' rather than an exception. Code that forgets to check error proceeds with data.user === null.

getUser() makes a request to the Auth server to validate the token, while getSession() just reads the session stored locally (in localStorage or cookies). On the client that difference is minor, but on the server getSession() trusts whatever cookie the request carried, so authorization decisions in server code should use getUser() (or verify the JWT claims).

OAuth (Google, GitHub, etc.)

await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: 'http://localhost:3000/auth/callback',
  },
});

redirectTo must be listed under Authentication → URL Configuration → Redirect URLs in the dashboard; otherwise Supabase ignores it and sends the user to the project’s Site URL instead, which is the usual cause of “OAuth works locally but redirects to production” (or the reverse). With server-side rendering, the callback route exchanges the returned code for a session (supabase.auth.exchangeCodeForSession(code)) and sets the auth cookies; with a pure SPA, the SDK handles it on page load.

Auth State Listener

const { data: { subscription } } = supabase.auth.onAuthStateChange(
  (event, session) => {
    if (event === 'SIGNED_IN') console.log('Signed in:', session.user.email);
    if (event === 'SIGNED_OUT') console.log('Signed out');
  }
);

// Cleanup
subscription.unsubscribe();

Database (PostgreSQL)

Supabase exposes your Postgres database via a REST API. The JavaScript client provides a fluent query builder.

Basic CRUD

// SELECT
const { data, error } = await supabase
  .from('posts')
  .select('id, title, created_at')
  .order('created_at', { ascending: false })
  .limit(10);

// INSERT
const { data, error } = await supabase
  .from('posts')
  .insert({ title: 'Hello World', body: 'My first post', user_id: user.id })
  .select()
  .single();

// UPDATE
const { error } = await supabase
  .from('posts')
  .update({ title: 'Updated Title' })
  .eq('id', postId);

// DELETE
const { error } = await supabase
  .from('posts')
  .delete()
  .eq('id', postId);

Every call is translated into an HTTP request to PostgREST (GET /rest/v1/posts?select=id,title,created_at&order=created_at.desc&limit=10) and executed as a single SQL statement. Two consequences follow. insert and update do not return the affected rows unless you chain .select(), as the insert example does — and .single() then errors if the result is not exactly one row. More importantly, an update or delete that matches no rows is not an error: if RLS hides the row, or postId is wrong, error is null and nothing changed. When it matters, chain .select() and check that the returned array is not empty.

There is also no multi-statement transaction in the client API. Operations that must succeed or fail together (create an order and decrement stock) belong in a Postgres function called with supabase.rpc('create_order', {...}), where the function body runs in one transaction.

Filtering

// Equality
.eq('status', 'published')

// Range
.gte('views', 100).lte('views', 1000)

// Pattern matching
.ilike('title', '%supabase%')

// In array
.in('category', ['tech', 'ai', 'web'])

// Is null
.is('deleted_at', null)

Joins

const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    author:profiles(id, username, avatar_url),
    comments(id, body)
  `);

PostgREST builds these nested selects from foreign keys: author:profiles(...) works only if posts has a foreign key to profiles (the author: part is just an alias for the result field). Without one, the request fails with “Could not find a relationship between ‘posts’ and ‘profiles’ in the schema cache”. If there are two foreign keys between the same tables (say author_id and editor_id), the embed is ambiguous and must name the constraint or column, e.g. profiles!author_id(...). RLS applies to embedded tables as well, so a join can quietly return null for author when the profiles policy hides that row, even though the post itself is visible.


Row Level Security (RLS)

RLS is Supabase’s most important security feature. Enable it on every table, then write policies:

-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Anyone can read published posts
CREATE POLICY "Public posts are visible to all"
  ON posts FOR SELECT
  USING (status = 'published');

-- Users can only edit their own posts
CREATE POLICY "Users can update own posts"
  ON posts FOR UPDATE
  USING (auth.uid() = user_id);

-- Users can only insert as themselves
CREATE POLICY "Users can create posts"
  ON posts FOR INSERT
  WITH CHECK (auth.uid() = user_id);

With RLS enabled, your anon key is safe to use in client-side code — database policies enforce access control.

USING filters which existing rows a statement can see or touch; WITH CHECK validates the new row being written. For UPDATE, if you omit WITH CHECK, Postgres applies the USING expression to the new row too, so a user cannot update a post and set user_id to someone else. Policies for different commands combine in a way that is easy to trip over: an UPDATE can only reach rows that a SELECT policy also makes visible. With only the two policies above, an author cannot update their own draft — the select policy shows published posts only — and the update silently affects zero rows. Adding CREATE POLICY "Authors see own posts" ON posts FOR SELECT USING (auth.uid() = user_id); fixes it, since multiple permissive policies for the same command are OR-ed together.

The mistake I see most often is not a wrong policy but a missing one: a table created with plain SQL (in a migration or the SQL editor) does not have RLS enabled automatically, so every row is readable and writable by anyone with the anon key. The dashboard’s Security Advisor flags tables in the public schema without RLS; checking it after each migration is cheap insurance. For performance, index the columns used in policies (user_id here), and write (select auth.uid()) instead of auth.uid() in policies on large tables so Postgres evaluates it once per query rather than once per row.


Realtime Subscriptions

Subscribe to database changes in real time:

// Subscribe to all changes on a table
const channel = supabase
  .channel('posts-changes')
  .on(
    'postgres_changes',
    { event: '*', schema: 'public', table: 'posts' },
    (payload) => {
      if (payload.eventType === 'INSERT') {
        setPosts(prev => [payload.new, ...prev]);
      }
      if (payload.eventType === 'UPDATE') {
        setPosts(prev => prev.map(p => p.id === payload.new.id ? payload.new : p));
      }
      if (payload.eventType === 'DELETE') {
        setPosts(prev => prev.filter(p => p.id !== payload.old.id));
      }
    }
  )
  .subscribe();

// Cleanup
supabase.removeChannel(channel);

postgres_changes events come from Postgres logical replication, so the table must be part of the supabase_realtime publication (toggle Realtime for the table in the dashboard, or ALTER PUBLICATION supabase_realtime ADD TABLE posts;). Without that, the subscription reports SUBSCRIBED and then never delivers anything — the most common “realtime doesn’t work” report. RLS is applied to change events, so clients only receive rows their select policy allows. For DELETE events, payload.old contains only the primary key by default, because Postgres does not log the full old row; ALTER TABLE posts REPLICA IDENTITY FULL; includes all columns, at some extra write-ahead-log cost.

In React, the subscription must be created in useEffect and removed in its cleanup function; otherwise every re-render or Strict Mode double-mount adds another channel and each change is applied several times. Treat realtime as a hint rather than the source of truth: events that happen while a client is disconnected are not replayed, so refetch the list after reconnecting.

Broadcast (custom events)

Send events between clients without touching the database:

const channel = supabase.channel('room-1');

// Listen
channel.on('broadcast', { event: 'cursor' }, ({ payload }) => {
  updateCursor(payload.userId, payload.x, payload.y);
}).subscribe();

// Send
channel.send({
  type: 'broadcast',
  event: 'cursor',
  payload: { userId: user.id, x: 100, y: 200 },
});

Broadcast messages go through the Realtime server without touching the database, which makes them cheap enough for high-frequency events like cursor positions or typing indicators. Nothing is stored, so a client that joins later does not see earlier messages, and by default the sender does not receive its own broadcasts. For “who is online” lists, the Presence feature on the same channel tracks connected clients and their state and handles disconnects for you.


Storage

// Upload a file
const { data, error } = await supabase.storage
  .from('avatars')
  .upload(`public/${user.id}.jpg`, file, {
    cacheControl: '3600',
    upsert: true,
  });

// Get public URL
const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl(`public/${user.id}.jpg`);

// Download
const { data, error } = await supabase.storage
  .from('avatars')
  .download(`public/${user.id}.jpg`);

// Delete
const { error } = await supabase.storage
  .from('avatars')
  .remove([`public/${user.id}.jpg`]);

getPublicUrl only builds a URL string; it does not check anything. It works if the bucket is marked public. For a private bucket the URL returns an error, and you need createSignedUrl(path, expiresInSeconds) instead. Access to storage is controlled by RLS policies on the storage.objects table, so uploads fail with “new row violates row-level security policy” until you add an insert policy — and upsert: true additionally needs an update policy, because replacing an existing file is an update. A common policy restricts each user to a folder named after their ID, e.g. (storage.foldername(name))[1] = auth.uid()::text, which suggests ${user.id}/avatar.jpg as a more policy-friendly path than the one above. Also note that cacheControl: '3600' means browsers and the CDN may keep serving the old avatar for an hour after an upsert; appending a version query string to the URL is the usual workaround.


Edge Functions

Supabase Edge Functions run TypeScript/JavaScript at the edge (Deno runtime):

// supabase/functions/send-email/index.ts
Deno.serve(async (req) => {
  const { to, subject, body } = await req.json();
  // Call external email API...
  return new Response(JSON.stringify({ sent: true }), {
    headers: { "Content-Type": "application/json" },
  });
});

Older examples import serve from deno.land/std/http/server.ts; the built-in Deno.serve replaced it and is what current templates generate. Edge Functions are the place for code that must not run in the browser: calling third-party APIs with secret keys (supabase secrets set RESEND_API_KEY=..., read with Deno.env.get), processing webhooks, or doing work with the service-role key. By default a function requires a valid JWT in the Authorization header — supabase.functions.invoke sends the user’s token automatically — so webhook endpoints called by external services need --no-verify-jwt at deploy time and their own signature check. Calls from a browser also need CORS handling, including a response to the OPTIONS preflight, or invoke fails with a CORS error even though the function itself works.

supabase functions deploy send-email

Call from client:

const { data, error } = await supabase.functions.invoke('send-email', {
  body: { to: '[email protected]', subject: 'Hello', body: 'World' }
});

Next.js App Router Integration

npm install @supabase/ssr
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();   // async in Next.js 15+
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll(); },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options));
          } catch {
            // Called from a Server Component, where cookies are read-only;
            // the middleware refreshes the session instead.
          }
        },
      },
    }
  );
}

// app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';

export default async function Dashboard() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect('/login');

  const { data: posts } = await supabase.from('posts').select('*');
  return <PostList posts={posts} />;
}

The getAll / setAll cookie interface is the current @supabase/ssr API; the older get / set / remove trio that many tutorials show is deprecated. Server Components cannot write cookies, which is why setAll swallows the error there — and why the Supabase Next.js guide also adds a middleware.ts that creates a client per request and calls supabase.auth.getUser(). That middleware is what refreshes expired access tokens and writes the new cookies; without it, users are silently logged out on the server after the access token expires (one hour by default) even though the browser still considers them signed in. Because this page reads cookies, Next.js renders it dynamically per request, which is what you want for per-user data — do not try to cache it statically.


The RLS mistakes that expose data, or hide it

Because the browser talks to your database directly with the anon key, Row Level Security is not an optional hardening step in Supabase; it is the only thing standing between a public key and every row. The dangerous case is a table created through SQL or a migration without alter table ... enable row level security — it is readable and writable by anyone holding the anon key. Make enabling RLS part of every create table migration, and check the dashboard’s security warnings after schema changes.

The opposite mistake is confusing rather than dangerous: with RLS enabled and no policy yet, queries return an empty result instead of an error, which looks like a data-loading bug. When a query that works in the SQL editor returns nothing from the client, check the policies before the code. And keep the service_role key, which bypasses RLS entirely, in server-side contexts only — Edge Functions, server actions, background jobs — never in a NEXT_PUBLIC_ variable.


Frequently Asked Questions (FAQ)

Q. Why does my query return an empty array instead of an error after I enable RLS?

A. Once Row Level Security is enabled on a table, every row is denied until a policy allows it, and Postgres filters out denied rows rather than raising an error. So a missing or too-narrow select policy shows up as an empty result, not a permission failure. Add a policy for the operation you need (for example, rows where auth.uid() matches the owner column), and remember that the service-role key bypasses RLS, so never ship it to the browser.