# Što ćete izgraditi#
Ovaj vodič je end-to-end nacrt za production SaaS uz Next.js App Router i Supabase za Auth i Postgres, s Row Level Security za izolaciju tenant-a i Stripe pretplatama za naplatu.
Dobit ćete konkretne arhitekturne odluke, strukturu foldera koju možete kopirati, podatkovni model spreman za multi-tenancy, primjere RLS politika i zamke koje većina “starter kitova” ignorira.
Ako želite dublju usporedbu pristupa autentikaciji u Next.js-u, pogledajte naš vodič za Next.js autentikaciju. Za širu raspravu o multi-tenant strategiji pročitajte naš vodič za multi-tenant SaaS arhitekturu. Za detalje implementacije naplate koristite naš vodič za Stripe pretplate.
# Pregled arhitekture: production baseline#
Dobra Next.js Supabase SaaS početna arhitektura ima jedan primarni zadatak: spriječiti pristup podacima između tenant-a čak i kada UI i API pogriješe. Zato je baseline:
- Supabase Auth za identitet i upravljanje sesijama
- Postgres + RLS za autorizaciju i izolaciju tenant-a
- Next.js App Router za server-first renderiranje i server actions
- Stripe za naplatu, vezanu uz tenant entitet (organization workspace)
- Webhooks kao izvor istine za status pretplate
High-level tok zahtjeva#
| Scenarij | Next.js sloj | Supabase pristup | Sigurnosna granica |
|---|---|---|---|
| Javne stranice | Server komponente | Bez DB-a | Nije potrebno |
| Auth stranice | Client komponente | Auth endpointi | Rate limiting, pravila verifikacije emaila |
| Stranice tenant aplikacije | Server komponente | Server klijent koristeći korisničku sesiju | RLS provodi izolaciju tenant-a |
| Mutacije | Server actions ili route handleri | Server klijent | RLS + validacija inputa |
| Stripe webhookovi | Route handler | Service role za kontrolirane upise | Verificirati Stripe potpis, bez korisničke sesije |
🎯 Ključna poruka: Pretpostavite da će aplikacijski kod u nekom trenutku zakazati. RLS i dalje mora spriječiti bilo kakvo curenje podataka između tenant-a.
Zašto je App Router ovdje bitan#
App Router potiče prebacivanje dohvaćanja podataka na server i smanjuje broj javnih API-ja koje morate izlagati. To direktno smanjuje attack surface, ali samo ako izbjegnete čestu zamku: korištenje service role key-a bilo gdje što je user-facing.
# Preduvjeti i baseline stack#
| Zahtjev | Verzija | Napomene |
|---|---|---|
| Next.js | 15+ | App Router, server actions |
| Node.js | 20+ | Preporučen LTS |
| Supabase | Najnovije | Auth, Postgres, Storage, Edge Functions opcionalno |
| Stripe | API 2024-xx | Koristite webhooks i billing portal |
| Postgres | Supabase-managed | RLS uključen na tenant tablicama |
Za analitiku i email držite stvari odvojene. Koristite PostHog, Segment ili Plausible za analitiku i providera poput Resend za transakcijske emailove, ali nemojte dopustiti da oni utječu na autorizacijske odluke.
# Struktura repozitorija i foldera (copy-paste nacrt)#
Ova struktura razdvaja odgovornosti: UI, auth, tenant routing, pristup podacima i Stripe naplatu.
| Putanja | Svrha | Napomene |
|---|---|---|
app/(public)/ | Marketing stranice | Nije potrebna autentikacija |
app/(auth)/ | Prijava, registracija, callback | Minimalna UI logika |
app/(app)/[workspaceSlug]/ | Tenant app rute | Workspace se rješava na serveru |
app/api/webhooks/stripe/route.ts | Stripe webhookovi | Dozvoljeni upisi service role-a |
app/api/health/route.ts | Health check | Korisno za uptime monitoring |
components/ | Dijeljene UI komponente | Po defaultu neka budu server-safe |
lib/supabase/ | Supabase klijenti | Odvojite browser vs server vs admin |
lib/auth/ | Session helperi | Izbjegnite dupliciranje logike |
lib/billing/ | Stripe helperi | Centralizirajte plan logiku |
lib/tenancy/ | Razrješenje workspacea | workspaceSlug u workspace_id |
db/migrations/ | SQL migracije | Tretirajte kao izvor istine |
db/seed/ | Seed skripte | Samo za local/staging |
types/ | Dijeljeni tipovi | Razmislite o generiranju iz DB-a |
Minimalni setup Supabase klijenta#
Zadržite tri klijenta: browser, server (user session) i admin (service role). Samo admin može zaobići RLS i nikad se ne smije koristiti u user request putanjama.
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
export function supabaseServerClient(cookiesStore: any) {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookiesStore.getAll(),
setAll: (cookies: any) => cookies.forEach((c: any) => cookiesStore.set(c)),
},
}
);
}// lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);⚠️ Upozorenje: Nikada ne instancirajte admin klijent u bilo kojoj datoteci koja može biti importana u client komponente. Jedan slučajan import može ubaciti privilegirani pristup u browser bundle.
# Multi-tenancy model: odaberite workspace-first#
Za B2B SaaS korisnik može pripadati više workspaceova. Naplata i ovlasti tipično se vežu uz workspace.
Preporučeni entiteti#
| Entitet | Zašto postoji | Ključni stupci |
|---|---|---|
workspaces | Granica tenant-a | id, slug, name, created_by |
workspace_members | Članstvo + uloga | workspace_id, user_id, role |
profiles | Metadata korisnika | id, full_name, avatar_url |
subscriptions | Stanje naplate | workspace_id, stripe_customer_id, stripe_subscription_id, status, price_id |
| Domenske tablice | Podaci vašeg proizvoda | Moraju uključivati workspace_id |
Odluke u podatkovnom modelu koje kasnije štede živce#
- 1Uvijek uključite
workspace_idna tenant podatke. Čak i ako tablica “pripada projektu” koji pripada workspaceu, zadržiteworkspace_idradi jednostavnijeg RLS-a i bržih upita. - 2Koristite UUID PK-ove svugdje. Supabase defaulti su dobri; izbjegnite otkrivanje sekvencijalnih ID-eva.
- 3Koristite
slugza routing. Rute neka koristeworkspaceSlug, aworkspace_idrazrješavajte server-side. - 4Uloge trebaju biti eksplicitne. Koristite
owner,admin,memberi tretirajte dozvole kao funkciju uloge.
# Shema baze: SQL s kojim možete krenuti#
Ovo je jezgrena shema za Next.js Supabase SaaS početnu arhitekturu s tenant izolacijom.
Osnovne tablice#
-- db/migrations/001_core.sql
create table public.workspaces (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
name text not null,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create table public.workspace_members (
workspace_id uuid not null references public.workspaces(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 (workspace_id, user_id)
);
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text,
avatar_url text,
created_at timestamptz not null default now()
);
create table public.subscriptions (
workspace_id uuid primary key references public.workspaces(id) on delete cascade,
stripe_customer_id text unique,
stripe_subscription_id text unique,
status text not null default 'inactive',
price_id text,
current_period_end timestamptz,
updated_at timestamptz not null default now()
);Primjer domenske tablice u vlasništvu tenant-a#
create table public.projects (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references public.workspaces(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_workspace_id_idx on public.projects(workspace_id);ℹ️ Napomena: Taj indeks na
workspace_idnije opcionalan. Bez njega, RLS-zaštićeni upiti brzo degradiraju kako tenant-i rastu, jer svaki upit mora evaluirati provjere članstva.
# RLS: politike koje stvarno sprječavaju curenje#
RLS nije “lijepo za imati”. To je mehanizam koji zaustavlja slučajno izlaganje podataka zbog buga na klijentu, krivo routanog zahtjeva ili preširokog selecta.
Uključite RLS i napravite membership helper#
Koristite stabilnu funkciju kako biste centralizirali membership logiku. Pažljivo s security definer i uvijek je vežite uz auth.uid().
alter table public.workspaces enable row level security;
alter table public.workspace_members enable row level security;
alter table public.projects enable row level security;
alter table public.subscriptions enable row level security;
create or replace function public.is_workspace_member(w_id uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1
from public.workspace_members wm
where wm.workspace_id = w_id
and wm.user_id = auth.uid()
);
$$;Politike vidljivosti workspacea#
create policy "workspaces_select_for_members"
on public.workspaces
for select
using (public.is_workspace_member(id));
create policy "workspaces_insert_for_authenticated"
on public.workspaces
for insert
with check (auth.uid() = created_by);Politike članstva#
create policy "members_select_self_workspaces"
on public.workspace_members
for select
using (user_id = auth.uid());
create policy "members_insert_owner_only"
on public.workspace_members
for insert
with check (
exists (
select 1
from public.workspace_members wm
where wm.workspace_id = workspace_id
and wm.user_id = auth.uid()
and wm.role = 'owner'
)
);Politike za tenant tablice (projects)#
create policy "projects_select_member"
on public.projects
for select
using (public.is_workspace_member(workspace_id));
create policy "projects_insert_member"
on public.projects
for insert
with check (
public.is_workspace_member(workspace_id)
and created_by = auth.uid()
);
create policy "projects_update_admin"
on public.projects
for update
using (
public.is_workspace_member(workspace_id)
)
with check (
exists (
select 1
from public.workspace_members wm
where wm.workspace_id = workspace_id
and wm.user_id = auth.uid()
and wm.role in ('owner', 'admin')
)
);Politike za tablicu pretplata#
Većina aplikacija treba dopustiti članovima da čitaju status pretplate, ali samo owneri trebaju upravljati naplatom. Upisi tipično dolaze iz Stripe webhookova koristeći service role.
create policy "subscriptions_select_member"
on public.subscriptions
for select
using (public.is_workspace_member(workspace_id));Operativno: izbjegavajte da klijent piše u subscriptions. Stripe tretirajte kao izvor istine.
💡 Savjet: Krenite s manje write politika i dodajte ih tek kada imate jasan product zahtjev. Većina sigurnosnih incidenata dolazi iz previše permisivnih insert i update politika “samo da proradi”.
# Workspace routing i kontekst u Next.js App Routeru#
Vaše tenant rute trebaju izgledati ovako:
/acme/dashboard/acme/projects/acme/settings/billing
Koristite [workspaceSlug] i razrješavajte ga server-side u workspace red kojem korisnik smije pristupiti. Ako nije član, vratite notFound().
Server-only resolver za workspace#
// lib/tenancy/getWorkspace.ts
import { cookies } from "next/headers";
import { supabaseServerClient } from "@/lib/supabase/server";
export async function getWorkspaceBySlug(workspaceSlug: string) {
const supabase = supabaseServerClient(cookies());
const { data, error } = await supabase
.from("workspaces")
.select("id, slug, name")
.eq("slug", workspaceSlug)
.single();
if (error) return null;
return data;
}Layout gating za tenant rute#
// app/(app)/[workspaceSlug]/layout.tsx
import { notFound } from "next/navigation";
import { getWorkspaceBySlug } from "@/lib/tenancy/getWorkspace";
export default async function WorkspaceLayout(props: {
children: React.ReactNode;
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = await props.params;
const workspace = await getWorkspaceBySlug(workspaceSlug);
if (!workspace) notFound();
return props.children;
}Zašto je ovo bitno: izbjegavate dupliciranje access checkova na svakoj stranici. RLS ga i dalje provodi, ali UX je čišći i smanjujete bespotrebne upite.
# Auth: production defaulti koji izbjegavaju edge-caseove#
Supabase Auth je tipično dovoljan, ali trebate nekoliko production pravila:
- Odlučite treba li vaš SaaS verificirani email za pristup tenant podacima.
- Spremite user profile metadata u
profilesi tretirajteauth.userskao identity-only. - Ispravno riješite session refresh u App Routeru.
Za širi framework odluka i tradeoffe s NextAuth i Clerk, pogledajte vodič za Next.js autentikaciju.
Post-signup bootstrap flow#
Pouzdan SaaS flow je:
- 1Korisnik se registrira.
- 2Kreira se red u
profiles. - 3Kreira se zadani
workspace. - 4Korisnik se doda kao
owneruworkspace_members. - 5Redirect na
/{workspaceSlug}/dashboard.
Korake 2 do 4 odradite na serveru kako biste izbjegli race conditione i djelomično kreirane tenant-e.
# Naplata: Stripe vezan uz workspace, ne uz korisnika#
Vaš billing lifecycle je set prijelaza stanja koje pokreću Stripe webhookovi. U multi-tenancy postavci pretplata pripada workspace_id.
Što spremiti (minimum viable)#
| Stupac | Izvor | Zašto je bitno |
|---|---|---|
stripe_customer_id | Stripe | Brzo pronalaženje customer-a |
stripe_subscription_id | Stripe | Jedinstveni identitet pretplate |
status | Stripe | Ograničavanje pristupa plaćenim značajkama |
price_id | Stripe | Logika planova/tierova |
current_period_end | Stripe | Grace periodi, UI za obnovu |
Izbjegavajte da klijent postavlja status. Ako to napravite, prije ili kasnije stvorit ćete put gdje zlonamjerni klijent može otključati značajke.
Stripe webhook route handler#
Neka bude malen: verificirajte potpis, parsirajte event, upsertajte stanje pretplate prema workspace_id metapodacima.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { supabaseAdmin } from "@/lib/supabase/admin";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18" });
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get("stripe-signature") as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.created") {
const sub = event.data.object as Stripe.Subscription;
const workspaceId = (sub.metadata?.workspace_id || null) as string | null;
if (workspaceId) {
await supabaseAdmin.from("subscriptions").upsert({
workspace_id: workspaceId,
stripe_customer_id: String(sub.customer),
stripe_subscription_id: sub.id,
status: sub.status,
price_id: sub.items.data[0]?.price?.id ?? null,
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
});
}
}
return new Response("ok", { status: 200 });
}Za implementaciju kreiranja checkout sessiona, billing portala i rukovanja upgradeovima i otkazivanjima, pratite naš poseban vodič: Next.js Stripe pretplate za SaaS naplatu.
# Kontrola pristupa u aplikaciji: kombinirajte RLS s feature gatingom#
RLS kontrolira pristup podacima. Feature gating kontrolira pristup funkcionalnostima proizvoda, npr. “samo plaćeni workspaceovi mogu kreirati više od 3 projekta”.
Radite oboje:
- RLS sprječava curenje podataka.
- Server-side gating sprječava zloupotrebu resursa i provodi limite plana.
Primjer: provođenje limita plana u server actionu#
Limite držite server-side kako biste spriječili client bypass. Koristite jedan upit za trenutnu potrošnju i jedan insert.
// lib/projects/createProject.ts
import { cookies } from "next/headers";
import { supabaseServerClient } from "@/lib/supabase/server";
export async function createProject(workspaceId: string, name: string) {
const supabase = supabaseServerClient(cookies());
const { count } = await supabase
.from("projects")
.select("id", { count: "exact", head: true })
.eq("workspace_id", workspaceId);
const limit = 3;
if ((count ?? 0) >= limit) {
return { ok: false, error: "Dosegnut je limit projekata. Nadogradite plan kako biste kreirali više." };
}
const { error } = await supabase.from("projects").insert({
workspace_id: workspaceId,
name,
created_by: (await supabase.auth.getUser()).data.user?.id,
});
if (error) return { ok: false, error: error.message };
return { ok: true };
}Ovaj action se i dalje oslanja na RLS kako bi osigurao da korisnik može insertati samo u workspace kojem pripada.
# Uobičajene zamke (i kako ih izbjeći)#
Ovo su problemi koji najčešće ruše production SaaS aplikacije s Next.js-om i Supabaseom.
Zamka 1: Korištenje service role-a u user request putanjama#
Ako vaš API route koristi service role key, efektivno ste isključili RLS za tu rutu. Jedan bug postaje potpuni tenant data breach.
Rješenje: service role samo u webhookovima, background jobovima i admin-only maintenanceu.
Zamka 2: Nedostaje workspace_id na domenskim tablicama#
Možete provesti tenant izolaciju kroz joinove, ali RLS postaje kompleksan i performanse upita pate. Timovi na kraju oslabe politike da bi nastavili isporučivati.
Rješenje: uključite workspace_id na svaku tenant-owned tablicu i indeksirajte ga.
Zamka 3: RLS politike rade u devu, ali padaju u produkciji#
Česti uzroci:
- Neispravno korištenje
auth.uid()unutarsecurity definerfunkcija - Zaboravljen
with checkza inserte i updateove - Testiranje samo kroz SQL editor, a ne s pravim user sessionima
Rješenje: napravite minimalni checklist testiranja politika za svaku tablicu. Minimalno potvrdite da korisnik iz workspacea A ne može čitati ni pisati u workspace B.
Zamka 4: Enumeracija workspace slugova#
Ako je routing /[workspaceSlug] i vraćate različita stanja greške za “postoji ali je zabranjeno” vs “ne postoji”, stvarate enumeration side-channel.
Rješenje: vraćajte isti oblik odgovora. U Next.js-u, notFound() za oba slučaja je obično u redu.
Zamka 5: Drift billing stanja#
Ako u aplikaciji postavite status pretplate nakon checkouta, dobit ćete mismatch kada plaćanje ne prođe, kartica istekne ili dođe do dispute-a. Stripe webhookovi su jedini pouzdan signal.
Rješenje: ažurirajte subscriptions isključivo iz webhookova i eksplicitno obradite past_due i unpaid.
# Checklist za deployment i operacije#
Ovo je minimum koji biste trebali imati prije nego što to nazovete “production”.
| Područje | Stavka | Zašto je bitno |
|---|---|---|
| Tajne | Odvojeni env po okruženju | Sprječava slučajne prod upise |
| DB | Migracije u CI-u | Izbjegava schema drift |
| RLS | Uključen na svim tenant tablicama | Sprječava curenje podataka |
| Webhookovi | Verifikacija potpisa | Sprječava krivotvorene evente |
| Observability | Logiranje webhook failurea | Billing bugovi postaju gubitak prihoda |
| Backup | Point-in-time recovery | Smanjuje blast radius grešaka |
Praktična metrika: IBM-ovo često citirano izvješće Cost of a Data Breach procjenjuje prosječan trošak breach-a u milijunima USD. Ne trebate enterprise alate da smanjite rizik, ali trebate strogi RLS i sigurno rukovanje ključevima.
# Ključne poruke#
- Modelirajte multi-tenancy oko workspaceova i vežite naplatu uz workspaceove, ne uz korisnike.
- Stavite
workspace_idna svaku tenant-owned tablicu, indeksirajte ga i provodite izolaciju s RLS-om. - Koristite tri Supabase klijenta i nikad ne koristite service role u bilo kojem user-facing code pathu.
- Razrješavajte
workspaceSlugserver-side u App Router layoutovima i vratitenotFound()kada pristup nije dopušten. - Stripe webhookove tretirajte kao izvor istine za status pretplate i spremite ga u posebnu tablicu
subscriptions.
# Zaključak#
Production-ready Next.js Supabase SaaS početna arhitektura manje je o spajanju stranica, a više o provođenju tenant izolacije, održavanju konzistentnog billing stanja i sprječavanju curenja privilegiranog pristupa u user code.
Ako želite da vam Samioda pomogne implementirati ovaj nacrt end-to-end, uključujući RLS review, Stripe naplatu i skalabilnu App Router codebase bazu, kontaktirajte nas i pretvorit ćemo vaše SaaS zahtjeve u sigurnu, isporučivu osnovu.
FAQ
Više iz kategorije Web razvoj
Sve →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.
Feature flagovi i A/B testiranje u Next.js-u: arhitektura, alati i sigurna uvođenja (vodič za 2026.)
Praktičan vodič za implementaciju feature flagova i A/B testiranja u Next.js-u uz evaluaciju na serveru i klijentu, Edge runtime specifičnosti, analitiku i timske playbookove za rollout.
React Query vs SWR u Next.js App Routeru: Kada koristiti koji (i kako izbjeći dvostruko dohvaćanje)
Praktična usporedba za 2026. React Queryja i SWR-a unutar Next.js App Routera — modeli cacheiranja, SSR i RSC kompatibilnost, mutacije, optimistična ažuriranja, DX i provjereni obrasci za sprječavanje dvostrukog dohvaćanja.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
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.
Implementacija Stripe pretplata u Next.js-u: Billing Portal, webhookovi i prava pristupa
Vodič spreman za produkciju za Next.js Stripe pretplate: planovi i probni periodi, Billing Portal, provjera webhookova, idempotentna obrada, mapiranje prava pristupa i testiranje sa Stripe CLI-jem.