Skip to main content

@tuturuuu/supabase

The @tuturuuu/supabase package provides type-safe Supabase client utilities for the Tuturuuu platform with different privilege levels and execution contexts.

Installation

# Already included in monorepo workspace
import { createClient, createAdminClient } from '@tuturuuu/supabase/next/server';

Client Types

createClient() - User-Scoped Client

Use Case: Standard operations respecting Row-Level Security (RLS) policies. Context: Server Components, Server Actions, API Routes Privilege Level: Current authenticated user
import { createClient } from '@tuturuuu/supabase/next/server';

export default async function TaskPage() {
  const supabase = createClient();

  // Queries respect RLS policies
  const { data: tasks } = await supabase
    .from('workspace_tasks')
    .select('*')
    .eq('ws_id', 'workspace-id');

  // User can only see tasks they have permission to view
  return <div>{tasks?.length} tasks</div>;
}
Important:
  • ✅ Respects RLS policies
  • ✅ Safe for user-initiated operations
  • ❌ Cannot bypass workspace isolation
  • ❌ Cannot perform privileged operations

createAdminClient() - Service Role Client

Use Case: Privileged operations bypassing RLS (admin tasks, system operations). Context: Server-side only (never expose to client) Privilege Level: Service role (bypasses RLS)
import { createAdminClient } from '@tuturuuu/supabase/next/server';

export async function createWorkspace(name: string, ownerId: string) {
  const sbAdmin = await createAdminClient();

  // Bypasses RLS - can insert into any workspace
  const { data: workspace } = await sbAdmin
    .from('workspaces')
    .insert({ name })
    .select()
    .single();

  // Add owner as member
  await supabase
    .from('workspace_members')
    .insert({
      ws_id: workspace.id,
      user_id: ownerId,
      role: 'owner',
    });

  return workspace;
}
⚠️ Critical Security Warning:
  • ✅ Only use server-side
  • ✅ Only for privileged operations
  • ❌ NEVER expose to client
  • ❌ NEVER use for user-initiated queries (always prefer createClient())

createDynamicClient() - Middleware Client

Use Case: Middleware and edge runtime contexts. Context: Next.js middleware, edge functions
import { createDynamicClient } from '@tuturuuu/supabase/next/server';
import { NextRequest } from 'next/server';

export async function proxy(request: NextRequest) {
  const supabase = createDynamicClient();

  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return Response.redirect('/login');
  }
}

Common Patterns

Authentication

Revalidated Session User

Use resolveAuthenticatedSessionUser() from @tuturuuu/supabase/next/auth-session-user when shared server code needs the current Supabase session user. The helper may probe auth.getClaims() for compatibility or timing context, but it must revalidate the session with auth.getUser() before returning a user. Treat JWT claims as verified token contents, not current account state. Do not authorize privileged routes, staff-only checks, or createAdminClient() flows from claim-derived email, role, or metadata alone. For those paths, make the authorization decision from the Auth-server user returned by getUser() so revoked, deleted, or offboarded sessions are rejected before any service-role operation runs.

Get Current User

import { createClient } from '@tuturuuu/supabase/next/server';

export async function getCurrentUser() {
  const supabase = createClient();
  const { data: { user }, error } = await supabase.auth.getUser();

  if (error || !user) {
    return null;
  }

  return user;
}

Server Action with Auth Check

'use server';

import { createClient } from '@tuturuuu/supabase/next/server';

export async function createTask(formData: FormData) {
  const supabase = createClient();

  // Check authentication
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    throw new Error('Unauthorized');
  }

  // Perform operation (RLS policies apply)
  const { data, error } = await supabase
    .from('workspace_tasks')
    .insert({
      name: formData.get('name') as string,
      ws_id: formData.get('wsId') as string,
      created_by: user.id,
    })
    .select()
    .single();

  if (error) throw error;
  return data;
}

Workspace Operations

Get User’s Workspaces

import { createClient } from '@tuturuuu/supabase/next/server';

export async function getUserWorkspaces(userId: string) {
  const supabase = createClient();

  const { data: memberships } = await supabase
    .from('workspace_members')
    .select(`
      ws_id,
      role,
      pending,
      workspaces (
        id,
        name,
        logo_url
      )
    `)
    .eq('user_id', userId)
    .eq('pending', false);

  return memberships?.map((m) => m.workspaces) || [];
}

