Paywall
Gate UI features and API routes by plan using requirePlan and the PlanGate component.
Overview
Nebutra provides two complementary mechanisms for enforcing plan-based access:
| Mechanism | Where to use |
|---|---|
requirePlan(planId) | Server-side: API route middleware, Server Components, Server Actions |
<PlanGate require="plan"> | Client-side: React components, conditional UI rendering |
Both read the current tenant's active plan from the database — no client-side state to spoof.
Server Component paywall
Use requirePlan in a Next.js Server Component to redirect users below the required plan:
import { requirePlan } from "@nebutra/billing";
import { getCurrentTenant } from "@nebutra/tenant";
export default async function AnalyticsPage() {
const tenant = getCurrentTenant();
// Redirects to /billing/upgrade if the tenant is not on PRO or higher
await requirePlan("pro", tenant.tenantId);
return <AnalyticsDashboard />;
}requirePlan behavior
| Tenant plan | Required plan | Result |
|---|---|---|
free | pro | Redirects to /billing/upgrade |
pro | pro | Passes through |
enterprise | pro | Passes through (enterprise ≥ pro) |
pro | enterprise | Redirects to /billing/upgrade |
Plan hierarchy is free < pro < enterprise. requirePlan("pro") allows both pro and enterprise tenants through.
API route gating
Protect API routes with requirePlan as middleware:
import { requirePlan } from "@nebutra/billing";
import { getCurrentTenant } from "@nebutra/tenant";
app.post("/api/v1/exports", async (c) => {
const tenant = getCurrentTenant();
// Returns 403 Forbidden with a structured error if plan is insufficient
await requirePlan("pro", tenant.tenantId, { throwOnFail: true });
// ... handle the export
});When throwOnFail: true, requirePlan throws a structured error that the API gateway error handler converts to:
HTTP 403 Forbidden
{
"success": false,
"error": {
"code": "PLAN_REQUIRED",
"message": "This feature requires the PRO plan.",
"details": {
"requiredPlan": "pro",
"currentPlan": "free",
"upgradeUrl": "https://app.nebutra.com/billing/upgrade"
}
}
}React <PlanGate> component
Use <PlanGate> to conditionally render UI based on the current tenant's plan. This is a Server Component — plan data is never exposed to the client.
import { PlanGate } from "@nebutra/billing/react";
// Hard block — shows an upgrade prompt when plan is insufficient
<PlanGate require="pro">
<AdvancedAnalytics />
</PlanGate>
// Custom fallback — show your own upgrade UI
<PlanGate require="pro" fallback={<UpgradeBanner feature="Advanced Analytics" />}>
<AdvancedAnalytics />
</PlanGate>
// Silent hide — renders nothing when plan is insufficient (no upgrade prompt)
<PlanGate require="pro" fallback={null}>
<ExportButton />
</PlanGate>Default upgrade prompt
When no fallback is provided, <PlanGate> renders the built-in upgrade prompt:
┌────────────────────────────────────────────────┐
│ 🔒 This feature requires the PRO plan. │
│ │
│ [Upgrade to PRO →] │
└────────────────────────────────────────────────┘The button links to /billing/upgrade.
Upgrade redirect target
The /billing/upgrade route creates a Stripe Checkout session and redirects the user. It is pre-built and requires no configuration beyond the environment variables.
If you want to customize the upgrade page (e.g., to show a plan comparison table before redirecting), edit:
apps/web/src/app/billing/upgrade/page.tsxShowing upgrade prompts vs hard blocks
Use the following heuristics to choose between a soft prompt and a hard block:
| Scenario | Recommendation |
|---|---|
| Feature is discoverable but gated (e.g., export button) | Soft prompt — show <PlanGate> with upgrade CTA |
| Route only makes sense on the paid plan (e.g., SSO settings) | Hard redirect via requirePlan in the Server Component |
| Sensitive data that must never reach FREE users | Hard block on both Server Component and API route |
| Feature preview / teaser | Render a disabled/locked version with tooltip via <PlanGate fallback={...}> |
Never rely on client-side plan checks alone. Always enforce access on the server using requirePlan. A motivated user can bypass any client-side guard.
Related
How is this guide?
Last updated on