Build Real-Time Apps with Supabase in 2026

Build Real-Time Apps with Supabase in 2026

Ship real-time features in under two hours using Supabase PostgreSQL subscriptions, presence, and RLS policies—no backend setup required.

Supabase provides PostgreSQL subscriptions, edge functions, and authentication right out of the box, eliminating the need for backend setup. With a grasp of its row-level security model and avoiding common auth pitfalls, you can ship a real-time feature in under two hours.

teal LED panel

Who this is for: Solo founders creating SaaS products, internal tools, or multiplayer features needing real-time sync without handling WebSocket infrastructure or deploying a separate backend. If Firebase's lack of SQL bothers you and you want to avoid vendor lock-in, this is the stack for you.

Why Supabase Real-Time Beats Rolling Your Own

Most indie hackers spend weeks building WebSocket servers, managing connections, and debugging race conditions. Supabase offers PostgreSQL's LISTEN/NOTIFY wrapped in a JavaScript client that automatically handles reconnection, presence, and broadcast channels.

The real-time engine leverages Phoenix Channels under the hood (as per Supabase's official architecture docs, 2024). Subscribing to database changes at the table or row level, Supabase streams inserts, updates, and deletes to your client in milliseconds. No polling needed. No custom API endpoints necessary.

Here's what you get:

  • Database subscriptions: Listen to INSERT, UPDATE, DELETE on any table.
  • Presence: Track who's online in a room or document.
  • Broadcast: Send ephemeral messages (e.g., cursor positions, typing indicators).
  • Edge functions: Deploy serverless TypeScript functions globally.

The catch: Row-level security (RLS) policies dictate what each user can subscribe to. Misconfigure RLS, and your real-time subscriptions silently fail. The issue almost always traces back to RLS.

Set Up Your Supabase Project and Enable Real-Time

real text on white wall

Create a new project at supabase.com. In under 60 seconds, receive a PostgreSQL database, REST API, and real-time server. Choose a region near your users—latency is crucial for real-time.

Install the JavaScript client:

npm install @supabase/supabase-js

Initialize in your app:

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = 'https://your-project.supabase.co'
const supabaseKey = 'your-anon-key'
const supabase = createClient(supabaseUrl, supabaseKey)

Enable real-time on your table. By default, Supabase disables real-time replication to save resources. Head to Database → Replication in the dashboard, locate your table, and toggle real-time on. Skip this, and your subscriptions will connect but never receive events.

Create a simple messages table via SQL Editor:

CREATE TABLE messages (
  id BIGSERIAL PRIMARY KEY,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  user_id UUID REFERENCES auth.users(id),
  content TEXT NOT NULL,
  room_id TEXT NOT NULL
);

ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

Enable RLS right away. Never run a Supabase table in production without it.

Write Row-Level Security Policies That Don't Break

Here's the thing: most tutorials stop here, but your app will fail without proper RLS policies. Real-time subscriptions respect RLS policies. If a user can't SELECT a row, they won't receive real-time updates for it.

Create a policy letting users read messages in their room:

CREATE POLICY "Users can read messages in their rooms"
ON messages FOR SELECT
USING (auth.uid() IS NOT NULL);

For inserts:

CREATE POLICY "Users can insert their own messages"
ON messages FOR INSERT
WITH CHECK (auth.uid() = user_id);

Test this in the SQL Editor by running:

SELECT * FROM messages;

If logged out (no JWT), you'll receive zero rows, even if the table has data. Subscriptions behave the same way.

Common mistake: Creating a policy with TO authenticated but forgetting authenticated applies only to users with a valid session. Testing with the anon key and no login, subscriptions connect but never emit events. Use auth.uid() IS NOT NULL or explicitly set policies TO anon for public data.

Subscribe to Real-Time Database Changes

Now subscribe to inserts on the messages table:

const channel = supabase
  .channel('room-1')
  .on(
    'postgres_changes',
    { 
      event: 'INSERT', 
      schema: 'public', 
      table: 'messages',
      filter: 'room_id=eq.room-1'
    },
    (payload) => {
      console.log('New message:', payload.new)
      // Update your UI here
    }
  )
  .subscribe()

The filter parameter uses PostgREST syntax: column=operator.value. Filter by any column. This is server-side, so you aren't streaming every row and filtering client-side.

To unsubscribe:

supabase.removeChannel(channel)

Worth noting: Subscriptions don't replay missed events. If a user disconnects for 10 seconds and five messages are inserted, they won't see them upon reconnection. Fetch the latest rows on reconnect and merge with local state.

Here's the pattern used:

async function initializeRoom(roomId) {
  // Fetch existing messages
  const { data } = await supabase
    .from('messages')
    .select('*')
    .eq('room_id', roomId)
    .order('created_at', { ascending: true })

  setMessages(data)

  // Subscribe to new messages
  const channel = supabase
    .channel(roomId)
    .on(
      'postgres_changes',
      { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
      (payload) => {
        setMessages(prev => [...prev, payload.new])
      }
    )
    .subscribe()

  return () => supabase.removeChannel(channel)
}

Fetch first, then subscribe. Not the other way around.

Use Presence to Track Online Users

Presence syncs arbitrary JSON state across all clients in a channel. Perfect for "who's online" or "who's viewing this document."

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

channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    console.log('Online users:', state)
  })
  .on('presence', { event: 'join' }, ({ key, newPresences }) => {
    console.log('User joined:', newPresences)
  })
  .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
    console.log('User left:', leftPresences)
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await channel.track({ 
        user_id: user.id, 
        online_at: new Date().toISOString() 
      })
    }
  })

Call channel.track() after subscription is confirmed. The object passed is broadcasted to all subscribers. Supabase manages heartbeats and removes disconnected users.

