Claude Code Plugins

Community-maintained marketplace

Feedback

Supabase React Best Practices

@amo-tech-ai/event-studio
0
0

Comprehensive guide for building production-ready React applications with Supabase, TypeScript, and TanStack Query. Use when implementing auth, data fetching, real-time features, or optimizing Supabase integration.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name Supabase React Best Practices
description Comprehensive guide for building production-ready React applications with Supabase, TypeScript, and TanStack Query. Use when implementing auth, data fetching, real-time features, or optimizing Supabase integration.

Supabase + React Best Practices

Expert guide for building scalable, performant React applications with Supabase.

When to Use This Skill

  • Setting up new Supabase + React project
  • Implementing authentication flows
  • Creating data fetching hooks with TanStack Query
  • Adding real-time subscriptions
  • Optimizing database queries
  • Setting up TypeScript types
  • Debugging Supabase integration issues

Quick Start Checklist

// ✅ Essential Setup
- [ ] Install: @supabase/supabase-js @tanstack/react-query
- [ ] Generate TypeScript types from schema
- [ ] Create typed Supabase client
- [ ] Set up TanStack Query provider
- [ ] Configure environment variables
- [ ] Set up auth context/provider

1. Project Structure (Best Practice)

src/
├── integrations/
│   └── supabase/
│       ├── client.ts          # Supabase client singleton
│       ├── types.ts           # Generated DB types
│       └── auth.tsx           # Auth context provider
├── features/
│   └── [feature]/
│       ├── hooks/
│       │   ├── use[Feature].ts       # Query hooks
│       │   └── use[Feature]Mutations.ts  # Mutation hooks
│       ├── components/
│       └── types.ts
└── lib/
    └── queryClient.ts         # TanStack Query config

Why This Structure:

  • Clear separation of concerns
  • Reusable hooks across features
  • Type safety throughout
  • Easy to test and maintain

2. TypeScript Setup (CRITICAL)

Generate Types from Database

# Generate types (run after every migration)
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/integrations/supabase/types.ts

# Or use local
npx supabase gen types typescript --local > src/integrations/supabase/types.ts

Create Typed Client

File: src/integrations/supabase/client.ts

import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

if (!supabaseUrl || !supabaseAnonKey) {
  throw new Error('Missing Supabase environment variables');
}

// ✅ Typed Supabase client
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
  auth: {
    persistSession: true,
    autoRefreshToken: true,
    detectSessionInUrl: true
  }
});

// Export types for convenience
export type Tables<T extends keyof Database['public']['Tables']> =
  Database['public']['Tables'][T]['Row'];
export type Enums<T extends keyof Database['public']['Enums']> =
  Database['public']['Enums'][T];

Benefits:

  • Full IntelliSense for all tables
  • Type-safe queries
  • Catch errors at compile time
  • Better refactoring support

3. Authentication Patterns

Pattern 1: Auth Context Provider (Recommended)

File: src/integrations/supabase/auth.tsx

import { createContext, useContext, useEffect, useState } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from './client';

