Web razvoj
Next.jsSupabaseSaaSApp RouterPostgreSQLRLSStripeMulti-tenancyArhitektura

Next.js + Supabase SaaS početna arhitektura (App Router): Auth, RLS, naplata i multi-tenancy

AO
Adrijan Omićević
·15 min čitanja

# Š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#

ScenarijNext.js slojSupabase pristupSigurnosna granica
Javne straniceServer komponenteBez DB-aNije potrebno
Auth straniceClient komponenteAuth endpointiRate limiting, pravila verifikacije emaila
Stranice tenant aplikacijeServer komponenteServer klijent koristeći korisničku sesijuRLS provodi izolaciju tenant-a
MutacijeServer actions ili route handleriServer klijentRLS + validacija inputa
Stripe webhookoviRoute handlerService role za kontrolirane upiseVerificirati 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#

ZahtjevVerzijaNapomene
Next.js15+App Router, server actions
Node.js20+Preporučen LTS
SupabaseNajnovijeAuth, Postgres, Storage, Edge Functions opcionalno
StripeAPI 2024-xxKoristite webhooks i billing portal
PostgresSupabase-managedRLS 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.

PutanjaSvrhaNapomene
app/(public)/Marketing straniceNije potrebna autentikacija
app/(auth)/Prijava, registracija, callbackMinimalna UI logika
app/(app)/[workspaceSlug]/Tenant app ruteWorkspace se rješava na serveru
app/api/webhooks/stripe/route.tsStripe webhookoviDozvoljeni upisi service role-a
app/api/health/route.tsHealth checkKorisno za uptime monitoring
components/Dijeljene UI komponentePo defaultu neka budu server-safe
lib/supabase/Supabase klijentiOdvojite browser vs server vs admin
lib/auth/Session helperiIzbjegnite dupliciranje logike
lib/billing/Stripe helperiCentralizirajte plan logiku
lib/tenancy/Razrješenje workspaceaworkspaceSlug u workspace_id
db/migrations/SQL migracijeTretirajte kao izvor istine
db/seed/Seed skripteSamo za local/staging
types/Dijeljeni tipoviRazmislite 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.

TypeScript
// 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)),
      },
    }
  );
}
TypeScript
// 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#

EntitetZašto postojiKljučni stupci
workspacesGranica tenant-aid, slug, name, created_by
workspace_membersČlanstvo + ulogaworkspace_id, user_id, role
profilesMetadata korisnikaid, full_name, avatar_url
subscriptionsStanje naplateworkspace_id, stripe_customer_id, stripe_subscription_id, status, price_id
Domenske tablicePodaci vašeg proizvodaMoraju uključivati workspace_id

Odluke u podatkovnom modelu koje kasnije štede živce#

  1. 1
    Uvijek uključite workspace_id na tenant podatke. Čak i ako tablica “pripada projektu” koji pripada workspaceu, zadržite workspace_id radi jednostavnijeg RLS-a i bržih upita.
  2. 2
    Koristite UUID PK-ove svugdje. Supabase defaulti su dobri; izbjegnite otkrivanje sekvencijalnih ID-eva.
  3. 3
    Koristite slug za routing. Rute neka koriste workspaceSlug, a workspace_id razrješavajte server-side.
  4. 4
    Uloge trebaju biti eksplicitne. Koristite owner, admin, member i 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#

SQL
-- 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#

SQL
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_id nije 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().

SQL
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#

SQL
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#

SQL
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)#

SQL
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.

SQL
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#

TypeScript
// 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#

TypeScript
// 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 profiles i tretirajte auth.users kao 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:

  1. 1
    Korisnik se registrira.
  2. 2
    Kreira se red u profiles.
  3. 3
    Kreira se zadani workspace.
  4. 4
    Korisnik se doda kao owner u workspace_members.
  5. 5
    Redirect 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)#

StupacIzvorZašto je bitno
stripe_customer_idStripeBrzo pronalaženje customer-a
stripe_subscription_idStripeJedinstveni identitet pretplate
statusStripeOgraničavanje pristupa plaćenim značajkama
price_idStripeLogika planova/tierova
current_period_endStripeGrace 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.

TypeScript
// 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.

TypeScript
// 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() unutar security definer funkcija
  • Zaboravljen with check za 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čjeStavkaZašto je bitno
TajneOdvojeni env po okruženjuSprječava slučajne prod upise
DBMigracije u CI-uIzbjegava schema drift
RLSUključen na svim tenant tablicamaSprječava curenje podataka
WebhookoviVerifikacija potpisaSprječava krivotvorene evente
ObservabilityLogiranje webhook failureaBilling bugovi postaju gubitak prihoda
BackupPoint-in-time recoverySmanjuje 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_id na 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 workspaceSlug server-side u App Router layoutovima i vratite notFound() 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

Share
A
Adrijan OmićevićSamioda Team
All articles →

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.