Back to Blog

Why We Use Supabase as Our Default Backend

Why We Use Supabase as Our Default Backend

We have built over ten production applications on Supabase. Not prototypes, not side projects — real products handling real users and real money. MindHyv, VincelIO, LancerSpace, JustTheRip, and several client projects that we cannot name publicly.

After all of that, Supabase is still our default backend for new projects. Not because it is perfect — it is not — but because it eliminates a category of infrastructure decisions that slow teams down without adding value. This post explains what we love, what we tolerate, and what makes us reach for something else.

The Pitch: Postgres With Batteries

Supabase gives you a PostgreSQL database, authentication, row-level security, real-time subscriptions, file storage, and edge functions. All managed, all connected, all accessible through a single SDK.

For a small studio like ours — four senior engineers shipping twelve-plus products — the time we do not spend configuring auth providers, setting up storage buckets, or wiring up WebSocket servers is time we spend building features that matter to users.

Here is what a typical Supabase setup looks like in one of our SvelteKit projects:

// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';

const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;

export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

That is the entire backend client initialization. One import, two environment variables, full type safety. The Database type is auto-generated from our schema using supabase gen types typescript, which means every query is checked against the actual database structure at compile time.

Authentication That Just Works

Auth is the feature that saves us the most time. Supabase Auth supports email/password, magic links, OAuth providers (Google, GitHub, Apple, etc.), and phone authentication out of the box.

Setting up Google OAuth for a new project takes about ten minutes:

// Sign in with Google
const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: `${window.location.origin}/auth/callback`,
  },
});

// Handle the callback
// src/routes/auth/callback/+server.ts
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {
  const code = url.searchParams.get('code');

  if (code) {
    await supabase.auth.exchangeCodeForSession(code);
  }

  throw redirect(303, '/dashboard');
};

Compare this to rolling your own auth with Passport.js, managing JWT refresh tokens, building password reset flows, handling OAuth state parameters, and storing sessions. We have done that. It takes a week minimum for a production-grade implementation. Supabase Auth takes an afternoon.

For MindHyv, we needed auth that could handle entrepreneurs signing up with email, their customers booking with magic links, and team members invited via email. Supabase Auth handled all three patterns with the same API. We added role-based access by extending the auth.users table with a profiles table and using RLS policies to control access.

Developer working with technology tools and multiple screens

Row-Level Security: The Right Abstraction

Row-Level Security (RLS) is the feature that most developers either love or find confusing. We are firmly in the love camp, but it took a few projects to get there.

RLS lets you define access rules at the database level. Instead of writing authorization checks in every API endpoint, you write policies that Postgres enforces automatically. Here is a real example from a multi-tenant project:

-- Users can only see their own organization's data
CREATE POLICY "Users can view own org invoices"
ON invoices FOR SELECT
USING (
  organization_id IN (
    SELECT organization_id
    FROM organization_members
    WHERE user_id = auth.uid()
  )
);

-- Users can only insert invoices for their own org
CREATE POLICY "Users can create org invoices"
ON invoices FOR INSERT
WITH CHECK (
  organization_id IN (
    SELECT organization_id
    FROM organization_members
    WHERE user_id = auth.uid()
    AND role IN ('admin', 'member')
  )
);

-- Only admins can delete invoices
CREATE POLICY "Admins can delete org invoices"
ON invoices FOR DELETE
USING (
  organization_id IN (
    SELECT organization_id
    FROM organization_members
    WHERE user_id = auth.uid()
    AND role = 'admin'
  )
);

Once these policies are in place, every query through the Supabase client is automatically filtered. A user querying supabase.from('invoices').select('*') only gets back their organization’s invoices. No additional filtering needed in application code.

The mental shift is significant: you stop thinking about authorization as an application concern and start thinking about it as a data concern. Once it clicks, it is hard to go back.

For LancerSpace, we had freelancers managing clients, proposals, invoices, and projects — all scoped to individual users. RLS policies ensured that one freelancer could never see another’s data, even if a bug in our application code forgot to filter by user_id. The database itself enforced the boundary.

Real-Time Subscriptions

Supabase Realtime lets you subscribe to database changes over WebSockets. We use this in every project that has collaborative or live-updating features.

// Subscribe to new messages in a conversation
const channel = supabase
  .channel('conversation-messages')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `conversation_id=eq.${conversationId}`,
    },
    (payload) => {
      messages = [...messages, payload.new as Message];
    }
  )
  .subscribe();

// Clean up when component unmounts
onDestroy(() => {
  supabase.removeChannel(channel);
});

This is significantly simpler than setting up your own WebSocket server with Socket.io or Pusher. The subscription is tied directly to database changes, so there is no separate event system to maintain.

We used Realtime extensively in MindHyv for the social feed and booking notifications, and in Trackelio for live feedback updates when team members are collaborating on the same survey results.

One caveat: Supabase Realtime has throughput limits on lower-tier plans. For high-frequency updates (chat applications with thousands of concurrent users, live dashboards updating every second), you may hit the ceiling. For our typical use case — tens to hundreds of concurrent users with moderate update frequency — it has been reliable.

Server rack infrastructure powering backend services

Storage: Simple and Integrated

Supabase Storage is S3-compatible object storage with a clean API and built-in image transformations. We use it for user avatars, file attachments, invoice PDFs, and product images.

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