interface AuthContext {
  user: User | null;
  session: Session | null;
  loading: boolean;
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContext | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [session, setSession] = useState<Session | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setUser(session?.user ?? null);
      setLoading(false);
    });

    // Listen for auth changes
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session);
      setUser(session?.user ?? null);
      setLoading(false);
    });

    return () => subscription.unsubscribe();
  }, []);

  const signIn = async (email: string, password: string) => {
    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });
    if (error) throw error;
  };

  const signOut = async () => {
    const { error } = await supabase.auth.signOut();
    if (error) throw error;
  };

  return (
    <AuthContext.Provider value={{ user, session, loading, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

Usage:

// App.tsx
import { AuthProvider } from '@/integrations/supabase/auth';

function App() {
  return (
    <AuthProvider>
      <RouterProvider router={router} />
    </AuthProvider>
  );
}

// In components
function Profile() {
  const { user, loading } = useAuth();

  if (loading) return <Spinner />;
  if (!user) return <Navigate to="/login" />;

  return <div>Welcome {user.email}</div>;
}

Pattern 2: Protected Routes

import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '@/integrations/supabase/auth';

export function ProtectedRoute() {
  const { user, loading } = useAuth();

  if (loading) {
    return <div>Loading...</div>;
  }

  return user ? <Outlet /> : <Navigate to="/login" replace />;
}

// In router config
<Route element={<ProtectedRoute />}>
  <Route path="/dashboard" element={<Dashboard />} />
  <Route path="/profile" element={<Profile />} />
</Route>

4. Data Fetching with TanStack Query

Setup Query Client

File: src/lib/queryClient.ts

import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
});

Query Hooks Pattern

File: src/features/events/hooks/useEvents.ts

import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import type { Tables } from '@/integrations/supabase/client';

type Event = Tables<'events'>;

/**
 * Fetch all published events
 *
 * @example
 * const { data, isLoading, error } = useEvents();
 */
export function useEvents() {
  return useQuery({
    queryKey: ['events'],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('events')
        .select('*')
        .eq('status', 'published')
        .order('start_at', { ascending: true });

      if (error) throw error;
      return data as Event[];
    },
    staleTime: 5 * 60 * 1000,
  });
}

/**
 * Fetch single event by ID
 */
export function useEvent(id: string) {
  return useQuery({
    queryKey: ['event', id],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('events')
        .select('*, venue:venues(*)')
        .eq('id', id)
        .single();

      if (error) throw error;
      return data;
    },
    enabled: !!id, // Only run if ID provided
    staleTime: 5 * 60 * 1000,
  });
}

Mutation Hooks Pattern

File: src/features/events/hooks/useEventMutations.ts

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import type { Tables } from '@/integrations/supabase/client';

type EventInsert = Tables<'events'>['Insert'];
type EventUpdate = Tables<'events'>['Update'];

export function useCreateEvent() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (event: EventInsert) => {
      const { data, error } = await supabase
        .from('events')
        .insert(event)
        .select()
        .single();

      if (error) throw error;
      return data;
    },
    onSuccess: () => {
      // Invalidate queries to refetch
      queryClient.invalidateQueries({ queryKey: ['events'] });
    },
  });
}

export function useUpdateEvent() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, ...updates }: { id: string } & EventUpdate) => {
      const { data, error } = await supabase
        .from('events')
        .update(updates)
        .eq('id', id)
        .select()
        .single();

      if (error) throw error;
      return data;
    },
    onSuccess: (data) => {
      // Invalidate list and single item
      queryClient.invalidateQueries({ queryKey: ['events'] });
      queryClient.invalidateQueries({ queryKey: ['event', data.id] });
    },
  });
}

export function useDeleteEvent() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await supabase
        .from('events')
        .delete()
        .eq('id', id);

      if (error) throw error;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['events'] });
    },
  });
}

Usage in Components:

function EventForm() {
  const createEvent = useCreateEvent();
  const updateEvent = useUpdateEvent();

  const handleSubmit = async (values) => {
    try {
      if (editMode) {
        await updateEvent.mutateAsync({ id: eventId, ...values });
        toast.success('Event updated!');
      } else {
        await createEvent.mutateAsync(values);
        toast.success('Event created!');
      }
    } catch (error) {
      toast.error(error.message);
    }
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

5. Real-Time Subscriptions

Pattern: Real-Time Hook

import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';

export function useEventsSubscription() {
  const queryClient = useQueryClient();

  useEffect(() => {
    const channel = supabase
      .channel('events-changes')
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'events',
        },
        (payload) => {
          console.log('Event change:', payload);

          // Invalidate queries to refetch
          queryClient.invalidateQueries({ queryKey: ['events'] });

          // Or update cache directly (optimistic)
          if (payload.eventType === 'INSERT') {
            queryClient.setQueryData(['events'], (old: any[]) => [
              ...(old || []),
              payload.new,
            ]);
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [queryClient]);
}

// Usage
function EventsList() {
  const { data: events, isLoading } = useEvents();
  useEventsSubscription(); // Auto-refetch on changes

  return <div>{events?.map(...)}</div>;
}

6. Advanced Query Patterns

Filtering with Parameters

export function useEvents(filters?: {
  status?: string;
  type?: string;
  search?: string;
}) {
  return useQuery({
    queryKey: ['events', filters],
    queryFn: async () => {
      let query = supabase
        .from('events')
        .select('*');

      if (filters?.status) {
        query = query.eq('status', filters.status);
      }

      if (filters?.type) {
        query = query.eq('type', filters.type);
      }

      if (filters?.search) {
        query = query.ilike('name', `%${filters.search}%`);
      }

      const { data, error } = await query.order('start_at', { ascending: true });

      if (error) throw error;
      return data;
    },
    enabled: true,
  });
}

Pagination

export function useEventsPaginated(page: number, pageSize: number = 10) {
  return useQuery({
    queryKey: ['events', 'paginated', page, pageSize],
    queryFn: async () => {
      const from = page * pageSize;
      const to = from + pageSize - 1;

      const { data, error, count } = await supabase
        .from('events')
        .select('*', { count: 'exact' })
        .range(from, to)
        .order('start_at', { ascending: false });

      if (error) throw error;

      return {
        events: data,
        total: count || 0,
        totalPages: Math.ceil((count || 0) / pageSize),
      };
    },
  });
}

Complex Joins

export function useEventWithDetails(id: string) {
  return useQuery({
    queryKey: ['event', 'details', id],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('events')
        .select(`
          *,
          venue:venues(*),
          organizer:profiles(id, full_name, avatar_url),
          tickets:ticket_tiers(*),
          bookings:orders(
            id,
            status,
            total_amount,
            attendee:attendees(*)
          )
        `)
        .eq('id', id)
        .single();

      if (error) throw error;
      return data;
    },
    enabled: !!id,
  });
}

7. Row Level Security (RLS) Best Practices

Understanding RLS in React

Problem: Your React app uses the anon key, which has limited permissions.

Solution: RLS policies control what data users can access based on auth.uid().

Testing RLS Policies

// Helper to test if user can access data
export async function testRLS() {
  const { data: { user } } = await supabase.auth.getUser();

  console.log('Current user:', user?.id);

  // Test read access
  const { data, error } = await supabase
    .from('events')
    .select('*');

  if (error) {
    console.error('RLS blocking access:', error);
  } else {
    console.log('Accessible events:', data);
  }
}

Common RLS Patterns for React

-- Allow public read, authenticated insert
CREATE POLICY "Public events are viewable by everyone"
  ON events FOR SELECT
  USING (visibility = 'public');

CREATE POLICY "Users can insert their own events"
  ON events FOR INSERT
  WITH CHECK (auth.uid() = organizer_id);

CREATE POLICY "Users can update their own events"
  ON events FOR UPDATE
  USING (auth.uid() = organizer_id);

Handling RLS Errors in React

export function useEvents() {
  return useQuery({
    queryKey: ['events'],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('events')
        .select('*');

      // RLS will return error if blocked
      if (error) {
        if (error.code === 'PGRST301') {
          throw new Error('You do not have permission to view these events');
        }
        throw error;
      }

      return data;
    },
    retry: false, // Don't retry RLS errors
  });
}

8. Error Handling Patterns

Global Error Boundary

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { toast } from 'sonner';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error: any) => {
        // Handle RLS errors
        if (error.code === 'PGRST301') {
          toast.error('Access denied. Please check your permissions.');
          return;
        }

        // Handle auth errors
        if (error.message?.includes('JWT')) {
          toast.error('Session expired. Please log in again.');
          // Optionally redirect to login
          return;
        }

        // Generic error
        toast.error(error.message || 'Something went wrong');
      },
    },
    mutations: {
      onError: (error: any) => {
        toast.error(error.message || 'Failed to save changes');
      },
    },
  },
});

Component-Level Error Handling

