Web razvoj
upravljanje pristupom značajkama u Next.jsNext.js App RouterautorizacijaRBACStripe pretplate

Upravljanje pristupom značajkama u Next.js (App Router): dozvole, prava korištenja i pristup prema naplati

AO
Adrijan Omićević
·16 min čitanja

# Uvod#

Većina Next.js aplikacija kreće s jednostavnim provjerama uloga poput admin naspram user. Čim dodate cjenovne razrede, add-one, limite korištenja i događaje životnog ciklusa Stripe pretplate, te se provjere rasprše po Server Components, route handlerima, server actions i middlewareu.

Ta fragmentacija je skupa. Uzrokuje suptilne sigurnosne rupe, nedosljedan UX i tikete podrške poput “Nadogradio sam, ali još uvijek nemam pristup značajci” ili “Zašto i dalje mogu zvati endpoint nakon downgradea”.

Ovaj vodič pokriva praktične obrasce za Next.js feature gating u App Routeru: kako ograničiti UI i server-side operacije prema ulozi, planu, pravima korištenja (entitlements) i pristupu koji je svjestan naplate (billing-aware) koristeći Stripe stanje pretplate. Dobit ćete konkretan data model, pomoćne funkcije za gate koje se mogu ponovno koristiti i dosljednu provedbu kroz Server Components, route handlere i middleware.

Za dublje obrasce RBAC i ABAC u App Routeru, pogledajte Next.js App Router authz patterns: RBAC and ABAC. Za B2B admin panele s audit logovima i multi-tenant pravilima pristupa, pogledajte B2B SaaS admin panel architecture. Za detalje sinkronizacije Stripe naplate, pogledajte Next.js Stripe subscriptions for SaaS billing.

# 4 sloja upravljanja pristupom značajkama koja stvarno znače razliku#

U praksi će vam trebati sva četiri. Ako preskočite ijedan sloj, ili ćete “procuriti” pristup ili isporučiti nezgrapan UX.

1) UI gating za UX#

UI gating skriva ili onemogućuje kontrole, rute i stranice. Smanjuje zbunjenost i povećava konverzije prikazujući pozive na nadogradnju (upgrade) u kontekstu.

UI gating nije sigurnost. Tretirajte ga isključivo kao UX optimizaciju.

2) Server gating za sigurnost#

