13 KiB
13 KiB
| name | description | parent-skill | version | updated |
|---|---|---|---|---|
| supabase-examples | Full-stack templates and working examples for Supabase applications | moai-platform-supabase | 1.0.0 | 2026-01-06 |
Supabase Full-Stack Examples
Multi-Tenant SaaS Application
Database Schema
-- Organizations (tenants)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Organization members with roles
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, user_id)
);
-- Projects within organizations
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
owner_id UUID NOT NULL,
status TEXT DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policies
CREATE POLICY "org_member_select" ON organizations FOR SELECT
USING (id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
CREATE POLICY "org_admin_update" ON organizations FOR UPDATE
USING (id IN (SELECT organization_id FROM organization_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));
CREATE POLICY "member_view" ON organization_members FOR SELECT
USING (organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
CREATE POLICY "project_access" ON projects FOR ALL
USING (organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
-- Indexes
CREATE INDEX idx_org_members_user ON organization_members(user_id);
CREATE INDEX idx_org_members_org ON organization_members(organization_id);
CREATE INDEX idx_projects_org ON projects(organization_id);
TypeScript Service Layer
// services/organization-service.ts
import { supabase } from '@/lib/supabase/client'
import type { Database } from '@/types/database'
type Organization = Database['public']['Tables']['organizations']['Row']
type OrganizationMember = Database['public']['Tables']['organization_members']['Row']
export class OrganizationService {
async create(name: string, slug: string): Promise<Organization> {
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// Create organization
const { data: org, error: orgError } = await supabase
.from('organizations')
.insert({ name, slug })
.select()
.single()
if (orgError) throw orgError
// Add creator as owner
const { error: memberError } = await supabase
.from('organization_members')
.insert({
organization_id: org.id,
user_id: user.id,
role: 'owner'
})
if (memberError) {
// Rollback org creation
await supabase.from('organizations').delete().eq('id', org.id)
throw memberError
}
return org
}
async getMyOrganizations(): Promise<Organization[]> {
const { data, error } = await supabase
.from('organizations')
.select('*, organization_members!inner(role)')
.order('name')
if (error) throw error
return data
}
async getMembers(orgId: string): Promise<OrganizationMember[]> {
const { data, error } = await supabase
.from('organization_members')
.select('*, user:profiles(*)')
.eq('organization_id', orgId)
.order('joined_at')
if (error) throw error
return data
}
async inviteMember(orgId: string, email: string, role: string): Promise<void> {
const { error } = await supabase.functions.invoke('invite-member', {
body: { organizationId: orgId, email, role }
})
if (error) throw error
}
}
export const organizationService = new OrganizationService()
AI Document Search Application
Database Schema
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS vector;
-- Documents with embeddings
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
metadata JSONB DEFAULT '{}',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- HNSW index for fast similarity search
CREATE INDEX idx_documents_embedding ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Full-text search index
CREATE INDEX idx_documents_content_fts ON documents
USING gin(to_tsvector('english', content));
-- Enable RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "document_access" ON documents FOR ALL
USING (project_id IN (
SELECT p.id FROM projects p
JOIN organization_members om ON p.organization_id = om.organization_id
WHERE om.user_id = auth.uid()
));
-- Semantic search function
CREATE OR REPLACE FUNCTION search_documents(
p_project_id UUID,
p_query_embedding vector(1536),
p_match_threshold FLOAT DEFAULT 0.7,
p_match_count INT DEFAULT 10
) RETURNS TABLE (
id UUID,
title TEXT,
content TEXT,
similarity FLOAT
) LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY
SELECT d.id, d.title, d.content,
1 - (d.embedding <=> p_query_embedding) AS similarity
FROM documents d
WHERE d.project_id = p_project_id
AND 1 - (d.embedding <=> p_query_embedding) > p_match_threshold
ORDER BY d.embedding <=> p_query_embedding
LIMIT p_match_count;
END; $$;
Edge Function for Embeddings
// supabase/functions/generate-embedding/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { documentId, content } = await req.json()
// Generate embedding using OpenAI
const embeddingResponse = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'text-embedding-ada-002',
input: content.slice(0, 8000)
})
})
const embeddingData = await embeddingResponse.json()
const embedding = embeddingData.data[0].embedding
// Update document with embedding
const { error } = await supabase
.from('documents')
.update({ embedding })
.eq('id', documentId)
if (error) throw error
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})
React Search Component
// components/DocumentSearch.tsx
'use client'
import { useState, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
import { documentService } from '@/services/document-service'
interface SearchResult {
id: string
title: string
content: string
similarity: number
}
export function DocumentSearch({ projectId }: { projectId: string }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery.length < 3) {
setResults([])
return
}
async function search() {
setLoading(true)
try {
const data = await documentService.semanticSearch(projectId, debouncedQuery)
setResults(data)
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}
search()
}, [debouncedQuery, projectId])
return (
<div className="space-y-4">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documents..."
className="w-full px-4 py-2 border rounded-lg"
/>
{loading && <div>Searching...</div>}
<div className="space-y-2">
{results.map((result) => (
<div key={result.id} className="p-4 border rounded-lg">
<div className="flex justify-between">
<h3 className="font-medium">{result.title}</h3>
<span className="text-sm text-gray-500">
{(result.similarity * 100).toFixed(1)}% match
</span>
</div>
<p className="mt-2 text-gray-600 line-clamp-2">{result.content}</p>
</div>
))}
</div>
</div>
)
}
Real-Time Collaboration
Collaborative Editor with Presence
// components/CollaborativeEditor.tsx
'use client'
import { useEffect, useState, useCallback } from 'react'
import { supabase } from '@/lib/supabase/client'
interface User {
id: string
name: string
color: string
}
interface PresenceState {
user: User
cursor: { x: number; y: number } | null
selection: { start: number; end: number } | null
}
export function CollaborativeEditor({
documentId,
currentUser
}: {
documentId: string
currentUser: User
}) {
const [content, setContent] = useState('')
const [otherUsers, setOtherUsers] = useState<PresenceState[]>([])
const [channel, setChannel] = useState<ReturnType<typeof supabase.channel> | null>(null)
useEffect(() => {
const ch = supabase.channel(`doc:${documentId}`, {
config: { presence: { key: currentUser.id } }
})
ch.on('presence', { event: 'sync' }, () => {
const state = ch.presenceState<PresenceState>()
const users = Object.values(state)
.flat()
.filter((p) => p.user.id !== currentUser.id)
setOtherUsers(users)
})
ch.on('broadcast', { event: 'content-update' }, ({ payload }) => {
if (payload.userId !== currentUser.id) {
setContent(payload.content)
}
})
ch.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await ch.track({
user: currentUser,
cursor: null,
selection: null
})
}
})
setChannel(ch)
return () => {
supabase.removeChannel(ch)
}
}, [documentId, currentUser])
const handleContentChange = useCallback(
async (newContent: string) => {
setContent(newContent)
if (channel) {
await channel.send({
type: 'broadcast',
event: 'content-update',
payload: { userId: currentUser.id, content: newContent }
})
}
},
[channel, currentUser.id]
)
return (
<div className="relative">
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
className="w-full h-96 p-4 border rounded-lg"
/>
<div className="mt-4 flex gap-2">
<span className="text-sm text-gray-500">Active:</span>
{otherUsers.map((presence) => (
<span
key={presence.user.id}
className="px-2 py-1 text-xs rounded-full"
style={{ backgroundColor: presence.user.color + '20', color: presence.user.color }}
>
{presence.user.name}
</span>
))}
</div>
</div>
)
}
Project Structure Template
my-supabase-app/
├── supabase/
│ ├── functions/
│ │ ├── generate-embedding/
│ │ │ └── index.ts
│ │ └── invite-member/
│ │ └── index.ts
│ ├── migrations/
│ │ ├── 20240101000000_initial_schema.sql
│ │ └── 20240101000001_add_embeddings.sql
│ └── config.toml
├── src/
│ ├── lib/
│ │ └── supabase/
│ │ ├── client.ts
│ │ └── server.ts
│ ├── services/
│ │ ├── organization-service.ts
│ │ ├── project-service.ts
│ │ └── document-service.ts
│ ├── types/
│ │ └── database.ts
│ └── components/
│ ├── DocumentSearch.tsx
│ ├── CollaborativeEditor.tsx
│ └── FileUploader.tsx
├── .env.local
└── package.json
Environment Configuration
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Server-side only
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Edge Functions secrets (set via CLI)
# supabase secrets set OPENAI_API_KEY=sk-...