Supabase Realtime: Guide to Building Live Applications
Master Supabase Realtime: Postgres Changes, Presence, Broadcast, and building real-time features like chat, notifications, collaborative editing.
Supabase Realtime enables you to build live, collaborative applications with minimal code. This guide covers everything from basic subscriptions to advanced patterns.
What is Supabase Realtime?#
Supabase Realtime is built on top of PostgreSQL's replication functionality and provides three main features, as laid out in Supabase's own Realtime documentation:
- Postgres Changes - Listen to database changes (INSERT, UPDATE, DELETE)
- Presence - Track and sync user state across clients
- Broadcast - Send ephemeral messages between clients
Architecture Overview#
Client → Supabase Realtime Server → PostgreSQL
↓ ↓
WebSocket Replication Slot
↓ ↓
Live Updates ← ← ← ← ← ← ← ← ← ← ← ← ← ← ←Setup and Configuration#
1. Enable Realtime on Tables#
Adding the table to the supabase_realtime publication is the mandatory step — without it, no Postgres Changes events are emitted, regardless of RLS or client code:
-- Required: register the table with the realtime publication
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
-- Or in Supabase Dashboard:
-- Publications settings → under supabase_realtime, toggle the table onREPLICA IDENTITY FULL is a separate, optional step — only needed if you want the old record on UPDATE/DELETE payloads or need to filter delete events:
-- Optional: include full old row data on UPDATE/DELETE payloads
ALTER TABLE messages REPLICA IDENTITY FULL;2. Row Level Security (RLS)#
-- Enable RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Policy for reading messages
CREATE POLICY "Users can read messages in their channels"
ON messages FOR SELECT
USING (
auth.uid() IN (
SELECT user_id FROM channel_members
WHERE channel_id = messages.channel_id
)
);
-- Policy for inserting messages
CREATE POLICY "Users can insert messages in their channels"
ON messages FOR INSERT
WITH CHECK (
auth.uid() IN (
SELECT user_id FROM channel_members
WHERE channel_id = messages.channel_id
)
);Postgres Changes: Database Subscriptions#
Basic Subscription#
// @supabase/auth-helpers-nextjs is archived — use @supabase/ssr instead. createBrowserClient is for Client Components.
import { createBrowserClient } from '@supabase/ssr';
import { useEffect, useState } from 'react';
export default function Messages() {
const [messages, setMessages] = useState([]);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
useEffect(() => {
// Fetch initial data
const fetchMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*')
.order('created_at', { ascending: true });
setMessages(data || []);
};
fetchMessages();
// Subscribe to new messages
const channel = supabase
.channel('messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages'
},
(payload) => {
setMessages(prev => [...prev, payload.new]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase]);
return (
<div>
{messages.map(msg => (
<div key={msg.id}>{msg.content}</div>
))}
</div>
);
}Listen to All Events#
const channel = supabase
.channel('all-changes')
.on(
'postgres_changes',
{
event: '*', // INSERT, UPDATE, DELETE
schema: 'public',
table: 'messages'
},
(payload) => {
console.log('Change received!', payload);
switch (payload.eventType) {
case 'INSERT':
setMessages(prev => [...prev, payload.new]);
break;
case 'UPDATE':
setMessages(prev =>
prev.map(msg =>
msg.id === payload.new.id ? payload.new : msg
)
);
break;
case 'DELETE':
setMessages(prev =>
prev.filter(msg => msg.id !== payload.old.id)
);
break;
}
}
)
.subscribe();Filter by Column Value#
// Only listen to messages in a specific channel
const channel = supabase
.channel('channel-messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}` // Filter by channel
},
(payload) => {
setMessages(prev => [...prev, payload.new]);
}
)
.subscribe();Presence: Track Online Users#
Per Supabase's Presence documentation, track() broadcasts arbitrary JSON state to every other client on the same channel and presenceState() returns the merged state of every client currently connected to the channel. Each client's payload is stored under a presence key: a server-generated UUID by default, or the custom key you pass in the channel config, as below.
Basic Presence Implementation#
'use client';
import { useEffect, useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';
export default function OnlineUsers({ roomId, currentUser }) {
const [onlineUsers, setOnlineUsers] = useState([]);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`, {
config: {
presence: {
key: currentUser.id
}
}
});
// Track presence
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const users = Object.values(state).flat();
setOnlineUsers(users);
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
console.log('User joined:', newPresences);
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
console.log('User left:', leftPresences);
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// Track current user
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
avatar: currentUser.avatar,
online_at: new Date().toISOString()
});
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [roomId, currentUser, supabase]);
return (
<div>
<h3>Online Users ({onlineUsers.length})</h3>
<ul>
{onlineUsers.map(user => (
<li key={user.user_id}>
<img src={user.avatar} alt={user.username} />
{user.username}
</li>
))}
</ul>
</div>
);
}Typing Indicators#
import { useEffect, useState, useRef } from 'react';
export default function ChatInput({ channelId, currentUser }) {
const [typingUsers, setTypingUsers] = useState([]);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
// Presence.track() only works after the channel reaches SUBSCRIBED — it
// cannot be called on a freshly created, unjoined channel instance. Keep a
// ref to the one channel that was actually subscribed in the effect below.
const channelRef = useRef(null);
const typingTimeoutRef = useRef(null);
useEffect(() => {
const channel = supabase.channel(`typing:${channelId}`);
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const typing = Object.values(state)
.flat()
.filter(user => user.user_id !== currentUser.id);
setTypingUsers(typing);
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
channelRef.current = channel;
}
});
return () => {
channelRef.current = null;
supabase.removeChannel(channel);
};
}, [channelId, currentUser, supabase]);
const handleTyping = async () => {
const channel = channelRef.current;
if (!channel) return; // not SUBSCRIBED yet — skip this keystroke
// Track typing
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
typing: true
});
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Stop tracking after 3 seconds
typingTimeoutRef.current = setTimeout(async () => {
await channel.untrack();
}, 3000);
};
return (
<div>
{typingUsers.length > 0 && (
<div className="text-sm text-gray-500">
{typingUsers.map(u => u.username).join(', ')}
{typingUsers.length === 1 ? ' is' : ' are'} typing...
</div>
)}
<input
type="text"
onChange={handleTyping}
placeholder="Type a message..."
/>
</div>
);
}Broadcast: Send Ephemeral Messages#
Per Supabase's Broadcast documentation, Broadcast sends low-latency messages between connected clients through the Realtime server without a write to your tables, which suits per-frame data like cursor positions. The exception is Broadcast from the Database: realtime.send() inserts into the realtime.messages table, where messages are kept for three days.
Cursor Tracking#
export default function CollaborativeCanvas({ documentId, currentUser }) {
const [cursors, setCursors] = useState({});
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
useEffect(() => {
const channel = supabase.channel(`canvas:${documentId}`);
channel
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
setCursors(prev => ({
...prev,
[payload.user_id]: payload
}));
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [documentId, supabase]);
const handleMouseMove = async (e) => {
const channel = supabase.channel(`canvas:${documentId}`);
await channel.send({
type: 'broadcast',
event: 'cursor',
payload: {
user_id: currentUser.id,
x: e.clientX,
y: e.clientY,
username: currentUser.username
}
});
};
return (
<div onMouseMove={handleMouseMove}>
{/* Render other users' cursors */}
{Object.values(cursors).map(cursor => (
<div
key={cursor.user_id}
style={{
position: 'absolute',
left: cursor.x,
top: cursor.y,
pointerEvents: 'none'
}}
>
<div className="cursor-pointer">
{cursor.username}
</div>
</div>
))}
{/* Your canvas content */}
</div>
);
}Live Reactions#
export default function LiveReactions({ postId, currentUser }) {
const [reactions, setReactions] = useState([]);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
useEffect(() => {
const channel = supabase.channel(`reactions:${postId}`);
channel
.on('broadcast', { event: 'reaction' }, ({ payload }) => {
// Add reaction with animation
const id = Math.random();
setReactions(prev => [...prev, { ...payload, id }]);
// Remove after animation
setTimeout(() => {
setReactions(prev => prev.filter(r => r.id !== id));
}, 3000);
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [postId, supabase]);
const sendReaction = async (emoji) => {
const channel = supabase.channel(`reactions:${postId}`);
await channel.send({
type: 'broadcast',
event: 'reaction',
payload: {
emoji,
user_id: currentUser.id,
timestamp: Date.now()
}
});
};
return (
<div>
<div className="reaction-buttons">
{['❤️', '👍', '🎉', '🔥'].map(emoji => (
<button key={emoji} onClick={() => sendReaction(emoji)}>
{emoji}
</button>
))}
</div>
<div className="reactions-overlay">
{reactions.map(reaction => (
<div
key={reaction.id}
className="floating-reaction"
style={{
left: `${Math.random() * 100}%`,
animation: 'float-up 3s ease-out'
}}
>
{reaction.emoji}
</div>
))}
</div>
</div>
);
}Real-World Patterns#
1. Chat Application#
// components/Chat.tsx
'use client';
import { useEffect, useState, useRef } from 'react';
import { createBrowserClient } from '@supabase/ssr';
interface Message {
id: string;
content: string;
user_id: string;
username: string;
created_at: string;
}
export default function Chat({ channelId, currentUser }) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const [onlineUsers, setOnlineUsers] = useState([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
useEffect(() => {
// Fetch initial messages
const fetchMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*')
.eq('channel_id', channelId)
.order('created_at', { ascending: true })
.limit(50);
setMessages(data || []);
};
fetchMessages();
// Setup realtime channel
const channel = supabase.channel(`chat:${channelId}`, {
config: {
presence: { key: currentUser.id }
}
});
// Listen to new messages
channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`
},
(payload) => {
setMessages(prev => [...prev, payload.new as Message]);
scrollToBottom();
}
)
// Track online users
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
setOnlineUsers(Object.values(state).flat());
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
online_at: new Date().toISOString()
});
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [channelId, currentUser, supabase]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
await supabase.from('messages').insert({
content: newMessage,
channel_id: channelId,
user_id: currentUser.id,
username: currentUser.username
});
setNewMessage('');
};
return (
<div className="flex flex-col h-screen">
{/* Online users */}
<div className="p-4 border-b">
<span className="text-sm text-gray-600">
{onlineUsers.length} online
</span>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{messages.map(msg => (
<div
key={msg.id}
className={`mb-4 ${
msg.user_id === currentUser.id ? 'text-right' : ''
}`}
>
<div className="text-sm text-gray-600">{msg.username}</div>
<div
className={`inline-block p-3 rounded-lg ${
msg.user_id === currentUser.id
? 'bg-blue-500 text-white'
: 'bg-gray-200'
}`}
>
{msg.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={sendMessage} className="p-4 border-t">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type a message..."
className="w-full p-2 border rounded"
/>
</form>
</div>
);
}2. Live Notifications#
// components/NotificationBell.tsx
'use client';
import { useEffect, useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';
import { Bell } from 'lucide-react';
export default function NotificationBell({ userId }) {
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
useEffect(() => {
// Fetch initial unread count
const fetchUnreadCount = async () => {
const { count } = await supabase
.from('notifications')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('read', false);
setUnreadCount(count || 0);
};
fetchUnreadCount();
// Subscribe to new notifications
const channel = supabase
.channel('notifications')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${userId}`
},
(payload) => {
setUnreadCount(prev => prev + 1);
setNotifications(prev => [payload.new, ...prev]);
// Show browser notification
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(payload.new.title, {
body: payload.new.message,
icon: '/icon.png'
});
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId, supabase]);
const markAsRead = async (notificationId) => {
await supabase
.from('notifications')
.update({ read: true })
.eq('id', notificationId);
setUnreadCount(prev => Math.max(0, prev - 1));
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2"
>
<Bell className="w-6 h-6" />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border">
<div className="p-4 border-b">
<h3 className="font-semibold">Notifications</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.map(notif => (
<div
key={notif.id}
className={`p-4 border-b hover:bg-gray-50 cursor-pointer ${
!notif.read ? 'bg-blue-50' : ''
}`}
onClick={() => markAsRead(notif.id)}
>
<div className="font-medium">{notif.title}</div>
<div className="text-sm text-gray-600">{notif.message}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}3. Collaborative Document Editing#
// components/CollaborativeEditor.tsx
'use client';
import { useEffect, useState, useRef } from 'react';
import { createBrowserClient } from '@supabase/ssr';
export default function CollaborativeEditor({ documentId, currentUser }) {
const [content, setContent] = useState('');
const [cursors, setCursors] = useState({});
const [selections, setSelections] = useState({});
const editorRef = useRef<HTMLTextAreaElement>(null);
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
useEffect(() => {
// Fetch document
const fetchDocument = async () => {
const { data } = await supabase
.from('documents')
.select('content')
.eq('id', documentId)
.single();
setContent(data?.content || '');
};
fetchDocument();
const channel = supabase.channel(`doc:${documentId}`, {
config: {
presence: { key: currentUser.id }
}
});
channel
// Listen to content changes
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'documents',
filter: `id=eq.${documentId}`
},
(payload) => {
setContent(payload.new.content);
}
)
// Track user presence
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const users = Object.values(state).flat();
const newCursors = {};
const newSelections = {};
users.forEach(user => {
if (user.user_id !== currentUser.id) {
newCursors[user.user_id] = user.cursor;
newSelections[user.user_id] = user.selection;
}
});
setCursors(newCursors);
setSelections(newSelections);
})
// Listen to cursor movements
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
if (payload.user_id !== currentUser.id) {
setCursors(prev => ({
...prev,
[payload.user_id]: payload.position
}));
}
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
color: currentUser.color
});
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [documentId, currentUser, supabase]);
const handleChange = async (e) => {
const newContent = e.target.value;
setContent(newContent);
// Debounced save
clearTimeout(window.saveTimeout);
window.saveTimeout = setTimeout(async () => {
await supabase
.from('documents')
.update({ content: newContent })
.eq('id', documentId);
}, 1000);
};
const handleSelectionChange = async () => {
const channel = supabase.channel(`doc:${documentId}`);
const start = editorRef.current?.selectionStart;
const end = editorRef.current?.selectionEnd;
await channel.send({
type: 'broadcast',
event: 'cursor',
payload: {
user_id: currentUser.id,
position: start,
selection: { start, end }
}
});
};
return (
<div className="relative">
<textarea
ref={editorRef}
value={content}
onChange={handleChange}
onSelect={handleSelectionChange}
className="w-full h-screen p-4 font-mono"
/>
{/* Show other users' cursors */}
{Object.entries(cursors).map(([userId, position]) => (
<div
key={userId}
className="absolute w-0.5 h-6 bg-blue-500"
style={{
top: `${Math.floor(position / 80) * 24}px`,
left: `${(position % 80) * 9}px`
}}
/>
))}
</div>
);
}Conflict Resolution#
Two Realtime clients writing to the same row at nearly the same time will race. Pick a strategy based on how much correctness you need:
Last Write Wins#
The simplest approach — whichever update reaches Postgres last overwrites the previous value. Fine for low-contention data (a user profile, a draft note) where losing a rare concurrent edit is an acceptable tradeoff:
async function saveDocument(documentId: string, content: string) {
const { data, error } = await supabase
.from('documents')
.update({
content,
updated_at: new Date().toISOString(),
})
.eq('id', documentId);
return { data, error };
}Optimistic Locking#
For anything where silently losing an edit is a real problem (collaborative documents, inventory counts), add a version column and make the update conditional on the version you read. If another writer already bumped the version, the UPDATE matches zero rows and you know there was a conflict:
async function saveDocumentWithLocking(
documentId: string,
content: string,
currentVersion: number
) {
const { data, error } = await supabase
.from('documents')
.update({
content,
version: currentVersion + 1,
updated_at: new Date().toISOString(),
})
.eq('id', documentId)
.eq('version', currentVersion) // only updates if version still matches
.select();
if (error || !data || data.length === 0) {
// Version conflict — someone else wrote first. Reload and let the
// caller decide how to merge or re-apply the edit.
const { data: latest } = await supabase
.from('documents')
.select('*')
.eq('id', documentId)
.single();
return { conflict: true, latest };
}
return { data, conflict: false };
}For true multi-cursor collaborative editing (multiple people typing in the same paragraph simultaneously), optimistic locking is not enough — that needs an Operational Transformation or CRDT library (e.g. Yjs) layered on top of Realtime as the transport.
Performance Optimization#
1. Channel Multiplexing#
// Reuse the same channel for multiple subscriptions
const channel = supabase.channel(`room:${roomId}`);
channel
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, handleMessage)
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'reactions' }, handleReaction)
.on('presence', { event: 'sync' }, handlePresence)
.on('broadcast', { event: 'cursor' }, handleCursor)
.subscribe();2. Throttling Updates#
import { throttle } from 'lodash';
const sendCursorUpdate = throttle(async (x, y) => {
await channel.send({
type: 'broadcast',
event: 'cursor',
payload: { x, y }
});
}, 100); // Max 10 updates per second3. Cleanup and Memory Management#
useEffect(() => {
const channel = supabase.channel(`chat:${channelId}`);
// ... setup subscriptions
return () => {
// Always cleanup
channel.untrack();
supabase.removeChannel(channel);
};
}, [channelId]);Troubleshooting#
Common Issues#
-
Not receiving updates
- Check RLS policies
- Verify the table is in the
supabase_realtimepublication - Ensure channel is subscribed
-
Too many connections
- Reuse channels when possible
- Implement connection pooling
- Use channel multiplexing
-
Delayed updates
- Check network latency
- Optimize database queries
- Use broadcast for ephemeral data
Best Practices#
- Always cleanup subscriptions in useEffect return
- Use RLS policies to secure realtime data
- Throttle frequent updates (cursor movements, typing)
- Handle reconnection gracefully
- Test with multiple clients to catch race conditions
- Monitor channel status and show connection state to users
Conclusion#
Supabase Realtime makes building live, collaborative features straightforward. Key takeaways:
- Use Postgres Changes for database updates
- Use Presence for user state tracking
- Use Broadcast for ephemeral messages
- Always implement proper RLS policies
- Clean up subscriptions to prevent memory leaks
Last updated: 21 October 2026
Further reading: Next.js + Supabase: 10 Production Mistakes + Fixes (2026), Supabase Multi-Tenant Architecture: Best Practices 2026.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Related Guides
Supabase Multi-Tenant Architecture: Best Practices 2026
One codebase, isolated tenants: RLS policies, subdomain routing, and billing for a multi-tenant SaaS on Next.js + Supabase — with schema and code.
Next.js + Supabase Background Jobs & Async Patterns
Background jobs and async patterns for Next.js + Supabase: database queues, pg_cron, and Edge Functions — no external services.
Supabase Edge Functions in Next.js: Setup to Cron (2026)
From supabase functions new to production: Deno runtime gotchas, built-in SUPABASE_URL and SERVICE_ROLE_KEY env vars, webhook triggers and cron jobs.