function EventsList() {
  const { data, isLoading, error } = useEvents();

  if (isLoading) {
    return <Skeleton count={5} />;
  }

  if (error) {
    return (
      <Alert variant="destructive">
        <AlertTitle>Error loading events</AlertTitle>
        <AlertDescription>{error.message}</AlertDescription>
      </Alert>
    );
  }

  if (!data || data.length === 0) {
    return (
      <EmptyState
        title="No events yet"
        description="Get started by creating your first event"
        action={<Button>Create Event</Button>}
      />
    );
  }

  return <div>{data.map(...)}</div>;
}

9. Performance Optimization

1. Selective Field Fetching

// ❌ Bad: Fetching all fields
const { data } = await supabase.from('events').select('*');

// ✅ Good: Only fetch what you need
const { data } = await supabase
  .from('events')
  .select('id, name, start_at, price_cents');

2. Query Key Strategies

// Hierarchical query keys for better caching
const queryKeys = {
  all: ['events'] as const,
  lists: () => [...queryKeys.all, 'list'] as const,
  list: (filters: string) => [...queryKeys.lists(), filters] as const,
  details: () => [...queryKeys.all, 'detail'] as const,
  detail: (id: string) => [...queryKeys.details(), id] as const,
};

// Usage
useQuery({ queryKey: queryKeys.list('published') });
useQuery({ queryKey: queryKeys.detail(eventId) });

// Invalidate all events
queryClient.invalidateQueries({ queryKey: queryKeys.all });

// Invalidate only lists
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });

3. Optimistic Updates

export function useUpdateEvent() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, ...updates }) => {
      const { data, error } = await supabase
        .from('events')
        .update(updates)
        .eq('id', id)
        .select()
        .single();

      if (error) throw error;
      return data;
    },
    onMutate: async ({ id, ...updates }) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['event', id] });

      // Snapshot previous value
      const previousEvent = queryClient.getQueryData(['event', id]);

      // Optimistically update
      queryClient.setQueryData(['event', id], (old: any) => ({
        ...old,
        ...updates,
      }));

      return { previousEvent };
    },
    onError: (err, variables, context) => {
      // Rollback on error
      if (context?.previousEvent) {
        queryClient.setQueryData(
          ['event', variables.id],
          context.previousEvent
        );
      }
    },
    onSettled: (data, error, variables) => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['event', variables.id] });
    },
  });
}

4. Prefetching for Better UX

function EventCard({ event }) {
  const queryClient = useQueryClient();

  const prefetchEvent = () => {
    queryClient.prefetchQuery({
      queryKey: ['event', event.id],
      queryFn: () => fetchEventDetails(event.id),
    });
  };

  return (
    <Card onMouseEnter={prefetchEvent}>
      <Link to={`/events/${event.id}`}>{event.name}</Link>
    </Card>
  );
}

10. Security Best Practices

1. Never Expose Service Role Key

// ❌ NEVER do this in client-side code
const supabase = createClient(url, SERVICE_ROLE_KEY);

// ✅ Always use anon key in React
const supabase = createClient(url, ANON_KEY);

2. Use RLS, Not Client-Side Checks

// ❌ Bad: Client-side authorization
function deleteEvent(id: string) {
  if (user.role !== 'admin') {
    toast.error('Not authorized');
    return;
  }
  // Delete event
}

// ✅ Good: Let RLS handle it
function deleteEvent(id: string) {
  // RLS policy will block if user isn't authorized
  supabase.from('events').delete().eq('id', id);
}

3. Validate Input Data

import { z } from 'zod';

const eventSchema = z.object({
  name: z.string().min(3).max(100),
  description: z.string().max(5000),
  price_cents: z.number().int().positive(),
  start_at: z.string().datetime(),
});

export function useCreateEvent() {
  return useMutation({
    mutationFn: async (event: unknown) => {
      // Validate before sending to Supabase
      const validated = eventSchema.parse(event);

      const { data, error } = await supabase
        .from('events')
        .insert(validated)
        .select()
        .single();

      if (error) throw error;
      return data;
    },
  });
}

4. Sanitize User Input

