| name | repository-pattern |
| description | Repository Pattern for data abstraction in Next.js 15 + Supabase. Trigger: When reading or writing data in any domain — always use a repository, never import DB clients directly in components, hooks, or Server Actions.
|
| license | Apache-2.0 |
| metadata | {"version":"1.0"} |
Core Rule
❌ NEVER: Import supabase, prisma, or any DB client directly in components, hooks, or actions
✅ ALWAYS: Access data through a repository. One repository per domain.
File Structure
Every domain that touches data must have a repositories/ folder:
src/domains/{domain}/
├── components/
├── hooks/
├── stores/
├── actions.ts
├── schema.ts
├── messages.ts
└── repositories/
├── {domain}.repository.interface.ts ← contract
└── {domain}.repository.ts ← Supabase implementation
1. Define the Interface
import type { Team, CreateTeamInput, UpdateTeamInput } from '../types';
export interface ITeamsRepository {
findAll(): Promise<Team[]>;
findById(id: string): Promise<Team | null>;
create(input: CreateTeamInput): Promise<Team>;
update(id: string, input: UpdateTeamInput): Promise<Team>;
remove(id: string): Promise<void>;
}
Rules:
- Interface names:
I{Domain}Repository (PascalCase with I prefix)
- Always return domain types — never raw DB row types
findById returns T | null, never throws for not-found
- Mutations return the updated entity, never
void (except remove)
2. Implement with Supabase
import { createClient } from '@/lib/supabase/server';
import type { ITeamsRepository } from './teams.repository.interface';
import type { Team, CreateTeamInput, UpdateTeamInput } from '../types';
export const teamsRepository: ITeamsRepository = {
async findAll() {
const supabase = await createClient();
const { data, error } = await supabase
.from('teams')
.select('*')
.order('name');
if (error) throw new Error(error.message);
return data;
},
async findById(id) {
const supabase = await createClient();
const { data, error } = await supabase
.from('teams')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) throw new Error(error.message);
return data;
},
async create(input) {
const supabase = await createClient();
const { data, error } = await supabase
.from('teams')
.insert(input)
.select()
.single();
if (error) throw new Error(error.message);
return data;
},
async update(id, input) {
const supabase = await createClient();
const { data, error } = await supabase
.from('teams')
.update(input)
.eq('id', id)
.select()
.single();
if (error) throw new Error(error.message);
return data;
},
async remove(id) {
const supabase = await createClient();
const { error } = await supabase
.from('teams')
.delete()
.eq('id', id);
if (error) throw new Error(error.message);
}
};
Rules:
- Export as
const object — not a class, not a default export
- Always
await createClient() inside each method (Next.js server context)
- Use
.maybeSingle() for nullable results, .single() when result is guaranteed
- Throw
new Error(error.message) on Supabase errors — let the caller handle UI
3. Consume in Server Actions
'use server';
import { revalidatePath } from 'next/cache';
import { teamsRepository } from './repositories/teams.repository';
import { createTeamSchema } from './schema';
export async function createTeam(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const parsed = createTeamSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) throw new Error('Invalid input');
const team = await teamsRepository.create(parsed.data);
revalidatePath('/admin/teams');
return team;
}
export async function getTeams() {
return teamsRepository.findAll();
}
4. Consume in React Query hooks
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTeams, createTeam } from '../actions';
export function useTeams() {
return useQuery({
queryKey: ['teams'],
queryFn: getTeams
});
}
export function useCreateTeam() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (formData: FormData) => createTeam(formData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
}
});
}
5. Consume in RSC (Server Component)
import { teamsRepository } from '@/domains/teams/repositories/teams.repository';
import { TeamList } from '@/domains/teams/components/organisms/team-list';
export default async function TeamsPage() {
const teams = await teamsRepository.findAll();
return <TeamList teams={teams} />;
}
Anti-patterns
'use client';
import { createClient } from '@/lib/supabase/client';
function TeamList() {
const supabase = createClient();
supabase.from('teams').select('*');
}
'use server';
import { createClient } from '@/lib/supabase/server';
export async function getTeams() {
const supabase = await createClient();
const { data } = await supabase.from('teams').select('*');
return data;
}
class TeamsRepository {
async findAll() { ... }
}
export default new TeamsRepository();
Naming Quick-Reference
| Artifact | Convention | Example |
|---|
| Interface file | {domain}.repository.interface.ts | teams.repository.interface.ts |
| Interface type | I{Domain}Repository | ITeamsRepository |
| Implementation file | {domain}.repository.ts | teams.repository.ts |
| Implementation export | {domain}Repository (camelCase const) | teamsRepository |
| Folder | repositories/ inside the domain | src/domains/teams/repositories/ |