Check Workspace Permission

import { createClient } from '@tuturuuu/supabase/next/server';
import type { Database } from '@tuturuuu/types';

type Permission = Database['public']['Enums']['workspace_role_permission'];

export async function hasPermission(
  userId: string,
  wsId: string,
  permission: Permission
): Promise<boolean> {
  const supabase = createClient();

  const { data } = await supabase
    .from('workspace_members')
    .select(`
      role,
      workspace_role_permissions!inner (
        permission
      )
    `)
    .eq('user_id', userId)
    .eq('ws_id', wsId)
    .eq('workspace_role_permissions.permission', permission)
    .limit(1);

  return (data?.length || 0) > 0;
}

Data Fetching

Paginated Query

import { createClient } from '@tuturuuu/supabase/next/server';

export async function getTasks(wsId: string, page = 0, pageSize = 20) {
  const supabase = createClient();

  const from = page * pageSize;
  const to = from + pageSize - 1;

  const { data: tasks, error, count } = await supabase
    .from('workspace_tasks')
    .select('*', { count: 'exact' })
    .eq('ws_id', wsId)
    .order('created_at', { ascending: false })
    .range(from, to);

  if (error) throw error;

  return {
    tasks,
    totalCount: count,
    currentPage: page,
    totalPages: Math.ceil((count || 0) / pageSize),
  };
}

Filtered Query with Relations

import { createClient } from '@tuturuuu/supabase/next/server';

export async function getTasksWithAssignees(wsId: string, completed?: boolean) {
  const supabase = createClient();

  let query = supabase
    .from('workspace_tasks')
    .select(`
      *,
      task_assignees (
        workspace_users (
          id,
          display_name,
          avatar_url
        )
      ),
      task_lists (
        id,
        name
      )
    `)
    .eq('ws_id', wsId);

  if (completed !== undefined) {
    query = query.eq('completed', completed);
  }

  const { data, error } = await query;
  if (error) throw error;

  return data;
}

Realtime Subscriptions

'use client';

import { createClient } from '@tuturuuu/supabase/next/client';
import { useEffect, useState } from 'react';
import type { Database } from '@tuturuuu/types';

type Task = Database['public']['Tables']['workspace_tasks']['Row'];