// Prevent SQL injection in text search
export function useSearchEvents(query: string) {
  const sanitized = query.replace(/[%_]/g, '\\$&');

  return useQuery({
    queryKey: ['events', 'search', sanitized],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('events')
        .select('*')
        .ilike('name', `%${sanitized}%`);

      if (error) throw error;
      return data;
    },
  });
}

11. Testing Patterns

Mock Supabase Client

File: src/__tests__/mocks/supabase.ts

import { vi } from 'vitest';

export const mockSupabase = {
  from: vi.fn(() => ({
    select: vi.fn(() => ({
      eq: vi.fn(() => ({
        single: vi.fn(() => Promise.resolve({ data: null, error: null })),
      })),
    })),
    insert: vi.fn(() => ({
      select: vi.fn(() => ({
        single: vi.fn(() => Promise.resolve({ data: null, error: null })),
      })),
    })),
  })),
  auth: {
    getSession: vi.fn(() => Promise.resolve({ data: { session: null }, error: null })),
    signInWithPassword: vi.fn(),
    signOut: vi.fn(),
  },
};

Test Hooks

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEvents } from '../useEvents';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
};

describe('useEvents', () => {
  it('fetches events successfully', async () => {
    const { result } = renderHook(() => useEvents(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    expect(result.current.data).toHaveLength(4);
  });
});

12. Common Pitfalls & Solutions

Pitfall 1: Not Handling Loading States

// ❌ Bad: Flash of wrong content
function EventsList() {
  const { data } = useEvents();
  return <div>{data?.map(...)}</div>;
}

// ✅ Good: Proper loading state
function EventsList() {
  const { data, isLoading } = useEvents();

  if (isLoading) {
    return <Skeleton count={5} />;
  }

  return <div>{data?.map(...)}</div>;
}

Pitfall 2: Missing Query Key Dependencies

// ❌ Bad: Filters don't refetch
const { data } = useQuery({
  queryKey: ['events'],
  queryFn: () => fetchEvents(filters),
});

// ✅ Good: Include dependencies
const { data } = useQuery({
  queryKey: ['events', filters],
  queryFn: () => fetchEvents(filters),
});

Pitfall 3: Not Invalidating After Mutations

// ❌ Bad: Cache not updated
const createEvent = useMutation({
  mutationFn: (event) => supabase.from('events').insert(event),
});

// ✅ Good: Invalidate cache
const createEvent = useMutation({
  mutationFn: (event) => supabase.from('events').insert(event),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['events'] });
  },
});

Pitfall 4: Forgetting to Unsubscribe

// ❌ Bad: Memory leak
useEffect(() => {
  const channel = supabase.channel('events');
  channel.subscribe();
  // Missing cleanup!
}, []);

// ✅ Good: Cleanup subscription
useEffect(() => {
  const channel = supabase.channel('events');
  channel.subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
}, []);

13. Debugging Tips

Enable Query Devtools

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Log Supabase Queries

// Add to client.ts for debugging
export const supabase = createClient<Database>(url, key, {
  auth: { /* ... */ },
  global: {
    headers: {
      'x-my-custom-header': 'debug-mode'
    }
  }
});

// Log all queries
const originalFrom = supabase.from.bind(supabase);
supabase.from = (table: string) => {
  console.log(`[Supabase] Querying table: ${table}`);
  return originalFrom(table);
};

Check Network Tab

  • Open DevTools → Network tab
  • Filter by "supabase.co"
  • Check request/response for errors
  • Verify RLS headers (x-client-info)

Resources

See /resources folder for:

  • query-patterns.ts - Complete TanStack Query examples
  • auth-patterns.tsx - Auth provider templates
  • rls-examples.sql - Common RLS policies
  • testing-setup.ts - Test configuration
  • migration-guide.md - Upgrading from Auth Helpers

Quick Reference Commands

# Generate types
npx supabase gen types typescript --project-id <id> > src/integrations/supabase/types.ts

# Start local Supabase
npx supabase start

# Create migration
npx supabase migration new <name>

# Apply migrations
npx supabase db push

# Reset database
npx supabase db reset

Last Updated: 2025-10-19 Maintained By: EventOS Team Questions? Check resources/ folder for detailed examples