The Supabase plugin provides seamless integration with Supabase, including CRUD operations, real-time subscriptions, and automatic type safety from your database schema.
Installation
npm install @legendapp/state @supabase/supabase-js
Setup
import { createClient } from '@supabase/supabase-js'
import { configureSyncedSupabase } from '@legendapp/state/sync-plugins/supabase'
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
)
configureSyncedSupabase({
supabase,
// Optional global config
changesSince: 'last-sync',
persist: {
plugin: ObservablePersistIndexedDB
}
})
Usage
import { syncedSupabase } from '@legendapp/state/sync-plugins/supabase'
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
realtime: true,
as: 'object'
})
Configuration
Table or view name in your databasecollection: 'posts'
collection: 'public.posts'
Database schema nameschema: 'public'
schema: 'custom_schema'
select
(query) => PostgrestFilterBuilder
Custom select query with joins and filtersselect: (query) => query
.select('*, author:users(name, avatar)')
filter
(select, params) => PostgrestFilterBuilder
Additional filters applied to queriesfilter: (select) => select
.eq('status', 'published')
.order('created_at', { ascending: false })
actions
('create' | 'read' | 'update' | 'delete')[]
Allowed CRUD operationsactions: ['read'] // Read-only
actions: ['create', 'read', 'update'] // No delete
realtime
boolean | { schema?: string, filter?: string }
default:"false"
Enable real-time subscriptions// Enable basic realtime
realtime: true
// With custom filter
realtime: {
schema: 'public',
filter: 'author_id=eq.123'
}
Automatically convert dates to/from ISO strings
as
'object' | 'array' | 'Map'
default:"object"
How to structure the data
Field tracking creation time (for incremental sync)
Field tracking update time (for incremental sync)
changesSince
'all' | 'last-sync'
default:"all"
Query strategy
all: Fetch all data
last-sync: Only fetch items updated since last sync
Transform data between local and remote formats
Local persistence configuration
Examples
Basic Usage
import { syncedSupabase } from '@legendapp/state/sync-plugins/supabase'
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
as: 'object'
})
// Access data
const posts = posts$.get()
Object.values(posts).forEach(post => {
console.log(post.title)
})
With Type Safety
import { Database } from './database.types' // Generated from Supabase
type Post = Database['public']['Tables']['posts']['Row']
const posts$ = syncedSupabase<
typeof supabase,
'posts',
'public',
'object',
Post
>({
supabase,
collection: 'posts',
as: 'object'
})
// Fully typed
posts$['post-1'].title.set('New Title') // ✓
posts$['post-1'].invalid.set('value') // ✗ TypeScript error
Real-time Sync
import { observer } from '@legendapp/state/react'
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
realtime: true, // Enable real-time
as: 'array'
})
const PostList = observer(function PostList() {
const posts = posts$.get()
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
)
})
// Automatically updates when data changes in Supabase
CRUD Operations
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
as: 'object'
})
// Create
posts$['new-id'].set({
id: 'new-id',
title: 'New Post',
content: 'Content here'
})
// Update
posts$['post-1'].title.set('Updated Title')
// Delete
posts$['post-1'].delete()
// Changes sync automatically with Supabase
With Filtering
const publishedPosts$ = syncedSupabase({
supabase,
collection: 'posts',
filter: (select) => select
.eq('status', 'published')
.order('created_at', { ascending: false })
.limit(20),
as: 'array'
})
With Joins
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
select: (query) => query
.select(`
*,
author:users!author_id(
id,
name,
avatar_url
),
comments:comments(
id,
content,
user:users(name)
)
`),
as: 'object'
})
// Access joined data
const post = posts$['post-1'].get()
console.log(post.author.name)
console.log(post.comments.length)
Incremental Sync
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
changesSince: 'last-sync',
fieldCreatedAt: 'created_at',
fieldUpdatedAt: 'updated_at',
fieldDeleted: 'deleted',
persist: {
name: 'posts',
plugin: ObservablePersistIndexedDB,
retrySync: true
}
})
// First load: Fetches all posts
// Subsequent loads: Only fetches posts updated since last sync
Read-Only View
const stats$ = syncedSupabase({
supabase,
collection: 'post_stats', // Database view
actions: ['read'], // Read-only
as: 'array'
})
// Can't modify (throws error)
// stats$[0].views.set(100) // Error
Soft Deletes
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
fieldDeleted: 'deleted_at',
filter: (select) => select.is('deleted_at', null) // Only non-deleted
})
// Delete sets deleted_at timestamp
posts$['post-1'].delete()
// Updates: { id: 'post-1', deleted_at: '2024-03-01T10:00:00Z' }
Date Handling
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
stringifyDates: true, // Auto-convert dates
as: 'object'
})
// Dates are automatically converted
const post = posts$['post-1'].get()
console.log(post.created_at.toISOString()) // Date object
// When saving, automatically stringified
posts$['post-1'].published_at.set(new Date())
User-Specific Data
import { observable } from '@legendapp/state'
const userId$ = observable<string | null>(null)
const myPosts$ = syncedSupabase({
supabase,
collection: 'posts',
filter: (select) => {
const userId = userId$.get()
return userId ? select.eq('author_id', userId) : select.eq('id', -1)
},
waitFor: () => !!userId$.get(), // Wait for user ID
realtime: true
})
// Login
const { data: { user } } = await supabase.auth.signInWithPassword({
email: '[email protected]',
password: 'password'
})
userId$.set(user.id)
// Automatically fetches user's posts
With Persistence
import { ObservablePersistIndexedDB } from '@legendapp/state/persist-plugins/indexeddb'
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
realtime: true,
persist: {
name: 'posts',
plugin: ObservablePersistIndexedDB
}
})
// 1. Loads from IndexedDB (instant)
// 2. Syncs with Supabase in background
// 3. Subscribes to real-time updates
// 4. Saves changes to both IndexedDB and Supabase
Error Handling
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
onError: (error, { source, type, retry, revert }) => {
console.error(`Supabase ${source} error:`, error)
if (error.message.includes('Failed to fetch')) {
// Network error, will retry
return
}
if (source === 'create' && error.message.includes('duplicate')) {
showNotification('Item already exists')
revert?.() // Revert local changes
} else {
showNotification(`Failed to ${source}`)
}
}
})
Multi-Tenant
const tenantId$ = observable('tenant-123')
const data$ = syncedSupabase({
supabase,
collection: 'data',
filter: (select) => select.eq('tenant_id', tenantId$.get()),
realtime: {
filter: computed(() => `tenant_id=eq.${tenantId$.get()}`)
},
persist: {
name: computed(() => `data-${tenantId$.get()}`)
}
})
// Switch tenant - automatically refetches with new filter
tenantId$.set('tenant-456')
Custom Functions
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
list: async (params) => {
// Custom function instead of direct table access
const { data, error } = await supabase
.rpc('get_posts_with_stats', {
user_id: currentUser$.id.get()
})
if (error) throw error
return { data, error: null }
},
as: 'array'
})
Real-time Subscriptions
Supabase real-time automatically:
- Subscribes on first access
- Updates observable when data changes
- Handles INSERT, UPDATE, DELETE events
- Filters updates by
fieldUpdatedAt to avoid duplicates
- Unsubscribes when observable is no longer observed
const posts$ = syncedSupabase({
supabase,
collection: 'posts',
realtime: true,
fieldUpdatedAt: 'updated_at'
})
// Changes from other clients are automatically synced
// No manual subscription management needed
Row Level Security
Supabase RLS is automatically respected:
// User can only see their own posts (enforced by RLS)
const myPosts$ = syncedSupabase({
supabase,
collection: 'posts'
// No filter needed - RLS handles it
})
// Attempts to access other users' posts will be filtered by RLS
Best Practices
- Enable RLS: Use Supabase Row Level Security for access control
- Use incremental sync: Enable
changesSince: 'last-sync' for better performance
- Add persistence: Cache data locally for offline support
- Enable real-time: Use
realtime: true for live updates
- Handle errors: Implement
onError for production apps
- Use filters: Filter at database level for better performance
See Also