Limitation: Presence state is temporary. If all users disconnect, the state resets. Use it only for transient UI state, not persistent data like cursor positions or typing indicators.

Ship a Real-Time Chat in Under 100 Lines

Here's a working React example that combines everything:

import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient('YOUR_URL', 'YOUR_ANON_KEY')

export default function Chat({ roomId, user }) {
  const [messages, setMessages] = useState([])
  const [newMessage, setNewMessage] = useState('')

  useEffect(() => {
    // Fetch existing messages
    supabase
      .from('messages')
      .select('*')
      .eq('room_id', roomId)
      .order('created_at', { ascending: true })
      .then(({ data }) => setMessages(data || []))

    // Subscribe to new messages
    const channel = supabase
      .channel(roomId)
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
        (payload) => setMessages(prev => [...prev, payload.new])
      )
      .subscribe()

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

  const sendMessage = async () => {
    if (!newMessage.trim()) return
    await supabase.from('messages').insert({
      content: newMessage,
      room_id: roomId,
      user_id: user.id
    })
    setNewMessage('')
  }

  return (
    <div>
      <div>
        {messages.map(msg => (
          <div key={msg.id}>{msg.content}</div>
        ))}
      </div>
      <input 
        value={newMessage} 
        onChange={e => setNewMessage(e.target.value)}
        onKeyPress={e => e.key === 'Enter' && sendMessage()}
      />
    </div>
  )
}

This handles fetching, subscribing, and inserting. The RLS policies ensure users only see permissible messages. No backend code. No WebSocket management.

What Nobody Tells You About Supabase Real-Time

1. Subscriptions don't retry failed inserts. If a client sends an INSERT that violates RLS, no error appears in the subscription callback. The insert silently fails. Always manage insert errors in your supabase.from().insert() call.

2. You're billed by connection minutes. Supabase counts active real-time connections. If 100 users connect for an hour, that's 100 connection-hours. The free tier includes 500,000 connection-minutes monthly (as per Supabase pricing, 2026), roughly 347 users connected 24/7. Beyond that, it's $0.00002 per minute. It scales with concurrency, not usage, but isn't costly.

3. Broadcast channels don't guarantee delivery. Using channel.send() for events like cursor movements might cause message drops under load. For critical events, write to the database and subscribe to changes.

4. Edge functions have cold starts. Functions run on Deno Deploy. Initial invocation after idle may take 200–500ms. Not an issue for async tasks, but unsuitable for latency-sensitive real-time logic. Keep that client-side or in database triggers.

5. Filters don't apply to presence or broadcast. The filter parameter works only for postgres_changes events. Subscribing to presence or broadcast, all events go to every client in the channel. Filter client-side.

Common Mistakes That Break Real-Time

Forgetting to enable replication. Subscriptions that connect but never fire often result from forgetting to enable replication. Always check Database → Replication in the dashboard.

Setting up RLS after you start testing. RLS policies evaluate at query time. Creating a subscription before enabling RLS might work. Enable RLS, and the subscription may stop receiving events. Drop and recreate the subscription after changing policies.

Not managing auth state changes. User login or logout changes their JWT, altering what they can SELECT. Subscriptions don't automatically update. Unsubscribe and resubscribe on auth state changes:

useEffect(() => {
  const { data: authListener } = supabase.auth.onAuthStateChange(() => {
    // Re-init subscriptions
  })
  return () => authListener.subscription.unsubscribe()
}, [])

Using INSERT events for presence. Don't write presence state to a table and subscribe to inserts. The Presence API suits ephemeral state and handles cleanup automatically.

FAQ

How many concurrent real-time connections can Supabase handle?

The free tier supports up to 200 concurrent connections. Paid plans start at 500 and scale to thousands based on your plan (per Supabase docs, 2026). Need more? Contact their enterprise team. For most indie hackers, 500 suffices to validate product-market fit.

Can Supabase real-time work with React Native or mobile apps?

Yes. The @supabase/supabase-js client functions in React Native, Expo, Flutter (via supabase-flutter), and Swift. WebSocket connections operate similarly. Ensure mobile apps handle backgrounding correctly—subscriptions disconnect when the app is backgrounded and must reconnect on resume.

Does Supabase real-time work with serverless functions or Next.js API routes?

Real-time subscriptions are strictly client-side. Subscribing to database changes from a serverless function isn't possible as the function ends post-response. Use database triggers or pg_notify for server-side real-time logic. With Next.js, subscribe from the browser, not API routes.

What if the connection limit is exceeded?

New connections will fail. Existing connections stay active. Expect client errors like channel timeout or subscription failed. Upgrade your plan or optimize connection usage—avoid creating new subscriptions per component; share one across your app.

Next Step: Build Your First Feature

Pick a real-time feature: live comments, notifications, or collaborative editing. Create a table, enable replication, write RLS policies, and subscribe from your frontend. Release it to a single user and monitor WebSocket traffic in the browser's network tab. Practical experience trumps countless tutorials.

Supabase real-time isn't magic—it's PostgreSQL replication via a well-designed API. Grasp RLS, test your policies, and you'll ship features in hours that once took weeks. For more insights on launching your SaaS, check out Why Your SaaS Launch Fails: Real Founder Lessons and explore innovative ideas with Best Micro-SaaS Ideas for Indie Hackers in 2026.


Editorial note: This article was produced with AI assistance and reviewed by Javier Valencia. Verified facts are distinguished from editorial opinion throughout the text. External sources linked are independent of NewsTide.

Sources

  1. teal LED panel
  2. Supabase's official architecture docs
  3. real text on white wall
  4. supabase.com
  5. Supabase pricing

More in Build & Launch

🇪🇸 Also available in Spanish: Leer en español

𝕏in