// Get a public URL with transformation
const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${userId}/profile.jpg`, {
    transform: {
      width: 200,
      height: 200,
      resize: 'cover',
    },
  });

The image transformation feature eliminates the need for a separate image processing pipeline. Upload the original, request the size you need at read time. For VincelIO, where creator profiles include portfolio images of varying sizes and aspect ratios, this saved us from building a separate image resizing service.

Storage also respects RLS policies through storage policies, so you can control who can upload to and read from specific buckets:

-- Users can upload to their own folder
CREATE POLICY "Users can upload own avatar"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'avatars'
  AND (storage.foldername(name))[1] = auth.uid()::text
);

Edge Functions: When You Need Custom Logic

Supabase Edge Functions are Deno-based serverless functions that run at the edge. We use them for webhook handlers, third-party API integrations, and background processing that does not fit into RLS policies or database functions.

// supabase/functions/send-invoice/index.ts
import { serve } from 'https://deno.land/std/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js';

serve(async (req) => {
  const { invoiceId } = await req.json();

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  );

  const { data: invoice } = await supabase
    .from('invoices')
    .select('*, client:clients(*)')
    .eq('id', invoiceId)
    .single();

  if (!invoice) {
    return new Response('Invoice not found', { status: 404 });
  }

  // Generate PDF, send email, update status
  await generateAndSendInvoice(invoice);

  await supabase
    .from('invoices')
    .update({ status: 'sent', sent_at: new Date().toISOString() })
    .eq('id', invoiceId);

  return new Response(JSON.stringify({ success: true }), {
    headers: { 'Content-Type': 'application/json' },
  });
});

Edge Functions are good enough for most of our needs. They are not a replacement for a full backend framework — if you need complex routing, middleware chains, or heavy computation, you should run your own server. But for the 80% case of “receive a webhook, do some processing, update the database,” they work well.

The Honest Limitations

Supabase is not perfect. Here are the real limitations we have encountered:

Cold starts on Edge Functions. The first invocation after a period of inactivity can take 1-2 seconds. For user-facing API calls, this is noticeable. We mitigate this with keep-alive pings for critical functions, but it is an annoyance.

Complex queries hit the PostgREST ceiling. Supabase’s query builder is built on PostgREST, which covers 90% of query patterns. But for complex aggregations, CTEs, or window functions, you end up writing raw SQL via supabase.rpc() and database functions. This is fine — we prefer writing SQL anyway — but it means maintaining PL/pgSQL functions alongside your application code.

-- When the query builder is not enough, write a database function
CREATE OR REPLACE FUNCTION get_monthly_revenue(org_id uuid)
RETURNS TABLE(month date, total numeric, count bigint)
LANGUAGE sql STABLE
AS $$
  SELECT
    date_trunc('month', created_at)::date as month,
    sum(amount) as total,
    count(*) as count
  FROM invoices
  WHERE organization_id = org_id
    AND status = 'paid'
  GROUP BY date_trunc('month', created_at)
  ORDER BY month DESC
  LIMIT 12;
$$;

Vendor lock-in is real but manageable. Your data is in Postgres, so the core is portable. But Supabase Auth, Storage, and Realtime are proprietary APIs. Migrating away means replacing those services. We accept this tradeoff because the productivity gain outweighs the migration risk for the size of projects we build.

The dashboard can be slow. Supabase Studio (the web dashboard) occasionally lags when browsing large tables or editing complex RLS policies. We do most of our work via the CLI and SQL migrations, so this is a minor irritation rather than a blocker.

No built-in job queue. For scheduled tasks and background jobs, you need to bring your own solution. We use pg_cron for simple scheduled tasks and an external queue (BullMQ or Inngest) when we need reliable job processing with retries.

Open source technology and collaborative software development

When We Reach for Something Else

Supabase is not always the answer. Here are the situations where we use alternatives:

  • High-throughput event processing: If the project involves ingesting thousands of events per second, we use a dedicated event system (Kafka, Redis Streams) with a custom backend.
  • Complex authorization beyond RLS: Some permission models (hierarchical roles, attribute-based access control, cross-tenant sharing) are painful to express in RLS. For those, we add an authorization layer in application code.
  • Projects requiring specific databases: If the data model fits a document database or graph database better than relational, Supabase is not the right choice. This is rare for us, but it happens.
  • Client teams with existing infrastructure: If a client already has a backend team running on AWS or GCP, we integrate with their existing stack rather than introducing Supabase.

The Stack in Practice

Our default stack for a new project looks like this:

  • Frontend: Astro (marketing) + SvelteKit (app) — see our Astro vs Next.js comparison
  • Backend: Supabase (auth, database, storage, realtime)
  • Mobile: Flutter — see our honest review of Flutter
  • Hosting: Cloudflare Pages (frontend) + Supabase Cloud (backend)
  • CI/CD: GitHub Actions

This stack lets us go from zero to production MVP in 4-6 weeks. Not because we cut corners, but because Supabase eliminates the backend infrastructure work that usually consumes the first two weeks of a project.

For a walkthrough of how we kick off new projects, including the discovery and scoping process that happens before any code is written, see our post on what happens when you email a dev studio.

If you are evaluating Supabase for your next project and want an honest opinion on whether it fits your use case, reach out at [email protected].