| name | supabase-auth-memory |
| description | Standardize authentication and persistent memory storage using Supabase PostgreSQL. Use when building SaaS apps that need user auth, cross-device sync, and conversation history. |
| license | MIT |
Supabase Auth + Memory System
Purpose
Implement user authentication and persistent AI conversation memory using Supabase as unified backend.
When to Use
- Need user authentication in SaaS app
- Want conversation history across devices
- Building multi-tenant AI applications
- Need SQL-based memory for agents
- Syncing state between localStorage and cloud
Quick Start
Installation
npm install @supabase/supabase-js
npm install zustand zustand/middleware
Environment Variables
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_KEY=your-service-key # Backend only
Database Schema
Core Tables
-- Conversations table
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
metadata JSONB DEFAULT '{}'::jsonb,
-- Indexes
CONSTRAINT conversations_user_id_fkey FOREIGN KEY (user_id)
REFERENCES auth.users(id) ON DELETE CASCADE
);
CREATE INDEX idx_conversations_user ON conversations(user_id);
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
-- Messages table
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
tool_used TEXT,
tool_result JSONB,
reasoning_details JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Indexes
CONSTRAINT messages_conversation_id_fkey FOREIGN KEY (conversation_id)
REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX idx_messages_conversation ON messages(conversation_id);
CREATE INDEX idx_messages_created ON messages(created_at DESC);
-- User preferences (config storage)
CREATE TABLE user_preferences (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Auto-update timestamp trigger
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER conversations_updated_at
BEFORE UPDATE ON conversations
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
Row Level Security (RLS) Policies
-- Enable RLS
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_preferences ENABLE ROW LEVEL SECURITY;
-- Conversations policies
CREATE POLICY "Users can view own conversations"
ON conversations FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can create own conversations"
ON conversations FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own conversations"
ON conversations FOR UPDATE
USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own conversations"
ON conversations FOR DELETE
USING (auth.uid() = user_id);
-- Messages policies
CREATE POLICY "Users can view own messages"
ON messages FOR SELECT
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can create messages"
ON messages FOR INSERT
WITH CHECK (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- User preferences policies
CREATE POLICY "Users can view own preferences"
ON user_preferences FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can upsert own preferences"
ON user_preferences FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own preferences"
ON user_preferences FOR UPDATE
USING (auth.uid() = user_id);
Frontend Integration
Supabase Client Setup
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// Get current user
export async function getCurrentUser() {
const { data: { user } } = await supabase.auth.getUser()
return user
}
Zustand Store with Supabase Sync
// stores/conversationStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { supabase } from '@/lib/supabase'
interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
tool_used?: string
created_at: string
}
interface ConversationStore {
// State
messages: Message[]
conversationId: string | null
isSyncing: boolean
// Local actions (optimistic UI)
addMessage: (message: Omit<Message, 'id' | 'created_at'>) => void
clearMessages: () => void
// Supabase sync actions
createConversation: (title: string) => Promise<string>
loadConversation: (conversationId: string) => Promise<void>
saveToSupabase: () => Promise<void>
syncWithSupabase: () => Promise<void>
}
export const useConversationStore = create<ConversationStore>()(
persist(
(set, get) => ({
// Initial state
messages: [],
conversationId: null,
isSyncing: false,
// Add message optimistically
addMessage: (message) => {
const newMessage: Message = {
...message,
id: crypto.randomUUID(),
created_at: new Date().toISOString()
}
set(state => ({
messages: [...state.messages, newMessage]
}))
// Auto-save to Supabase (non-blocking)
get().saveToSupabase()
},
// Clear messages
clearMessages: () => set({ messages: [], conversationId: null }),
// Create new conversation in Supabase
createConversation: async (title) => {
const user = await getCurrentUser()
if (!user) throw new Error('Not authenticated')
const { data, error } = await supabase
.from('conversations')
.insert({ user_id: user.id, title })
.select()
.single()
if (error) throw error
set({ conversationId: data.id })
return data.id
},
// Load conversation from Supabase
loadConversation: async (conversationId) => {
set({ isSyncing: true })
const { data, error } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true })
if (error) throw error
set({
messages: data || [],
conversationId,
isSyncing: false
})
},
// Save messages to Supabase
saveToSupabase: async () => {
const { messages, conversationId } = get()
if (!conversationId) return
set({ isSyncing: true })
// Find new messages (not yet in Supabase)
const newMessages = messages.filter(m => !m.id.startsWith('uuid'))
if (newMessages.length > 0) {
const { error } = await supabase
.from('messages')
.insert(
newMessages.map(m => ({
conversation_id: conversationId,
role: m.role,
content: m.content,
tool_used: m.tool_used
}))
)
if (error) console.error('Failed to save to Supabase:', error)
}
set({ isSyncing: false })
},
// Sync with Supabase (merge local + remote)
syncWithSupabase: async () => {
const { conversationId } = get()
if (!conversationId) return
await get().loadConversation(conversationId)
}
}),
{
name: 'conversation-storage',
storage: createJSONStorage(() => localStorage),
// Only persist specific fields
partialize: (state) => ({
messages: state.messages,
conversationId: state.conversationId
})
}
)
)
User Preferences Store
// stores/preferencesStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { supabase, getCurrentUser } from '@/lib/supabase'
interface Preferences {
theme: 'light' | 'dark'
temperature: number
model: string
[key: string]: any
}
interface PreferencesStore {
preferences: Preferences
updatePreferences: (updates: Partial<Preferences>) => void
syncWithSupabase: () => Promise<void>
}
export const usePreferencesStore = create<PreferencesStore>()(
persist(
(set, get) => ({
preferences: {
theme: 'dark',
temperature: 0.7,
model: 'openai/gpt-4o'
},
updatePreferences: (updates) => {
set(state => ({
preferences: { ...state.preferences, ...updates }
}))
// Auto-sync to Supabase
get().syncWithSupabase()
},
syncWithSupabase: async () => {
const user = await getCurrentUser()
if (!user) return
const { preferences } = get()
await supabase
.from('user_preferences')
.upsert({
user_id: user.id,
preferences
})
}
}),
{
name: 'user-preferences',
onRehydrateStorage: () => (state) => {
// Load from Supabase after localStorage rehydration
state?.syncWithSupabase()
}
}
)
)
Real-time Subscriptions
// hooks/useRealtimeMessages.ts
import { useEffect } from 'react'
import { supabase } from '@/lib/supabase'
import { useConversationStore } from '@/stores/conversationStore'
export function useRealtimeMessages(conversationId: string) {
const addMessage = useConversationStore(state => state.addMessage)
useEffect(() => {
const channel = supabase
.channel(`conversation:${conversationId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `conversation_id=eq.${conversationId}`
},
(payload) => {
addMessage(payload.new as any)
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [conversationId, addMessage])
}
Authentication Patterns
Sign Up
async function signUp(email: string, password: string) {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) throw error
return data
}
Sign In
async function signIn(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
})
if (error) throw error
return data
}
OAuth (Google, GitHub, etc.)
async function signInWithOAuth(provider: 'google' | 'github') {
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) throw error
return data
}
Supabase MCP Integration
Setup (.mcp.json)
{
"mcpServers": {
"supabase": {
"url": "https://mcp.supabase.com/mcp?project_ref=YOUR_PROJECT_REF",
"transport": "streamable-http",
"auth": {
"type": "oauth"
}
}
}
}
Available MCP Tools
- Create/manage projects
- Design tables & migrations
- Query data with SQL
- Generate TypeScript types
- Manage configurations
Best Practices
Security First
- Always enable RLS on all tables
- Use service key only on server
- Validate user ownership in policies
Hybrid Storage
- localStorage for offline/instant UX
- Supabase for cross-device sync
- Background sync with debouncing
Optimistic UI
- Update UI immediately
- Sync to Supabase in background
- Handle conflicts gracefully
Real-time Updates
- Use subscriptions for collaboration
- Clean up subscriptions on unmount
- Debounce rapid updates
Error Handling
- Retry failed syncs
- Show sync status to user
- Fallback to localStorage
Performance
- Index frequently queried columns
- Use JSONB for flexible metadata
- Implement pagination for large datasets
Common Patterns
Multi-tenant SaaS
-- Add tenant isolation
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE organization_members (
organization_id UUID REFERENCES organizations(id),
user_id UUID REFERENCES auth.users(id),
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
PRIMARY KEY (organization_id, user_id)
);
-- Update RLS policies for organization isolation
CREATE POLICY "Organization members can view conversations"
ON conversations FOR SELECT
USING (
user_id IN (
SELECT user_id FROM organization_members
WHERE organization_id = (
SELECT organization_id FROM organization_members
WHERE user_id = auth.uid()
)
)
);