export function useTaskSubscription(wsId: string) {
  const [tasks, setTasks] = useState<Task[]>([]);
  const supabase = createClient();

  useEffect(() => {
    // Initial fetch
    supabase
      .from('workspace_tasks')
      .select('*')
      .eq('ws_id', wsId)
      .then(({ data }) => setTasks(data || []));

    // Subscribe to changes
    const channel = supabase
      .channel(`workspace-tasks-${wsId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'workspace_tasks',
          filter: `ws_id=eq.${wsId}`,
        },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setTasks((prev) => [...prev, payload.new as Task]);
          } else if (payload.eventType === 'UPDATE') {
            setTasks((prev) =>
              prev.map((task) =>
                task.id === payload.new.id ? (payload.new as Task) : task
              )
            );
          } else if (payload.eventType === 'DELETE') {
            setTasks((prev) => prev.filter((task) => task.id !== payload.old.id));
          }
        }
      )
      .subscribe();

    return () => {
      channel.unsubscribe();
    };
  }, [wsId]);

  return tasks;
}

Type Safety

Using Generated Types

import type { Database } from '@tuturuuu/types';
import { createClient } from '@tuturuuu/supabase/next/server';

type Task = Database['public']['Tables']['workspace_tasks']['Row'];
type TaskInsert = Database['public']['Tables']['workspace_tasks']['Insert'];
type TaskUpdate = Database['public']['Tables']['workspace_tasks']['Update'];

export async function createTypedTask(task: TaskInsert): Promise<Task> {
  const supabase = createClient();

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

  if (error) throw error;
  return data;
}

Custom Type Helpers

import type { Database } from '@tuturuuu/types';

// Extract table types
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];

// Usage
type Task = Tables<'workspace_tasks'>;
type Permission = Enums<'workspace_role_permission'>;

Error Handling

Standard Pattern

import { createClient } from '@tuturuuu/supabase/next/server';

export async function getTask(taskId: string) {
  const supabase = createClient();

  const { data, error } = await supabase
    .from('workspace_tasks')
    .select('*')
    .eq('id', taskId)
    .single();

  if (error) {
    console.error('Failed to fetch task:', error);
    throw new Error(`Task not found: ${taskId}`);
  }

  return data;
}

With Try-Catch

import { createClient } from '@tuturuuu/supabase/next/server';

export async function safeGetTask(taskId: string) {
  try {
    const supabase = createClient();

    const { data, error } = await supabase
      .from('workspace_tasks')
      .select('*')
      .eq('id', taskId)
      .single();

    if (error) throw error;
    return { data, error: null };
  } catch (error) {
    console.error('Error fetching task:', error);
    return { data: null, error };
  }
}

Storage Operations

Upload File

import { createClient } from '@tuturuuu/supabase/next/server';

export async function uploadAvatar(userId: string, file: File) {
  const supabase = createClient();

  const fileName = `${userId}-${Date.now()}.${file.name.split('.').pop()}`;
  const filePath = `avatars/${fileName}`;

  const { data, error } = await supabase.storage
    .from('public')
    .upload(filePath, file, {
      cacheControl: '3600',
      upsert: false,
    });

  if (error) throw error;

  const { data: { publicUrl } } = supabase.storage
    .from('public')
    .getPublicUrl(filePath);

  return publicUrl;
}

Download File

import { createClient } from '@tuturuuu/supabase/next/server';

export async function downloadFile(path: string) {
  const supabase = createClient();

  const { data, error } = await supabase.storage
    .from('public')
    .download(path);

  if (error) throw error;
  return data; // Blob
}

Best Practices

✅ DO

  1. Use createClient() by default
    const supabase = createClient(); // Respects RLS
    
  2. Check authentication early
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) throw new Error('Unauthorized');
    
  3. Handle errors explicitly
    const { data, error } = await supabase.from('table').select();
    if (error) throw error;
    
  4. Use TypeScript types
    import type { Database } from '@tuturuuu/types';
    
  5. Limit data fetching
    .select('id, name, created_at') // Only needed fields
    .limit(20) // Pagination
    

❌ DON’T

  1. Never use createAdminClient() for user operations
    // ❌ Bad: Bypasses RLS
    const sbAdmin = await createAdminClient();
    const tasks = await sbAdmin.from('workspace_tasks').select();
    
  2. If you must use createAdminClient(), enforce membership and RBAC before the admin-backed operation
    const member = await verifyWorkspaceMembershipType({
      wsId,
      userId: user.id,
      supabase: requestScopedSupabase,
    });
    if (!member.ok) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }
    
    const permissions = await getPermissions({ wsId, user });
    if (!permissions?.containsPermission('manage_projects')) {
      return NextResponse.json(
        { error: "You don't have permission" },
        { status: 403 }
      );
    }
    
  3. Keep workspace-scoped admin mutations filtered by workspace ID
    await sbAdmin
      .from('workspace_boards')
      .update({ deleted_at: new Date().toISOString() })
      .eq('id', boardId)
      .eq('ws_id', wsId);
    
  4. Don’t expose service role key
    // ❌ Bad: Never do this
    const client = createClient(process.env.SUPABASE_SECRET_KEY);
    
  5. Don’t ignore errors
    // ❌ Bad: Silent failure
    const { data } = await supabase.from('table').select();
    return data; // Could be null!
    
  6. Don’t fetch unnecessary data
    // ❌ Bad: Over-fetching
    .select('*')
    .order('created_at')
    // No limit!
    

Testing

Mock Supabase Client

// __mocks__/@tuturuuu/supabase/next/server.ts
export const createClient = vi.fn(() => ({
  from: vi.fn(() => ({
    select: vi.fn(() => ({
      eq: vi.fn(() => ({
        single: vi.fn(() => Promise.resolve({ data: mockData, error: null })),
      })),
    })),
  })),
  auth: {
    getUser: vi.fn(() =>
      Promise.resolve({ data: { user: mockUser }, error: null })
    ),
  },
}));

Test Example

import { describe, it, expect, vi } from 'vitest';
import { getTask } from './tasks';

vi.mock('@tuturuuu/supabase/next/server');

describe('getTask', () => {
  it('should fetch task by ID', async () => {
    const task = await getTask('task-123');
    expect(task).toBeDefined();
    expect(task.id).toBe('task-123');
  });
});

External Resources