Svaka privilegirana operacija mora biti provedena na serveru, bez obzira na to skrivate li gumbe. To uključuje:

  • Route handlere u app/api/*
  • Server actions
  • Server-only funkcije pozivane iz Server Components
  • Background jobove i webhooks

3) Data gating za izolaciju tenanta#

U B2B svijetu “smije napraviti X” nije dovoljno. Mora biti “smije napraviti X nad resursom Y unutar tenanta Z”.

Tu ABAC-stil uvjeti postaju bitni, npr. resource.organizationId === session.organizationId.

4) Billing-aware gating za integritet prihoda#

Billing-aware gating koristi Stripe stanje pretplate, plan, add-one i “grace period” kako bi odlučio o pristupu. Ovdje se mnoge aplikacije slome, jer Stripe ima stvarnu složenost životnog ciklusa:

  • Trialing, active, past_due, canceled
  • Otkazivanje na kraju obračunskog razdoblja
  • Neuspjela plaćanja i recovery tokovi

Ako pogrešno gateate, ili gubite prihod dajući pristup besplatno ili gubite povjerenje blokirajući kupce koji plaćaju.

🎯 Ključna poruka: Na feature gating gledajte kao na sustav. UI gating poboljšava UX, ali server gating i billing-aware gating sprječavaju sigurnosne i “revenue” propuste.

# Uloge, planovi i entitlements: što koristiti kada#

Ovi se pojmovi preklapaju. Najlakši način da ostanete “prisebni” jest da svakom dodijelite drugačiju odgovornost.

PojamNajbolje zaPrimjerKoliko često se mijenjaGdje se pohranjuje
Uloga (role)Tko smije raditi admin-razinske radnjeowner, admin, member, viewerNiskoApp DB
PlanPaketiranje proizvodafree, pro, businessSrednjeApp DB preslikan iz Stripea
EntitlementFina granularnost pristupa sposobnostiexportCsv, sso, apiAccessSrednje do visokoApp DB izveden iz plana i add-ona
Usage limitsMjerna ograničenjamaxProjects, maxSeatsVisokoApp DB brojači, analytics ili billing meter

Praktično pravilo: koristite uloge za admin radnje, planove za odabir cjenovnog razreda, a entitlements kao jedini izvor istine za “smije li ovaj tenant koristiti značajku X”.

Zašto su entitlements važni: omogućuju vam isporuku novih značajki bez stvaranja novih planova. Možete rolloutati značajku iza “flaga” poput feature.aiSummaries za “business plan + AI add-on”, a aplikacijski kod ostaje stabilan.

# Konkretnan data model za Next.js feature gating#

Model ispod podržava B2B tenante, RBAC, feature entitlements i Stripe stanje pretplate. Namjerno je normaliziran kako bi provjere bile predvidljive.

Tablice i polja#

TablicaKljučna poljaSvrha
organizationsid, nameGranica tenanta
organization_membersorganizationId, userId, roleUloga po tenantu
plansid, code, nameLokalni katalog planova mapiran na Stripe
subscriptionsorganizationId, stripeCustomerId, stripeSubscriptionId, status, currentPeriodEnd, cancelAtPeriodEnd, planCodeSnapshot billing stanja
entitlementsorganizationId, key, value, sourcePristup značajkama po tenantu
usage_countersorganizationId, key, periodStart, periodEnd, valueLimiti poput seatova, projekata, API poziva

entitlements.value možete držati kao boolean ili broj, ovisno o keyu. Primjeri keyeva:

  • feature.auditLogs boolean
  • feature.sso boolean
  • limit.seats number
  • limit.projects number
  • feature.apiAccess boolean

Primjer isječka sheme#

SQL
-- entitlements: one row per org and entitlement key
-- value stored as text for flexibility, parsed in app layer
create table entitlements (
  organization_id uuid not null,
  key text not null,
  value text not null,
  source text not null, -- "plan", "addon", "manual"
  updated_at timestamptz not null default now(),
  primary key (organization_id, key)
);
 
create table subscriptions (
  organization_id uuid primary key,
  stripe_customer_id text,
  stripe_subscription_id text,
  status text not null, -- "trialing" | "active" | "past_due" | "canceled" etc
  plan_code text not null, -- "free" | "pro" | "business"
  current_period_end timestamptz,
  cancel_at_period_end boolean not null default false,
  updated_at timestamptz not null default now()
);

ℹ️ Napomena: Stripe nije vaša authorization baza. Stripe je vaš billing sustav. Za pouzdan gating, zrcalite Stripe stanje u vlastiti DB putem webhookova i gateajte prema snapshotu u DB-u, a ne prema live pozivima Stripe API-ja.

# Centralizirajte gating: jedan modul, korišten svugdje#

Dosljednost je najteži dio. Rješenje je dosadno: napravite jednu gate funkciju koja evaluira pristup, i zatim je ponovno koristite u:

  • Server Components
  • Route handlerima
  • Server actions
  • Middlewareu za grube odluke oko routinga

Rječnik za gate: permissions, entitlements i uvjeti#

Definirajte “zahtjeve” strukturirano, kako bi značajke imale jednu definiciju politike (policy).

Tip zahtjevaProvjeravaPrimjer
roleuloga člana unutar organizacijeadmin ili owner
entitlementsposobnost tenantafeature.auditLogs equals true
billingstatus pretplate i kraj razdobljaactive ili trialing
conditionvlasništvo nad resursom ili podudaranje tenantaresource.organizationId equals session.organizationId

Implementacija gatea u Next.js App Routeru#

Ovo zadržite server-only. Izbjegavajte miješanje u client bundle.

TypeScript
// lib/authz/gate.ts
export type Role = "owner" | "admin" | "member" | "viewer";
 
export type SubscriptionStatus =
  | "incomplete"
  | "trialing"
  | "active"
  | "past_due"
  | "canceled"
  | "unpaid";
 
export type GateContext = {
  userId: string;
  organizationId: string;
  role: Role;
  subscription: {
    status: SubscriptionStatus;
    currentPeriodEnd: string | null;
    cancelAtPeriodEnd: boolean;
    planCode: string;
  };
  entitlements: Record<string, string>;
};
 
export type GateResult =
  | { ok: true }
  | { ok: false; reason: "unauthenticated" | "forbidden" | "billing" | "not_found" };
 
const ROLE_RANK: Record<Role, number> = {
  owner: 4,
  admin: 3,
  member: 2,
  viewer: 1,
};
 
function entitlementBool(entitlements: Record<string, string>, key: string) {
  return entitlements[key] === "true";
}
 
export function canAccess(
  ctx: GateContext | null,
  req: {
    minRole?: Role;
    entitlement?: string;
    requireActiveSubscription?: boolean;
  }
): GateResult {
  if (!ctx) return { ok: false, reason: "unauthenticated" };
 
  if (req.minRole) {
    const ok = ROLE_RANK[ctx.role] >= ROLE_RANK[req.minRole];
    if (!ok) return { ok: false, reason: "forbidden" };
  }
 
  if (req.entitlement) {
    if (!entitlementBool(ctx.entitlements, req.entitlement)) {
      return { ok: false, reason: "forbidden" };
    }
  }
 
  if (req.requireActiveSubscription) {
    const ok = ctx.subscription.status === "active" || ctx.subscription.status === "trialing";
    if (!ok) return { ok: false, reason: "billing" };
  }
 
  return { ok: true };
}

Ovo izgleda jednostavno, i to je poanta. Složenost treba biti u “kako gradite context”, a ne raspršena po značajkama.

# Izgradnja gate contexta: session, članstvo, naplata, entitlements#

Gate context treba učitati jednom po requestu i ponovno ga koristiti. U App Routeru ga obično gradite u server kodu i prosljeđujete minimalnu verziju klijentu.

Pattern za učitavanje contexta#

  • Dohvatite session i odabir organizacije
  • Dohvatite ulogu članstva za tu organizaciju
  • Dohvatite snapshot pretplate
  • Dohvatite entitlements key-value map

Koristite caching kako ne biste više puta pogađali DB iz više Server Components.

TypeScript
// lib/authz/context.ts
import { cache } from "react";
 
export const getGateContext = cache(async function getGateContext() {
  // Replace these with your auth and DB calls
  const session = await getSession();
  if (!session) return null;
 
  const organizationId = await getActiveOrganizationId(session.user.id);
  if (!organizationId) return null;
 
  const membership = await db.organizationMember.findUnique({
    where: { organizationId_userId: { organizationId, userId: session.user.id } },
    select: { role: true },
  });
 
  if (!membership) return null;
 
  const subscription = await db.subscription.findUnique({
    where: { organizationId },
    select: {
      status: true,
      currentPeriodEnd: true,
      cancelAtPeriodEnd: true,
      planCode: true,
    },
  });
 
  const entRows = await db.entitlement.findMany({
    where: { organizationId },
    select: { key: true, value: true },
  });
 
  const entitlements = Object.fromEntries(entRows.map((r) => [r.key, r.value]));
 
  return {
    userId: session.user.id,
    organizationId,
    role: membership.role,
    subscription: subscription ?? {
      status: "canceled",
      currentPeriodEnd: null,
      cancelAtPeriodEnd: false,
      planCode: "free",
    },
    entitlements,
  };
});
 
// Placeholder signatures to keep the example focused
async function getSession() {
  return null as any;
}
async function getActiveOrganizationId(userId: string) {
  return null as any;
}
const db = null as any;

⚠️ Upozorenje: Izbjegavajte pozivanje Stripea unutar getGateContext na svakom requestu. Čak i 200 milisekundi po Stripe pozivu brzo postane skupo na većem opsegu. Sinkronizirajte putem webhookova i čitajte iz vlastitog DB-a.

# Pattern 1: Gating Server Components i stranica#

U App Routeru, Server Components su default. To je odlično za gating jer možete odlučiti što renderati na temelju serverom provednih provjera.

Primjer: gate admin stranicu prema ulozi i entitlementu#

TypeScript
// app/(app)/settings/audit-logs/page.tsx
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
import { redirect } from "next/navigation";
 
export default async function AuditLogsPage() {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, {
    minRole: "admin",
    entitlement: "feature.auditLogs",
    requireActiveSubscription: true,
  });
 
  if (!gate.ok) {
    if (gate.reason === "unauthenticated") redirect("/login");
    if (gate.reason === "billing") redirect("/billing/upgrade?feature=auditLogs");
    redirect("/403");
  }
 
  const logs = await loadAuditLogs(ctx.organizationId);
  return <pre>{JSON.stringify(logs, null, 2)}</pre>;
}
 
async function loadAuditLogs(organizationId: string) {
  return [{ id: "evt_1", action: "USER_INVITED" }];
}

Ovo je minimalno prihvatljiv sigurnosni standard: ako korisnik nema pristup, nikad ne dohvaćate zaštićene podatke.

# Pattern 2: Gating server actions (najčešće propušteno)#

Server actions je lako slučajno izložiti jer djeluju “vezano uz komponentu”. Nisu. Tretirajte ih kao API endpoint.

Primjer: gate akciju “pozovi korisnika” prema ulozi i seat limitu#

Ovaj primjer prikazuje čestu kombinaciju: gate prema ulozi plus gate prema usage limitu.

TypeScript
// app/(app)/team/actions.ts
"use server";
 
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
 
export async function inviteMemberAction(email: string) {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, { minRole: "admin", requireActiveSubscription: true });
  if (!gate.ok) throw new Error(`Access denied: ${gate.reason}`);
 
  const seatLimit = Number(ctx.entitlements["limit.seats"] ?? "1");
  const seatsUsed = await countActiveSeats(ctx.organizationId);
  if (seatsUsed >= seatLimit) {
    throw new Error("Dosegnut je limit seatova. Nadogradite kako biste dodali više članova.");
  }
 
  await createInvite(ctx.organizationId, email);
}
 
async function countActiveSeats(organizationId: string) {
  return 3;
}
async function createInvite(organizationId: string, email: string) {
  return;
}

UX i dalje može client-side prikazati disabled gumb, ali server action mora provesti oba gatea.

# Pattern 3: Gating route handlera i API endpointa#

Route handleri su najizravnija napadna površina. Uvijek gateajte na početku, prije dodira s podacima.

Primjer: gate export endpoint prema entitlementu#

TypeScript
// app/api/export/route.ts
import { NextResponse } from "next/server";
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
 
export async function POST() {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, {
    entitlement: "feature.exportCsv",
    requireActiveSubscription: true,
  });
 
  if (!gate.ok) {
    return NextResponse.json({ error: gate.reason }, { status: gate.reason === "unauthenticated" ? 401 : 403 });
  }
 
  const csv = await generateCsv(ctx.organizationId);
  return new NextResponse(csv, {
    headers: { "content-type": "text/csv" },
  });
}
 
async function generateCsv(organizationId: string) {
  return "id,name\n1,Example\n";
}

# Pattern 4: Middleware za grubi gating i tenant routing#

Middleware je koristan za:

  • Preusmjeravanje neautentificiranih korisnika dalje od app ruta
  • Provedbu da je tenant odabran
  • Rano blokiranje očito zabranjenih područja

Middleware nije idealan za fine-grained entitlement provjere jer radi na Edge runtimeu i trebali biste izbjegavati DB pozive tamo. Koristite ga za “fast fail” odluke koje se mogu donijeti iz cookieja ili JWT claimova.

Praktičan pristup za middleware#

  • Stavite role i organizationId u potpisani session token
  • Koristite middleware da blokira /app/admin/* ako je role ispod admina
  • Detaljne entitlement i billing provjere radite u Server Components i route handlerima
TypeScript
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname;
 
  if (path.startsWith("/app")) {
    const token = req.cookies.get("session")?.value;
    if (!token) return NextResponse.redirect(new URL("/login", req.url));
 
    // Keep this lightweight: decode and check basic claims only
    const claims = decodeSessionToken(token);
    if (!claims.organizationId) return NextResponse.redirect(new URL("/select-org", req.url));
 
    if (path.startsWith("/app/admin") && claims.role !== "owner" && claims.role !== "admin") {
      return NextResponse.redirect(new URL("/403", req.url));
    }
  }
 
  return NextResponse.next();
}
 
function decodeSessionToken(token: string) {
  return { organizationId: "org_123", role: "member" };
}
 
export const config = {
  matcher: ["/app/:path*"],
};

Ako trebate Edge-based billing provjere, razmislite o ugrađivanju minimalnog “billingAccess” claima u session token koji osvježavate na ključnim događajima. Za većinu SaaS aplikacija to nije potrebno ako su server-side gateovi ispravni.

# Billing-aware pristup sa Stripeom: stanje koje vam stvarno treba#

Stripeov subscription objekt je bogat. Za gating vam obično treba pojednostavljen snapshot.

Stripe polja koja vrijedi zrcaliti#

Stripe konceptŠto spremiti u DBZašto je važno
Status pretplatestatusPrimarni prekidač za allow/block
Kraj trenutnog razdobljacurrentPeriodEndPristup do kraja razdoblja kod cancel-at-period-end
Otkazivanje na kraju razdobljacancelAtPeriodEndOdlučivanje o upgrade promptovima i renewal UX-u
Mapiranje price/productplanCodeOdređivanje entitlements i limita
Trialstatus trialing, trial endPredvidljivo dopuštanje trial pristupa

Česta SaaS politika je:

  • Dopuštati pristup ako je status active ili trialing
  • Opcionalno dopuštati kratki grace period za past_due, npr. 3 dana
  • Blokirati pristup ako je canceled i currentPeriodEnd je u prošlosti

Ako implementirate grace period, učinite ga eksplicitnim i mjerljivim. Na primjer, “3 dana nakon što invoice postane past due”. Pohranite timestamp koji vam treba za evaluaciju.

Mapiranje planova na entitlements#

Entitlements možete računati “on-the-fly” iz planCode, ali mnogi timovi preferiraju pohranu entitlements radi lakšeg debugiranja i “manual overridea”.

Praktičan hibrid:

  • Održavajte mapu plan → entitlements u kodu
  • Na webhook događajima upsertajte entitlements u DB za organizaciju
  • Koristite tablicu entitlements za sve runtime provjere

Ovo također olakšava podršku: možete pregledati entitlements retke i vidjeti točno što tenant ima.

# Održavanje dosljednih gateova: jedna definicija politike po značajci#

Ako pravila definirate inline u stranicama i endpointima, razići će se. Umjesto toga, definirajte feature politike centralno i referencirajte ih svugdje.

Pattern registra politika (policy registry)#

TypeScript
// lib/authz/policies.ts
export const policies = {
  auditLogs: {
    minRole: "admin",
    entitlement: "feature.auditLogs",
    requireActiveSubscription: true,
  },
  exportCsv: {
    minRole: "member",
    entitlement: "feature.exportCsv",
    requireActiveSubscription: true,
  },
} as const;
 
export type PolicyKey = keyof typeof policies;

Korištenje ostaje dosljedno:

TypeScript
// lib/authz/enforce.ts
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
import { policies, type PolicyKey } from "@/lib/authz/policies";
 
export async function enforce(policyKey: PolicyKey) {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, policies[policyKey]);
  return { ctx, gate };
}

Sada vaše stranice, akcije i API-ji koriste isti policyKey string umjesto dupliciranja zahtjeva.

💡 Savjet: Logirajte gate failure s policyKey, organizationId i razlogom. U praksi to skraćuje debugging “ne mogu pristupiti X” s sati na minute, jer možete korelirati logove s promjenama naplate i entitlements redovima.

# Praktičan UI gating koji odgovara server pravilima#

Client gating treba koristiti istu semantiku, ali ne može se oslanjati na server-only context. Siguran pristup je:

  • Uvijek provoditi na serveru
  • Klijentu dati minimalan objekt “capabilities”, izveden na serveru

Primjer capability payloada#

Payload koji je jednostavan za konzumaciju:

PoljeTipPrimjer
rolestringadmin
planCodestringpro
featuresmap of booleanfeature.auditLogs true
limitsmap of numberlimit.seats 10

Zatim client komponente mogu raditi stvari poput “prikaži upsell banner” ili “onemogući gumb”.

Držite ga minimalnim i izbjegavajte stavljati osjetljive podatke u njega. Feature flagovi i role su obično u redu.

# Testiranje i observability: gateove tretirajte kao payment kod#

Feature gating dira prihod i sigurnost. Zaslužuje stvarne testove i logiranje.

Što testirati#

Tip testaŠto hvataMinimalni set
Unit testovi za canAccessSlomljenu logiku za role i billing statusrole rank, mapiranje billing statusa
Integracijski testovi za politikeOdstupanje između očekivanog i stvarnogsvaki policy key za core roleove
Webhook testoviProbleme sinkronizacije Stripe stanjaprijelazi statusa, cancel at period end

Ako pratite denied pristupe, vidjet ćete i product signale. Skok billing deniala nakon promjene cijena je stvaran alert.

# Ključne poruke#

  • Centralizirajte Next.js feature gating u jednom gate modulu i jednom registru politika, pa ga ponovno koristite u Server Components, route handlerima, server actions i middlewareu.
  • Stripe stanje pretplate zrcalite u svoju bazu putem webhookova i gateajte prema DB snapshotu, a ne prema live Stripe API pozivima po requestu.
  • Uloge koristite za admin autoritet, planove za paketiranje, a entitlements kao jedini izvor istine za “smije koristiti značajku X”.
  • Prvo provodite gateove server-side, zatim ih zrcalite client-side kroz minimalan capabilities payload za bolji UX.
  • Dodajte observability: logirajte denied pristupe po policy keyu i razlogu te pišite testove za prijelaze billing statusa i pokrivenost politika.

# Zaključak#

Feature gating je mjesto gdje se susreću autorizacija, paketiranje proizvoda i naplata, i male nedosljednosti postaju sigurnosne rupe ili “revenue leakovi”. Ako centralizirate politike, normalizirate Stripe stanje u svoj DB i dosljedno provodite gateove kroz App Router primitive, dobit ćete predvidljiv kontrolu pristupa i čišći kod.

Ako želite pomoć pri implementaciji billing-aware autorizacije u B2B Next.js SaaS-u, Samioda može pregledati vaš data model, Stripe webhook flow i gating sloj end-to-end. Krenite s našim vodičem za Next.js authorization obrasce ili se javite putem naših usluga web developmenta.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

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