Client Best Practices
Data Fetching Patterns
Server vs Client Fetching
┌─────────────────────────────────────────────────────────────┐
│ FETCHING STRATEGIES │
├─────────────────────────────────────────────────────────────┤
│ │
│ SERVER COMPONENTS (preferred): │
│ ✅ Faster initial load (no client JS) │
│ ✅ Better SEO │
│ ✅ Direct database access │
│ ✅ No loading states needed │
│ │
│ CLIENT COMPONENTS: │
│ ✅ Interactive features │
│ ✅ Real-time updates │
│ ✅ User-triggered fetches │
│ ❌ Needs loading/error states │
│ │
└─────────────────────────────────────────────────────────────┘
Pattern: Server Fetch, Client Hydrate
// app/tasks/page.tsx (Server Component)
import { createClient } from '@/lib/supabase/server';
import { TaskList } from './task-list';
export default async function TasksPage() {
const supabase = await createClient();
const { data: tasks } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false });
// Pass to client component for interactivity
return <TaskList initialTasks={tasks || []} />;
}
// app/tasks/task-list.tsx (Client Component)
'use client';
import { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
export function TaskList({ initialTasks }: { initialTasks: Task[] }) {
const [tasks, setTasks] = useState(initialTasks);
const supabase = createClient();
// Subscribe to realtime updates
useEffect(() => {
const channel = supabase
.channel('tasks')
.on('postgres_changes', { event: '*', table: 'tasks' }, (payload) => {
// Handle realtime updates
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
Query Optimization
Select Only Needed Columns
// ❌ BAD: Select all columns
const { data } = await supabase
.from('tasks')
.select('*');
// ✅ GOOD: Select only needed columns
const { data } = await supabase
.from('tasks')
.select('id, title, status');
// ✅ GOOD: Select with relations
const { data } = await supabase
.from('tasks')
.select(`
id,
title,
project:projects(id, name)
`);
// ✅ GOOD: Get count without fetching all data
const { count } = await supabase
.from('tasks')
.select('*', { count: 'exact', head: true })
.eq('project_id', projectId);
// Then fetch current page
const { data } = await supabase
.from('tasks')
.select('id, title, status')
.eq('project_id', projectId)
.range(offset, offset + pageSize - 1);
Error Handling Pattern
Centralized Error Handler
// lib/supabase/error-handler.ts
import { PostgrestError } from '@supabase/supabase-js';
export function handleSupabaseError(error: PostgrestError): never {
// Log for debugging
console.error('Supabase error:', {
code: error.code,
message: error.message,
details: error.details,
hint: error.hint,
});
// User-friendly messages
const errorMessages: Record<string, string> = {
'23505': 'This item already exists',
'23503': 'Cannot delete - item is in use',
'42501': 'You do not have permission for this action',
'PGRST116': 'Item not found',
};
const message = errorMessages[error.code] || 'An error occurred';
throw new Error(message);
}
Usage
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('id', taskId)
.single();
if (error) {
handleSupabaseError(error);
}
return data;
TypeScript Patterns
Typed Query Results
// Define types
type Task = Database['public']['Tables']['tasks']['Row'];
type TaskInsert = Database['public']['Tables']['tasks']['Insert'];
type TaskUpdate = Database['public']['Tables']['tasks']['Update'];
// Use in queries
async function createTask(task: TaskInsert): Promise<Task> {
const { data, error } = await supabase
.from('tasks')
.insert(task)
.select()
.single();
if (error) throw error;
return data;
}
async function updateTask(id: string, updates: TaskUpdate): Promise<Task> {
const { data, error } = await supabase
.from('tasks')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
}
Custom Query Types
// For queries with relations
type TaskWithProject = Task & {
project: Pick<Project, 'id' | 'name'> | null;
};
const { data } = await supabase
.from('tasks')
.select(`
*,
project:projects(id, name)
`)
.returns<TaskWithProject[]>();
Caching Strategies
React Query Integration
// hooks/use-tasks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { createClient } from '@/lib/supabase/client';
export function useTasks(projectId: string) {
const supabase = createClient();
return useQuery({
queryKey: ['tasks', projectId],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('project_id', projectId);
if (error) throw error;
return data;
},
staleTime: 1000 * 60, // 1 minute
});
}
export function useCreateTask() {
const supabase = createClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (task: TaskInsert) => {
const { data, error } = await supabase
.from('tasks')
.insert(task)
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: (data) => {
// Invalidate and refetch
queryClient.invalidateQueries({
queryKey: ['tasks', data.project_id]
});
},
});
}
Security Best Practices
Never Expose Service Role Key
// ❌ NEVER do this in client code
const supabase = createClient(url, SUPABASE_SERVICE_ROLE_KEY);
// ✅ Service role only in server-side code
// api/admin/route.ts
import { createClient } from '@supabase/supabase-js';
const adminClient = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Server-only env
);
Validate User Session
// Always verify user in sensitive operations
async function deleteTask(taskId: string) {
const supabase = await createClient();
// Get current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Unauthorized');
}
// RLS will enforce ownership, but explicit check is clearer
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', taskId)
.eq('user_id', user.id); // Extra safety
if (error) throw error;
}
Batch Operations
// ✅ GOOD: Single batch insert
await supabase
.from('tasks')
.insert([task1, task2, task3]);
// ❌ BAD: Multiple separate inserts
await supabase.from('tasks').insert(task1);
await supabase.from('tasks').insert(task2);
await supabase.from('tasks').insert(task3);
Parallel Queries
// ✅ GOOD: Parallel queries
const [tasksResult, projectsResult] = await Promise.all([
supabase.from('tasks').select('*'),
supabase.from('projects').select('*'),
]);
// ❌ BAD: Sequential queries
const tasks = await supabase.from('tasks').select('*');
const projects = await supabase.from('projects').select('*');
Tổng kết
Best Practices Checklist
Data Fetching:
✅ Server Components for initial data
✅ Client Components for interactivity
✅ Select only needed columns
✅ Use count for pagination meta
Error Handling:
✅ Centralized error handler
✅ User-friendly messages
✅ Proper logging
TypeScript:
✅ Generate and use types
✅ Type query results
✅ Define insert/update types
Security:
✅ Never expose service key
✅ Always verify user
✅ Trust RLS but verify
Performance:
✅ Batch operations
✅ Parallel queries
✅ Use caching (React Query)
Q&A
- Data fetching pattern hiện tại?
- Đã dùng React Query chưa?
- Error handling như thế nào?