# Što ćete izgraditi#
Ovaj vodič prikazuje praktičnu, production‑grade strategiju za Next.js Supabase row level security multitenant aplikacije uz Next.js App Router. Dizajnirat ćete tablice, implementirati RLS pravila s ulogama i koristiti sigurne server-side obrasce pristupa koji drže podatke tenanata izoliranima čak i kad upiti postanu kompleksni.
Također ćete naučiti kako timovi slučajno naruše izolaciju zbog pogrešne upotrebe service role i leaky joinova, te dobivate kontrolnu listu za deployment koju možete kopirati u svoj runbook.
Za širi kontekst dizajna multi-tenant sustava pogledajte naš arhitekturni deep dive: Vodič za Next.js multi-tenant SaaS arhitekturu. Ako još birate autentikaciju, ovaj vodič dobro ide uz: Vodič za autentikaciju u Next.js za NextAuth, Clerk i Supabase. Za end-to-end osnovu pogledajte: Next.js Supabase SaaS starter arhitektura.
# Multi-Tenant RLS strategija na jednoj stranici#
Postoje dva uobičajena modela tenanata:
| Model | Opis | Prednosti | Nedostaci | Najbolje za |
|---|---|---|---|---|
| Jedna baza, zajedničke tablice | Svaki red ima tenant_id (ili organization_id) i RLS izolira pristup | Jednostavniji ops, niži trošak, laka analitika | Pravila moraju biti ispravna svugdje | Većina B2B SaaS |
| Baza po tenantu (ili schema po tenantu) | Svaki tenant ima zasebnu bazu ili schemu | Snažna granica izolacije | Kompliciran ops, migracije, trošak | Regulirano okruženje ili vrlo veliki tenanti |
Ovaj vodič se fokusira na zajedničke tablice + strogi RLS, jer je to tipično dobar fit za Supabase i dobro skalira dok ne dođete do enterprise zahtjeva za izolacijom.
Robustan pristup ima tri stupa:
- 1Članstvo u tenantu se sprema u bazu, a ne hardkodira u JWT claimove.
- 2Svaka tablica vezana uz tenanta ima
tenant_idi uključen RLS. - 3Aplikacijski kod nikad ne “filtrira po tenantu” kao primarnu sigurnosnu granicu. Baza je granica.
🎯 Ključna poruka:
WHERE tenant_id = ...u aplikaciji služi za performanse i jasnoću, ne za sigurnost. RLS je sigurnosna kontrola.
# Preduvjeti#
| Zahtjev | Verzija | Napomena |
|---|---|---|
| Next.js | 14+ ili 15+ | Koriste se obrasci App Routera |
| Supabase | Postgres 15+ (managed) | RLS, auth, policyji |
| supabase-js | 2.x | Server-side obrasci klijenta |
| Auth | Supabase Auth ili vanjski provider | Mora isporučiti valjani korisnički JWT Supabaseu |
Ako koristite Clerk ili NextAuth, integracija i dalje mora završiti tako da Supabase dobije JWT koji može validirati kao korisnika iz zahtjeva. Ako to nije istina, vaš “RLS” postaje “vjeruj app serveru”, što poništava smisao.
# Dizajn tablica za multi-tenant SaaS#
Jezgreni entiteti#
Čest B2B SaaS model podataka koristi organizacije, članstva i resurse vezane uz tenanta.
| Tablica | Svrha | Vezano uz tenanta | Napomena |
|---|---|---|---|
organizations | Zapis tenanta | Da | Jedan red po tenantu |
organization_members | Korisnik pripada organizaciji, s ulogom | Da | Vaš primarni autorizacijski primitiv |
projects | Primjer resursa | Da | Ima organization_id |
tickets | Primjer resursa | Da | Ima organization_id i možda project_id |
profiles | Podaci profila korisnika | Ne | Obično 1 red po korisniku, nije tenant-specifično |
Primjer SQL sheme#
Shema neka bude eksplicitna: organization_id svugdje, stroga FK ograničenja i stabilni ID-evi.
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now()
);
create table public.organization_members (
organization_id uuid not null references public.organizations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamptz not null default now(),
primary key (organization_id, user_id)
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
name text not null,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create index projects_org_idx on public.projects (organization_id);
create table public.tickets (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
project_id uuid references public.projects(id) on delete set null,
title text not null,
body text,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create index tickets_org_idx on public.tickets (organization_id);
create index tickets_project_idx on public.tickets (project_id);Zašto je ovo bitno:
- Kompozitni primarni ključ na članstvu sprječava duplikate.
- Svaka tablica vezana uz tenanta ima direktan stupac
organization_id, čak i ako referencira drugu tenant-scope tablicu. - Indeksiranje
organization_idnije opcija. Bez toga RLS filtriranje postaje skupi seq scan pod opterećenjem.
💡 Savjet: Denormalizirajte
organization_idna svaku tablicu vezanu uz tenanta, čak i kad ga možete izvesti kroz joinove. Poboljšava performanse upita i čini RLS pravila jednostavnijima i manje sklona greškama.
# Uloge, claimovi i kako Supabase evaluira RLS#
Supabase koristi Postgres uloge i JWT claimove. U praksi:
anoniauthenticatedsu uloge koje ćete najčešće koristiti.auth.uid()vraća ID trenutnog korisnika iz JWT-a.- Pravila se evaluiraju po tablici, po naredbi (SELECT, INSERT, UPDATE, DELETE).
- Ako je RLS uključen i ne postoji odgovarajuće pravilo, pristup je odbijen.
Ključna dizajnerska odluka je: gdje žive uloge?
Preporuka: uloge žive u organization_members#
Tenant uloge spremite u tablicu i provjeravajte ih u pravilima. Ovo podržava:
- Korisnike u više organizacija
- Promjene uloga bez problema s osvježavanjem tokena
- Auditabilnu povijest članstva (ako kasnije dodate logove)
Izbjegavajte oslanjanje na custom JWT claimove za tenant uloge osim ako u potpunosti kontrolirate izdavanje tokena i strategiju refreshanja. Drift claimova je stvaran problem, a drift u kontroli pristupa je sigurnosni problem.
# Implementacija RLS-a: pravila koja skaliraju s kompleksnošću#
Korak 1: Uključite RLS na tenant tablicama#
alter table public.organizations enable row level security;
alter table public.organization_members enable row level security;
alter table public.projects enable row level security;
alter table public.tickets enable row level security;Razmotrite i prisilu:
alter table public.organizations force row level security;
alter table public.projects force row level security;
alter table public.tickets force row level security;Force RLS je defenzivna opcija koja sprječava vlasnike tablica da u nekim kontekstima zaobiđu pravila. Koristite je ako ste disciplinirani oko admin pristupa i migracija.
Korak 2: Pomoćni predikat kroz SQL funkciju#
Ponovno upotrebljiv helper smanjuje duplikacije pravila. Neka bude jednostavan, stabilan i siguran.
create or replace function public.is_org_member(org_id uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1
from public.organization_members m
where m.organization_id = org_id
and m.user_id = auth.uid()
);
$$;Možete dodati i provjeru uloga:
create or replace function public.org_role(org_id uuid)
returns text
language sql
stable
as $$
select m.role
from public.organization_members m
where m.organization_id = org_id
and m.user_id = auth.uid()
limit 1;
$$;Korak 3: Pravilo vidljivosti organizacije#
Organizacije bi trebale biti vidljive samo ako je korisnik član.
create policy org_select_for_members
on public.organizations
for select
to authenticated
using (public.is_org_member(id));Korak 4: Pravila za članstva#
Tablice članstva su osjetljive jer mogu otkriti strukturu tenanta i popise korisnika. Odlučite što je dopušteno.
Uobičajeno pravilo:
- Svaki član može vidjeti popis članova svoje organizacije (za UI poput “Team members”).
- Samo owneri i admini mogu dodavati ili uklanjati članove.
create policy members_select_in_org
on public.organization_members
for select
to authenticated
using (public.is_org_member(organization_id));Za inserte, ograničite po ulozi. Također zaključajte dodjelu user_id kako biste izbjegli napade tipa “pozovem sam sebe kao owner”.
create policy members_insert_admin_only
on public.organization_members
for insert
to authenticated
with check (
public.org_role(organization_id) in ('owner', 'admin')
);Za brisanja:
create policy members_delete_admin_only
on public.organization_members
for delete
to authenticated
using (
public.org_role(organization_id) in ('owner', 'admin')
);Ovo možete dodatno rafinirati, npr. spriječiti uklanjanje zadnjeg ownera. To je najbolje raditi u transakciji ili triggeru, ne samo u RLS-u.
⚠️ Upozorenje: RLS ne može pouzdano provoditi složene invarijante preko više redaka poput “mora ostati barem jedan owner” bez pažljivog lockanja. Implementirajte tu logiku u jednoj SQL funkciji ili transakciji i izložite je kroz RPC.
Korak 5: Pravila za resurse vezane uz tenanta#
Za projects:
create policy projects_select_in_org
on public.projects
for select
to authenticated
using (public.is_org_member(organization_id));
create policy projects_insert_in_org
on public.projects
for insert
to authenticated
with check (public.is_org_member(organization_id));
create policy projects_update_in_org
on public.projects
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));
create policy projects_delete_admin_only
on public.projects
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));Za tickets, sličan obrazac:
create policy tickets_select_in_org
on public.tickets
for select
to authenticated
using (public.is_org_member(organization_id));
create policy tickets_insert_in_org
on public.tickets
for insert
to authenticated
with check (public.is_org_member(organization_id));
create policy tickets_update_in_org
on public.tickets
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));Zašto i using i with check za update:
usingkontrolira koje postojeće retke možete ciljati.with checkkontrolira u što se red može pretvoriti nakon updatea.- Bez
with check, korisnik bi mogao ažuriratiorganization_idi “premjestiti” red u drugi tenant ako vaša shema to dopušta.
Ako se organization_id nikad ne bi smio mijenjati, provedite to:
- Učinite ga nepromjenjivim na razini aplikacije
- Razmotrite trigger koji baca exception na promjenu
- Ili uopće uklonite update dozvolu za taj stupac preko viewova ili RPC-a
# Next.js App Router: sigurni server-side obrasci pristupa#
Obrazac 1: Server Components i Server Actions koriste session korisnika#
Default bi trebao biti: koristite korisnikov JWT, pozovite Supabase i pustite RLS da filtrira.
To zahtijeva server-side Supabase klijent koji može čitati cookies i automatski priložiti session. Točna implementacija varira ovisno o setupu, ali princip je uvijek isti.
Minimalni factory za “server client” može izgledati ovako:
// lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
export function createSupabaseServerClient(accessToken: string) {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ global: { headers: { Authorization: `Bearer ${accessToken}` } } }
);
}Zatim u server action proslijedite korisnički token iz svojeg auth sloja.
'use server';
import { createSupabaseServerClient } from '@/lib/supabase/server';
export async function listProjects(accessToken: string, organizationId: string) {
const supabase = createSupabaseServerClient(accessToken);
const { data, error } = await supabase
.from('projects')
.select('id, name, created_at')
.eq('organization_id', organizationId)
.order('created_at', { ascending: false });
if (error) throw new Error(error.message);
return data;
}.eq('organization_id', organizationId) nije sigurnosna granica. Smanjuje broj redaka i poboljšava performanse. Ako korisnik pošalje ID drugog tenanta, RLS i dalje blokira pristup.
Obrazac 2: Koristite RPC za privilegirane višekoračne operacije#
Za radnje poput “invite member” često trebate:
- Validirati ulogu
- Insertati članstvo
- Možda zapisati audit red
- Možda poslati email job
Raditi to kroz više round-tripova iz Next.js-a povećava race conditione. Radije koristite jednu transakciju u bazi.
create or replace function public.invite_member(org_id uuid, new_user_id uuid, new_role text)
returns void
language plpgsql
security invoker
as $$
begin
if public.org_role(org_id) not in ('owner', 'admin') then
raise exception 'not allowed';
end if;
insert into public.organization_members(organization_id, user_id, role)
values (org_id, new_user_id, new_role)
on conflict (organization_id, user_id) do update set role = excluded.role;
end;
$$;Pozovite iz Next.js-a koristeći session korisnika:
'use server';
import { createSupabaseServerClient } from '@/lib/supabase/server';
export async function inviteMember(accessToken: string, orgId: string, userId: string) {
const supabase = createSupabaseServerClient(accessToken);
const { error } = await supabase.rpc('invite_member', {
org_id: orgId,
new_user_id: userId,
new_role: 'member',
});
if (error) throw new Error(error.message);
}Držite security invoker kako bi se RLS i provjere uloga primijenile na pozivatelja.
ℹ️ Napomena: Izbjegavajte
security definerza tenant podatke osim ako dubinski razumijete kako može zaobići RLS. Ako ga baš morate koristiti, eksplicitno provodite tenant provjere unutar funkcije i ograničite vraćene stupce.
Obrazac 3: Service role samo za background jobove#
Neki workloadovi legitimno trebaju service role:
- Stripe webhookovi koji sinkaju status pretplate
- Scheduled jobovi (dnevni računi)
- Admin dashboard koji obuhvaća sve tenantove
U Next.js-u to bi trebalo živjeti u server-only routama uz strogu autentikaciju zahtjeva, i nikad za čitanje u kontekstu korisnika.
Sigurna osnovna postavka:
- Service role se koristi samo u
/api/webhooks/*ili internim cron endpointima. - Endpointi su zaštićeni provjerom potpisa ili internom autentikacijom.
- Sve korisničke stranice i akcije koriste anon key plus korisnički JWT.
# Česte zamke koje ruše izolaciju tenanata#
# Zamka 1: Korištenje service role “iz praktičnosti”#
Developeri posegnu za service role jer “sve radi” kad RLS blokira upit. Upravo zato je opasno.
Ako koristite service role u bilo kojem request pathu koji pokreće korisnik, morate ponovno izgraditi autorizaciju u aplikacijskom kodu savršeno. To je teško održavati i lako se regresira.
Konkretan rizičan obrazac:
- List endpoint koristi service role.
- “Filtrira po organization_id” iz query parametra.
- Napadač promijeni parametar i čita drugog tenanta.
Rješenje:
- Koristite korisnički JWT za tenant-scope čitanja.
- Ograničite service role na server-to-server tokove.
- Dodajte automatizirane testove koji potvrđuju da cross-tenant čitanja vraćaju nula redaka.
# Zamka 2: Leaky joinovi i nedostajući RLS na povezanim tablicama#
RLS je na razini tablice. Ako tenant tablicu joinate s tablicom koja nije zaštićena, možete slučajno procuriti podatke.
Primjer scenarija:
ticketsima RLS.- Joinate na
ticket_comments, ali ste zaboravili uključiti RLS tamo. - Korisnik selekta tickete i dobije komentare iz drugih orgova jer tablica komentara nije filtrirana.
Rješenje:
- 1Svaka tablica koja sadrži tenant podatke mora imati:
organization_id- uključen RLS
- pravila koja provode članstvo
- 2Izbjegavajte tablice koje “implicitno pripadaju” tenantu samo kroz joinove. Vlasništvo tenanta učinite eksplicitnim.
Ako baš morate izvoditi tenanta kroz joinove, provedite to viewom ili RPC-om koji primjenjuje join ograničenje server-side, a zatim zaključajte direktan pristup tablici.
# Zamka 3: Pravila koja referenciraju neindeksirane stupce#
RLS dodaje predikate u upite. Ako vaše pravilo provjerava članstvo kroz subquery, taj subquery mora biti brz.
Osigurajte indekse:
organization_members (organization_id, user_id)je već primarni ključ u našem modelu- Ako često upitujete po
user_id, dodajteorganization_members_user_idx (user_id)
Loše performanse postaju sigurnosni problem kada timovi “privremeno” isključe RLS da bi isporučili funkcionalnost.
# Zamka 4: Dopuštanje updatea koji mijenjaju tenant vlasništvo#
Ako je organization_id moguće mijenjati, a update pravilo previše permisivno, napadači mogu pokušati “premjestiti” retke između tenanata.
Mitigacije:
- Učinite
organization_idneupdatable preko RPC-a ili viewova. - Dodajte trigger koji blokira promjene.
- Koristite
with checku UPDATE pravilima. - Razmotrite i
project_idi slična FK polja, ako premještanje između projekata implicira promjene pristupa.
# Zamka 5: Ne testirati RLS stvarnim upitima#
RLS bugovi se ne vide u unit testovima koji mockaju pristup podacima.
Što biste trebali testirati:
- Korisnik A u orgu A ne može čitati ni ažurirati retke orga B.
- Korisnik A ne može insertati red za org B.
- Admin-only operacije padaju za member ulogu.
- Joinovi ne cure cross-tenant podatke.
Čak i mali set integracijskih testova uhvati većinu regresija.
# Kontrolna lista za deployment za Next.js i Supabase RLS#
Koristite ovo prije deploya na staging i produkciju.
| Područje | Provjera | Zašto je važno |
|---|---|---|
| RLS pokrivenost | RLS uključen na svakoj tablici vezanoj uz tenanta | Jedna propuštena tablica može procuriti podatke |
| Pravila | SELECT, INSERT, UPDATE, DELETE pravila definirana namjerno | Nedostajuće pravilo znači denied ili nedosljedno ponašanje |
| Tenant stupac | Svaka tenant tablica uključuje organization_id i indekse | Jednostavnija pravila i predvidljive performanse |
| Model članstva | Tablica članstva ima PK i ograničenje uloge | Sprječava duplikate članstva i nevažeće uloge |
| Service role | Ne koristi se u user-driven request pathovima | Izbjegava slučajan pristup svim podacima |
| RPC funkcije | Preferirati security invoker, validirati ulogu unutar funkcije | Sprječava zaobilaženje RLS-a |
| Triggeri | Blokirati promjene tenant ID-a gdje je potrebno | Sprječava napade prebacivanja vlasništva |
| Logiranje | Logirati admin akcije i promjene članstva | Audit i incident response |
| Okruženje | Service role ključ nikad nije izložen klijentu | Tretirajte ga kao database root |
| Testovi | Testovi cross-tenant pristupa u CI | Sprječava regresije |
| Observability | Pratiti spore upite na provjerama članstva | Performanse guraju timove prema nesigurnim prečacima |
💡 Savjet: U CI-u pokrenite “tenant isolation smoke test” koji kreira dva orga i dva korisnika, ubaci podatke i potvrdi da svaki cross-tenant upit vraća prazan skup. Ovo traje par minuta i sprječava skupe incidente.
# Ključne poruke#
- Dizajnirajte multi-tenant tablice s eksplicitnim
organization_idna svakom tenant-scope retku, uz indekse za predvidljive RLS performanse. - Uloge spremite u
organization_membersi provjeravajte članstvo u RLS pravilima koristeći helpere poputpublic.is_org_member(org_id). - U Next.js App Routeru koristite korisnički JWT za sva tenant-scope čitanja i pisanja kako bi RLS uvijek bio sigurnosna granica.
- Koristite RPC za višekoračne operacije i držite funkcije
security invokerosim ako imate jak razlog i dodatne zaštite. - Izbjegavajte service role u user-triggered code pathovima i auditirajte shemu zbog leaky joinova tako da svugdje uključite RLS gdje postoje tenant podaci.
- Isporučujte uz kontrolnu listu za deployment i CI testove koji eksplicitno potvrđuju da je cross-tenant pristup nemoguć.
# Zaključak#
Next.js i Supabase mogu biti snažan stack za multi-tenant SaaS, ali samo ako bazu tretirate kao enforcement sloj. Kada su tablice dizajnirane za tenancy, RLS pravila dosljedna, a Next.js server kod koristi korisničke sesije umjesto service role prečaca, dobivate izolaciju koju je teško slučajno pokvariti.
Ako želite da pregledamo vašu shemu i pravila ili implementiramo sigurnu multi-tenant osnovu s Next.js App Routerom i Supabaseom, kontaktirajte Samioda i pomoći ćemo vam da brže isporučite production-ready arhitekturu.
FAQ
Više iz kategorije Web razvoj
Sve →Dinamične Open Graph slike u Next.js-u: generiranje OG slika, predmemoriranje, fontovi i savjeti za Edge runtime
Praktičan vodič za 2026. o dinamičnim Open Graph slikama u Next.js-u: generiranje OG slika po stranici, učitavanje fontova, cache headeri, Edge runtime zamke i rješavanje problema pri deployu.
Kontrolni popis za tehnički SEO audit u Next.js-u (App Router): indeksiranje, metapodaci, Core Web Vitals i strukturirani podaci
Kontrolni popis za tehnički SEO audit u Next.js-u (App Router) korak po korak: kontrole crawlanja i indeksiranja, metapodaci i kanonikali, sitemapovi i robots, paginacija, Core Web Vitals te JSON-LD schema s kodom koji možete kopirati i zalijepiti.
Izgradnja React dizajn sustava s dizajnerskim tokenima: Tailwind CSS + Radix UI + TypeScript
Praktičan vodič za 2026. o izgradnji React dizajn sustava s Tailwindom i Radixom uz dizajnerske tokene, pristupačne primitive, tematiziranje i ponovno iskoristive pakete kroz više aplikacija.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Next.js + Supabase SaaS početna arhitektura (App Router): Auth, RLS, naplata i multi-tenancy
Production-ready nacrt za Next.js App Router + Supabase SaaS početnu arhitekturu: autentikacija, Postgres podatkovni model, RLS politike, Stripe naplata i multi-tenant dizajn organizacija s konkretnim primjerima.
Next.js multitenant SaaS arhitektura: modeli tenancije, rutiranje, autentikacija i izolacija podataka (Vodič za 2026.)
Praktičan vodič za Next.js multitenant SaaS arhitekturu: modeli tenancije, tenant-aware rutiranje uz App Router i middleware, obrasci autentikacije te učvršćivanje izolacije podataka kako bi se spriječila curenja.
Next.js autentikacija u 2026.: NextAuth vs Clerk vs Supabase (što koristimo na klijentskim projektima)
Praktična usporedba opcija za Next.js autentikaciju u 2026. — NextAuth, Clerk i Supabase — kroz UX, sigurnost, trošak, vrijeme postavljanja i enterprise zahtjeve, uz matrice odluke za SaaS, interne alate i B2B portale.