
supabase-patterns
by wendleyw
SKILL.md
name: supabase-patterns description: Use when working with Supabase database queries, RLS policies, Edge Functions, migrations, real-time subscriptions, or storage. Ensures best practices for backend operations and data security. allowed-tools: Read, Edit, Grep, Glob, Bash, mcp__plugin_context7_context7__resolve-library-id, mcp__plugin_context7_context7__query-docs
Supabase Patterns Skill
Overview
This skill provides guidance for working with Supabase in the project management system, covering database operations, RLS policies, Edge Functions, migrations, real-time subscriptions, and file storage.
Database Architecture
Core Tables
Location: Database schema (see migrations in supabase/migrations/)
-
users
id(uuid, primary key)email(text, unique)role(enum:admin,designer,client)full_name(text)created_at(timestamptz)
-
projects
id(uuid, primary key)title(text)description(text, nullable)status(enum:draft,in_progress,review,done,archived)client_id(uuid, foreign key → users)designer_id(uuid, foreign key → users, nullable)miro_board_id(text, nullable)created_at(timestamptz)updated_at(timestamptz)
-
deliverables
id(uuid, primary key)project_id(uuid, foreign key → projects)title(text)status(enum:pending,wip,review,approved)position(integer)miro_item_id(text, nullable)created_at(timestamptz)
-
project_updates
id(uuid, primary key)project_id(uuid, foreign key → projects)user_id(uuid, foreign key → users)type(text:status_change,comment,file_upload)payload(jsonb)created_at(timestamptz)
-
files
id(uuid, primary key)project_id(uuid, foreign key → projects, nullable)deliverable_id(uuid, foreign key → deliverables, nullable)file_name(text)file_path(text)file_size(bigint)mime_type(text)uploaded_by(uuid, foreign key → users)created_at(timestamptz)
-
audit_logs
id(uuid, primary key)user_id(uuid, foreign key → users)action(text)entity_type(text)entity_id(uuid)metadata(jsonb)created_at(timestamptz)
Row-Level Security (RLS)
Critical: RLS is Enabled on All Tables
All tables have RLS enabled. Policies enforce access control based on user role.
RLS Policy Patterns
Admin Role: Full Access
-- Admins can read all projects
CREATE POLICY "Admins can read all projects"
ON projects FOR SELECT
TO authenticated
USING (
(SELECT role FROM users WHERE id = auth.uid()) = 'admin'
);
-- Admins can update all projects
CREATE POLICY "Admins can update all projects"
ON projects FOR UPDATE
TO authenticated
USING (
(SELECT role FROM users WHERE id = auth.uid()) = 'admin'
);
Designer Role: Assigned Projects Only
-- Designers can read assigned projects
CREATE POLICY "Designers can read assigned projects"
ON projects FOR SELECT
TO authenticated
USING (
(SELECT role FROM users WHERE id = auth.uid()) = 'designer'
AND designer_id = auth.uid()
);
-- Designers can update assigned projects
CREATE POLICY "Designers can update assigned projects"
ON projects FOR UPDATE
TO authenticated
USING (
(SELECT role FROM users WHERE id = auth.uid()) = 'designer'
AND designer_id = auth.uid()
);
Client Role: Own Projects Only
-- Clients can read their own projects
CREATE POLICY "Clients can read own projects"
ON projects FOR SELECT
TO authenticated
USING (
(SELECT role FROM users WHERE id = auth.uid()) = 'client'
AND client_id = auth.uid()
);
-- Clients can NOT update projects (read-only)
-- No UPDATE policy for clients
Testing RLS Policies
// ✅ CORRECT: RLS is automatically enforced
const { data, error } = await supabase
.from('projects')
.select('*');
// Returns only projects user has access to based on role
// - Admin: All projects
// - Designer: Assigned projects
// - Client: Own projects
// ❌ WRONG: Don't try to manually filter by role
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('client_id', userId); // Don't do this, RLS handles it!
Bypassing RLS (Service Role Key)
Only use in Edge Functions, never in client code!
// Edge Function with service role key
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, // ⚠️ Bypasses RLS
);
// Admin client can access all data regardless of RLS
const { data } = await supabaseAdmin
.from('projects')
.select('*'); // Returns ALL projects
Supabase Client Initialization
Client-Side (Browser)
Location: src/shared/lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
);
// RLS is enforced
// Uses authenticated user's JWT token
Server-Side (Edge Functions)
Location: supabase/functions/_shared/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
// For RLS-enforced operations
export const createSupabaseClient = (authToken: string) => {
return createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: `Bearer ${authToken}` },
},
},
);
};
// For admin operations (bypasses RLS)
export const createSupabaseAdmin = () => {
return createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
};
Query Patterns
Pattern 1: Simple Select
const { data, error } = await supabase
.from('projects')
.select('*');
if (error) {
logger.error('Failed to fetch projects', { error });
return [];
}
return data ?? [];
Pattern 2: Select with Filters
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('status', 'in_progress')
.order('created_at', { ascending: false });
if (error) {
logger.error('Failed to fetch projects', { error });
return [];
}
return data ?? [];
Pattern 3: Select Single Record
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.single(); // ✅ Returns single object or null
if (error) {
logger.error('Project not found', { projectId, error });
return null;
}
return data;
Pattern 4: Select with Relations
const { data, error } = await supabase
.from('projects')
.select(`
*,
client:client_id (
id,
full_name,
email
),
designer:designer_id (
id,
full_name
),
deliverables (
id,
title,
status
)
`)
.eq('id', projectId)
.single();
if (error || !data) return null;
// data has nested relations
console.log(data.client.full_name);
console.log(data.deliverables.length);
Pattern 5: Insert
const { data, error } = await supabase
.from('projects')
.insert({
title: 'New Project',
description: 'Project description',
status: 'draft',
client_id: clientId,
})
.select()
.single();
if (error) {
logger.error('Failed to create project', { error });
throw new Error('Failed to create project');
}
return data;
Pattern 6: Update
const { data, error } = await supabase
.from('projects')
.update({
status: 'in_progress',
updated_at: new Date().toISOString(),
})
.eq('id', projectId)
.select()
.single();
if (error) {
logger.error('Failed to update project', { projectId, error });
return null;
}
return data;
Pattern 7: Delete
const { error } = await supabase
.from('projects')
.delete()
.eq('id', projectId);
if (error) {
logger.error('Failed to delete project', { projectId, error });
throw new Error('Failed to delete project');
}
// Successful deletion
logger.info('Project deleted', { projectId });
Pattern 8: Upsert
const { data, error } = await supabase
.from('deliverables')
.upsert({
id: deliverableId, // If exists, update; else insert
project_id: projectId,
title: 'Logo Design',
status: 'pending',
})
.select()
.single();
if (error) {
logger.error('Failed to upsert deliverable', { error });
return null;
}
return data;
Edge Functions
Location
supabase/functions/
Critical Edge Functions
-
miro-oauth-start
- Initiates Miro OAuth flow
- Generates state token
- Redirects to Miro authorization
-
miro-oauth-callback
- Handles OAuth callback
- Exchanges code for access token
- Stores token in user metadata
-
miro-webhook
- Processes Miro webhook events
- Validates webhook signature
- Syncs board changes to database
-
projects-create
- Server-side project creation logic
- Validation and business rules
- Audit logging
-
projects-update
- Server-side project update logic
- Status change notifications
- Audit logging
-
sync-worker
- Background synchronization worker
- Periodic sync between database and Miro
- Handles batch operations
Edge Function Structure
// supabase/functions/example/index.ts
import { serve } from 'std/http/server.ts';
import { createClient } from '@supabase/supabase-js';
import { corsHeaders } from '../_shared/cors.ts';
serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Get auth token from request
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(
JSON.stringify({ error: 'Missing authorization' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Create Supabase client with user context (RLS enforced)
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: authHeader },
},
},
);
// Parse request body
const body = await req.json();
// Business logic here
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('id', body.projectId)
.single();
if (error) {
throw error;
}
// Return response
return new Response(
JSON.stringify({ data }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});
Invoking Edge Functions from Client
// Client-side invocation
const { data, error } = await supabase.functions.invoke('projects-create', {
body: {
title: 'New Project',
clientId: 'client-uuid',
},
});
if (error) {
logger.error('Function invocation failed', { error });
throw new Error(error.message);
}
return data;
Testing Edge Functions Locally
# Start Supabase local development
supabase start
# Serve specific function
supabase functions serve projects-create --no-verify-jwt
# Test with curl
curl -i --location --request POST 'http://localhost:54321/functions/v1/projects-create' \
--header 'Authorization: Bearer YOUR_ANON_KEY' \
--header 'Content-Type: application/json' \
--data '{"title":"Test Project"}'
Real-time Subscriptions
Pattern 1: Subscribe to Table Changes
Location: src/shared/hooks/useRealtimeSubscription.ts
import { useRealtimeSubscription } from '@shared/hooks/useRealtimeSubscription';
function ProjectList() {
const { data, loading, error } = useRealtimeSubscription('projects', {
event: '*', // INSERT, UPDATE, DELETE
filter: `status=eq.in_progress`,
});
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{data.map(project => (
<li key={project.id}>{project.title}</li>
))}
</ul>
);
}
Pattern 2: Manual Subscription
import { useEffect, useState } from 'react';
import { supabase } from '@shared/lib/supabase';
import type { Project } from '@features/projects/domain/types';
function useProjectUpdates(projectId: string) {
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
// Initial fetch
const fetchProject = async () => {
const { data } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.single();
setProject(data);
};
fetchProject();
// Subscribe to changes
const channel = supabase
.channel(`project:${projectId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'projects',
filter: `id=eq.${projectId}`,
},
(payload) => {
setProject(payload.new as Project);
}
)
.subscribe();
// Cleanup
return () => {
supabase.removeChannel(channel);
};
}, [projectId]);
return project;
}
Pattern 3: Broadcast Channel (Custom Events)
// Sender
const channel = supabase.channel('project-updates');
channel.send({
type: 'broadcast',
event: 'project-status-changed',
payload: { projectId, status: 'in_progress' },
});
// Receiver
const channel = supabase.channel('project-updates');
channel.on('broadcast', { event: 'project-status-changed' }, (payload) => {
console.log('Project status changed:', payload);
// Update UI
});
channel.subscribe();
Storage
Bucket Structure
project-files- Project attachments (RLS enforced)deliverable-files- Deliverable files (RLS enforced)avatars- User profile pictures (public)
Upload Pattern
const uploadFile = async (file: File, projectId: string) => {
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}.${fileExt}`;
const filePath = `${projectId}/${fileName}`;
// Upload to storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from('project-files')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
logger.error('File upload failed', { uploadError });
throw new Error('Failed to upload file');
}
// Save metadata to database
const { data: fileRecord, error: dbError } = await supabase
.from('files')
.insert({
project_id: projectId,
file_name: file.name,
file_path: filePath,
file_size: file.size,
mime_type: file.type,
uploaded_by: (await supabase.auth.getUser()).data.user?.id,
})
.select()
.single();
if (dbError) {
// Cleanup uploaded file if DB insert fails
await supabase.storage.from('project-files').remove([filePath]);
throw new Error('Failed to save file metadata');
}
return fileRecord;
};
Download Pattern
const getPublicUrl = (filePath: string) => {
const { data } = supabase.storage
.from('project-files')
.getPublicUrl(filePath);
return data.publicUrl;
};
// Or download directly
const downloadFile = async (filePath: string) => {
const { data, error } = await supabase.storage
.from('project-files')
.download(filePath);
if (error) {
logger.error('File download failed', { error });
throw new Error('Failed to download file');
}
// Create blob URL
const url = URL.createObjectURL(data);
return url;
};
Delete Pattern
const deleteFile = async (fileId: string) => {
// Get file path from database
const { data: fileRecord, error: fetchError } = await supabase
.from('files')
.select('file_path')
.eq('id', fileId)
.single();
if (fetchError || !fileRecord) {
throw new Error('File not found');
}
// Delete from storage
const { error: storageError } = await supabase.storage
.from('project-files')
.remove([fileRecord.file_path]);
if (storageError) {
logger.error('Storage deletion failed', { storageError });
throw new Error('Failed to delete file from storage');
}
// Delete from database
const { error: dbError } = await supabase
.from('files')
.delete()
.eq('id', fileId);
if (dbError) {
logger.error('DB deletion failed', { dbError });
throw new Error('Failed to delete file record');
}
logger.info('File deleted successfully', { fileId });
};
Migrations
Location
supabase/migrations/
Creating a New Migration
# Create new migration file
supabase migration new add_column_to_projects
# File created: supabase/migrations/YYYYMMDDHHMMSS_add_column_to_projects.sql
Migration Template
-- Migration: Add google_drive_url to projects
-- Created: 2024-01-15
-- Add column
ALTER TABLE projects
ADD COLUMN google_drive_url TEXT;
-- Create index if needed
CREATE INDEX idx_projects_google_drive_url
ON projects(google_drive_url)
WHERE google_drive_url IS NOT NULL;
-- Update RLS policies if needed
-- (Existing policies apply to new column automatically)
-- Rollback (optional, for documentation)
-- ALTER TABLE projects DROP COLUMN google_drive_url;
Apply Migrations
# Apply locally
supabase migration up
# Reset and reapply all
supabase db reset
# Push to remote
supabase db push
TypeScript Type Generation After Migration
# Generate TypeScript types from database schema
supabase gen types typescript --local > src/shared/types/database.types.ts
Best Practices
1. Always Handle Errors
// ✅ CORRECT
const { data, error } = await supabase.from('projects').select('*');
if (error) {
logger.error('Query failed', { error });
return [];
}
return data ?? [];
// ❌ WRONG
const { data } = await supabase.from('projects').select('*');
return data; // Could be null!
2. Use .single() for Single Records
// ✅ CORRECT
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('id', id)
.single();
// ❌ WRONG
const { data } = await supabase
.from('projects')
.select('*')
.eq('id', id);
const project = data[0]; // noUncheckedIndexedAccess error!
3. Trust RLS, Don't Duplicate Filters
// ✅ CORRECT - RLS handles role filtering
const { data } = await supabase.from('projects').select('*');
// ❌ WRONG - Redundant filter
const user = await supabase.auth.getUser();
const { data } = await supabase
.from('projects')
.select('*')
.eq('client_id', user.id); // RLS already does this!
4. Use Transactions for Related Operations
// ✅ CORRECT - Atomic operation
const { data: project, error } = await supabase.rpc('create_project_with_deliverables', {
p_title: 'Project',
p_deliverables: ['Logo', 'Brand Guide'],
});
// ❌ WRONG - Separate operations can fail partially
const { data: project } = await supabase.from('projects').insert({ title: 'Project' });
await supabase.from('deliverables').insert({ project_id: project.id, title: 'Logo' });
await supabase.from('deliverables').insert({ project_id: project.id, title: 'Brand Guide' });
5. Log Audit Trail for Critical Actions
// After project update
await supabase.from('audit_logs').insert({
user_id: userId,
action: 'project_updated',
entity_type: 'project',
entity_id: projectId,
metadata: { old_status: oldStatus, new_status: newStatus },
});
Useful Context7 Queries
When working with Supabase:
"Supabase RLS policies for multi-tenant application"
"Supabase Edge Functions with Deno best practices"
"Supabase real-time subscriptions with React"
"Supabase storage upload with progress tracking"
"Supabase database transactions and error handling"
Quick Reference
// Import client
import { supabase } from '@shared/lib/supabase';
// Select
const { data, error } = await supabase.from('projects').select('*');
// Select single
const { data, error } = await supabase.from('projects').select('*').eq('id', id).single();
// Insert
const { data, error } = await supabase.from('projects').insert({ title: 'New' }).select().single();
// Update
const { data, error } = await supabase.from('projects').update({ status: 'done' }).eq('id', id).select().single();
// Delete
const { error } = await supabase.from('projects').delete().eq('id', id);
// Storage upload
const { data, error } = await supabase.storage.from('bucket').upload(path, file);
// Storage download
const { data, error } = await supabase.storage.from('bucket').download(path);
// Edge Function
const { data, error } = await supabase.functions.invoke('function-name', { body: {} });
// Realtime
const channel = supabase.channel('channel-name').on('postgres_changes', { ... }, callback).subscribe();
Remember: RLS is your friend. Trust it, test it, and don't duplicate its logic in application code.
スコア
総合スコア
リポジトリの品質指標に基づく評価
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
レビュー
レビュー機能は近日公開予定です