Next.js Supabase V3 (#463)

Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

448
docs/api/account-api.mdoc Normal file
View File

@@ -0,0 +1,448 @@
---
status: "published"
label: "Account API"
order: 0
title: "Account API | Next.js Supabase SaaS Kit"
description: "Complete reference for the Account API in MakerKit. Manage personal accounts, subscriptions, billing customer IDs, and workspace data with type-safe methods."
---
The Account API is MakerKit's server-side service for managing personal user accounts. It provides methods to fetch subscription data, billing customer IDs, and account switcher information. Use it when building billing portals, feature gates, or account selection UIs. All methods are type-safe and respect Supabase RLS policies.
{% callout title="When to use Account API" %}
Use the Account API for: checking subscription status for feature gating, loading data for account switchers, accessing billing customer IDs for direct provider API calls. Use the Team Account API instead for team-based operations.
{% /callout %}
{% sequence title="Account API Reference" description="Learn how to use the Account API in MakerKit" %}
[Setup and initialization](#setup-and-initialization)
[getAccountWorkspace](#getaccountworkspace)
[loadUserAccounts](#loaduseraccounts)
[getSubscription](#getsubscription)
[getCustomerId](#getcustomerid)
[getOrder](#getorder)
[Real-world examples](#real-world-examples)
{% /sequence %}
## Setup and initialization
Import `createAccountsApi` from `@kit/accounts/api` and pass a Supabase server client. The client handles authentication automatically through RLS.
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function ServerComponent() {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
// Use API methods
}
```
In Server Actions:
```tsx
'use server';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function myServerAction() {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
// Use API methods
}
```
{% callout title="Request-scoped clients" %}
Always create the Supabase client and API instance inside your request handler, not at module scope. The client is tied to the current user's session.
{% /callout %}
## API Methods
### getAccountWorkspace
Returns the personal workspace data for the authenticated user. This includes account details, subscription status, and profile information.
```tsx
const workspace = await api.getAccountWorkspace();
```
**Returns:**
```tsx
{
id: string | null;
name: string | null;
picture_url: string | null;
public_data: Json | null;
subscription_status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused' | null;
}
```
**Usage notes:**
- Called automatically in the `/home/(user)` layout
- Cached per-request, so multiple calls are deduplicated
- Returns `null` values if the user has no personal account
---
### loadUserAccounts
Loads all accounts the user belongs to, formatted for account switcher components.
```tsx
const accounts = await api.loadUserAccounts();
```
**Returns:**
```tsx
Array<{
label: string; // Account display name
value: string; // Account ID or slug
image: string | null; // Account picture URL
}>
```
**Example: Build an account switcher**
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function AccountSwitcher() {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const accounts = await api.loadUserAccounts();
return (
<select>
{accounts.map((account) => (
<option key={account.value} value={account.value}>
{account.label}
</option>
))}
</select>
);
}
```
---
### getSubscription
Returns the subscription data for a given account, including all subscription items (line items).
```tsx
const subscription = await api.getSubscription(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The account UUID |
**Returns:**
```tsx
{
id: string;
account_id: string;
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused';
currency: string;
cancel_at_period_end: boolean;
period_starts_at: string;
period_ends_at: string;
trial_starts_at: string | null;
trial_ends_at: string | null;
items: Array<{
id: string;
subscription_id: string;
product_id: string;
variant_id: string;
type: 'flat' | 'per_seat' | 'metered';
quantity: number;
price_amount: number;
interval: 'month' | 'year';
interval_count: number;
}>;
} | null
```
**Example: Check subscription access**
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function checkPlanAccess(accountId: string, requiredPlan: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const subscription = await api.getSubscription(accountId);
if (!subscription) {
return { hasAccess: false, reason: 'no_subscription' };
}
if (subscription.status !== 'active' && subscription.status !== 'trialing') {
return { hasAccess: false, reason: 'inactive_subscription' };
}
const hasRequiredPlan = subscription.items.some(
(item) => item.product_id === requiredPlan
);
if (!hasRequiredPlan) {
return { hasAccess: false, reason: 'wrong_plan' };
}
return { hasAccess: true };
}
```
---
### getCustomerId
Returns the billing provider customer ID for an account. Use this when integrating with Stripe, Paddle, or Lemon Squeezy APIs directly.
```tsx
const customerId = await api.getCustomerId(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The account UUID |
**Returns:** `string | null`
**Example: Redirect to billing portal**
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createBillingPortalSession(accountId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const customerId = await api.getCustomerId(accountId);
if (!customerId) {
throw new Error('No billing customer found');
}
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
});
return session.url;
}
```
---
### getOrder
Returns one-time purchase order data for accounts using lifetime deals or credit-based billing.
```tsx
const order = await api.getOrder(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The account UUID |
**Returns:**
```tsx
{
id: string;
account_id: string;
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
status: 'pending' | 'completed' | 'refunded';
currency: string;
total_amount: number;
items: Array<{
product_id: string;
variant_id: string;
quantity: number;
price_amount: number;
}>;
} | null
```
---
## Real-world examples
### Feature gating based on subscription
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
type FeatureAccess = {
allowed: boolean;
reason?: string;
upgradeUrl?: string;
};
export async function canAccessFeature(
accountId: string,
feature: 'ai_assistant' | 'export' | 'api_access'
): Promise<FeatureAccess> {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const subscription = await api.getSubscription(accountId);
// No subscription means free tier
if (!subscription) {
const freeFeatures = ['export'];
if (freeFeatures.includes(feature)) {
return { allowed: true };
}
return {
allowed: false,
reason: 'This feature requires a paid plan',
upgradeUrl: '/pricing',
};
}
// Check if subscription is active
const activeStatuses = ['active', 'trialing'];
if (!activeStatuses.includes(subscription.status)) {
return {
allowed: false,
reason: 'Your subscription is not active',
upgradeUrl: '/settings/billing',
};
}
// Map features to required product IDs
const featureRequirements: Record<string, string[]> = {
ai_assistant: ['pro', 'enterprise'],
export: ['starter', 'pro', 'enterprise'],
api_access: ['enterprise'],
};
const requiredProducts = featureRequirements[feature] || [];
const userProducts = subscription.items.map((item) => item.product_id);
const hasAccess = requiredProducts.some((p) => userProducts.includes(p));
if (!hasAccess) {
return {
allowed: false,
reason: 'This feature requires a higher plan',
upgradeUrl: '/pricing',
};
}
return { allowed: true };
}
```
### Server Action with subscription check
```tsx
'use server';
import * as z from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const GenerateReportSchema = z.object({
accountId: z.string().uuid(),
reportType: z.enum(['summary', 'detailed', 'export']),
});
export const generateReport = authActionClient
.inputSchema(GenerateReportSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
// Check subscription before expensive operation
const subscription = await api.getSubscription(data.accountId);
const isProUser = subscription?.items.some(
(item) => item.product_id === 'pro' || item.product_id === 'enterprise'
);
if (data.reportType === 'detailed' && !isProUser) {
return {
success: false,
error: 'Detailed reports require a Pro subscription',
};
}
// Generate report...
return { success: true, reportUrl: '/reports/123' };
});
```
## Common pitfalls
### Creating client at module scope
```tsx
// WRONG: Client created at module scope
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
export async function handler() {
const subscription = await api.getSubscription(accountId); // Won't work
}
// RIGHT: Client created in request context
export async function handler() {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const subscription = await api.getSubscription(accountId);
}
```
### Forgetting to handle null subscriptions
```tsx
// WRONG: Assumes subscription exists
const subscription = await api.getSubscription(accountId);
const plan = subscription.items[0].product_id; // Crashes if null
// RIGHT: Handle null case
const subscription = await api.getSubscription(accountId);
if (!subscription) {
return { plan: 'free' };
}
const plan = subscription.items[0]?.product_id ?? 'free';
```
### Confusing account ID with user ID
The Account API expects account UUIDs, not user UUIDs. For personal accounts, the account ID is the same as the user ID, but for team accounts they differ.
## Related documentation
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team account management
- [User Workspace API](/docs/next-supabase-turbo/api/user-workspace-api) - Workspace context for layouts
- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Stripe and payment setup

View File

@@ -0,0 +1,473 @@
---
status: "published"
label: "Team Workspace API"
order: 4
title: "Team Workspace API | Next.js Supabase SaaS Kit"
description: "Access team account context in MakerKit layouts. Load team data, member permissions, subscription status, and role hierarchy with the Team Workspace API."
---
The Team Workspace API provides team account context for pages under `/home/[account]`. It loads team data, the user's role and permissions, subscription status, and all accounts the user belongs to, making this information available to both server and client components.
{% sequence title="Team Workspace API Reference" description="Access team workspace data in layouts and components" %}
[loadTeamWorkspace (Server)](#loadteamworkspace-server)
[useTeamAccountWorkspace (Client)](#useteamaccountworkspace-client)
[Data structure](#data-structure)
[Usage patterns](#usage-patterns)
{% /sequence %}
## loadTeamWorkspace (Server)
Loads the team workspace data for the specified team account. Use this in Server Components within the `/home/[account]` route group.
```tsx
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
export default async function TeamDashboard({
params,
}: {
params: { account: string };
}) {
const data = await loadTeamWorkspace();
return (
<div>
<h1>{data.account.name}</h1>
<p>Your role: {data.account.role}</p>
</div>
);
}
```
### Function signature
```tsx
async function loadTeamWorkspace(): Promise<TeamWorkspaceData>
```
### How it works
The loader reads the `account` parameter from the URL (the team slug) and fetches:
1. Team account details from the database
2. Current user's role and permissions in this team
3. All accounts the user belongs to (for the account switcher)
### Caching behavior
The function uses React's `cache()` to deduplicate calls within a single request. You can call it multiple times in nested components without additional database queries.
```tsx
// Both calls use the same cached data
const layout = await loadTeamWorkspace(); // First call: hits database
const page = await loadTeamWorkspace(); // Second call: returns cached data
```
{% callout title="Performance consideration" %}
While calls are deduplicated within a request, the data is fetched on every navigation. For frequently accessed data, the caching prevents redundant queries within a single page render.
{% /callout %}
---
## useTeamAccountWorkspace (Client)
Access the team workspace data in client components using the `useTeamAccountWorkspace` hook. The data is provided through React Context from the layout.
```tsx
'use client';
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
export function TeamHeader() {
const { account, user, accounts } = useTeamAccountWorkspace();
return (
<header className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
{account.picture_url && (
<img
src={account.picture_url}
alt={account.name}
className="h-8 w-8 rounded"
/>
)}
<div>
<h1 className="font-semibold">{account.name}</h1>
<p className="text-xs text-muted-foreground">
{account.role} · {account.subscription_status || 'Free'}
</p>
</div>
</div>
</header>
);
}
```
{% callout type="warning" title="Context requirement" %}
The `useTeamAccountWorkspace` hook only works within the `/home/[account]` route group where the context provider is set up. Using it outside this layout will throw an error.
{% /callout %}
---
## Data structure
### TeamWorkspaceData
```tsx
import type { User } from '@supabase/supabase-js';
interface TeamWorkspaceData {
account: {
id: string;
name: string;
slug: string;
picture_url: string | null;
role: string;
role_hierarchy_level: number;
primary_owner_user_id: string;
subscription_status: SubscriptionStatus | null;
permissions: string[];
};
user: User;
accounts: Array<{
id: string | null;
name: string | null;
picture_url: string | null;
role: string | null;
slug: string | null;
}>;
}
```
### account.role
The user's role in this team. Default roles:
| Role | Description |
|------|-------------|
| `owner` | Full access, can delete team |
| `admin` | Manage members and settings |
| `member` | Standard access |
### account.role_hierarchy_level
A numeric value where lower numbers indicate higher privilege. Use this for role comparisons:
```tsx
const { account } = useTeamAccountWorkspace();
// Check if user can manage someone with role_level 2
const canManage = account.role_hierarchy_level < 2;
```
### account.permissions
An array of permission strings the user has in this team:
```tsx
[
'billing.manage',
'members.invite',
'members.remove',
'members.manage',
'settings.manage',
]
```
### subscription_status values
| Status | Description |
|--------|-------------|
| `active` | Active subscription |
| `trialing` | In trial period |
| `past_due` | Payment failed, grace period |
| `canceled` | Subscription canceled |
| `unpaid` | Payment required |
| `incomplete` | Setup incomplete |
| `incomplete_expired` | Setup expired |
| `paused` | Subscription paused |
---
## Usage patterns
### Permission-based rendering
```tsx
'use client';
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
interface PermissionGateProps {
children: React.ReactNode;
permission: string;
fallback?: React.ReactNode;
}
export function PermissionGate({
children,
permission,
fallback = null,
}: PermissionGateProps) {
const { account } = useTeamAccountWorkspace();
if (!account.permissions.includes(permission)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Usage
function TeamSettingsPage() {
return (
<div>
<h1>Team Settings</h1>
<PermissionGate
permission="settings.manage"
fallback={<p>You don't have permission to manage settings.</p>}
>
<SettingsForm />
</PermissionGate>
<PermissionGate permission="billing.manage">
<BillingSection />
</PermissionGate>
</div>
);
}
```
### Team dashboard with role checks
```tsx
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function TeamDashboardPage() {
const { account, user } = await loadTeamWorkspace();
const client = getSupabaseServerClient();
const isOwner = account.primary_owner_user_id === user.id;
const isAdmin = account.role === 'admin' || account.role === 'owner';
// Fetch team-specific data
const { data: projects } = await client
.from('projects')
.select('*')
.eq('account_id', account.id)
.order('created_at', { ascending: false })
.limit(10);
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{account.name}</h1>
<p className="text-muted-foreground">
{account.subscription_status === 'active'
? 'Pro Plan'
: 'Free Plan'}
</p>
</div>
{isAdmin && (
<a
href={`/home/${account.slug}/settings`}
className="btn btn-secondary"
>
Team Settings
</a>
)}
</header>
<section>
<h2 className="text-lg font-medium">Recent Projects</h2>
<ul className="mt-2 space-y-2">
{projects?.map((project) => (
<li key={project.id}>
<a href={`/home/${account.slug}/projects/${project.id}`}>
{project.name}
</a>
</li>
))}
</ul>
</section>
{isOwner && (
<section className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<h2 className="font-medium text-destructive">Danger Zone</h2>
<p className="mt-1 text-sm text-muted-foreground">
Only the team owner can delete this team.
</p>
<button className="mt-3 btn btn-destructive">Delete Team</button>
</section>
)}
</div>
);
}
```
### Team members list with permissions
```tsx
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function TeamMembersPage() {
const { account } = await loadTeamWorkspace();
const client = getSupabaseServerClient();
const canManageMembers = account.permissions.includes('members.manage');
const canRemoveMembers = account.permissions.includes('members.remove');
const canInviteMembers = account.permissions.includes('members.invite');
const { data: members } = await client
.from('accounts_memberships')
.select(`
user_id,
role,
created_at,
users:user_id (
email,
user_metadata
)
`)
.eq('account_id', account.id);
return (
<div>
<header className="flex items-center justify-between">
<h1>Team Members</h1>
{canInviteMembers && (
<a href={`/home/${account.slug}/settings/members/invite`}>
Invite Member
</a>
)}
</header>
<table className="w-full">
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Joined</th>
{(canManageMembers || canRemoveMembers) && <th>Actions</th>}
</tr>
</thead>
<tbody>
{members?.map((member) => (
<tr key={member.user_id}>
<td>{member.users?.email}</td>
<td>{member.role}</td>
<td>{new Date(member.created_at).toLocaleDateString()}</td>
{(canManageMembers || canRemoveMembers) && (
<td>
{canManageMembers && member.user_id !== account.primary_owner_user_id && (
<button>Change Role</button>
)}
{canRemoveMembers && member.user_id !== account.primary_owner_user_id && (
<button>Remove</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
```
### Client-side permission hook
```tsx
'use client';
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
export function useTeamPermissions() {
const { account } = useTeamAccountWorkspace();
return {
canManageSettings: account.permissions.includes('settings.manage'),
canManageBilling: account.permissions.includes('billing.manage'),
canInviteMembers: account.permissions.includes('members.invite'),
canRemoveMembers: account.permissions.includes('members.remove'),
canManageMembers: account.permissions.includes('members.manage'),
isOwner: account.role === 'owner',
isAdmin: account.role === 'admin' || account.role === 'owner',
role: account.role,
roleLevel: account.role_hierarchy_level,
};
}
// Usage
function TeamActions() {
const permissions = useTeamPermissions();
return (
<div className="flex gap-2">
{permissions.canInviteMembers && (
<button>Invite Member</button>
)}
{permissions.canManageSettings && (
<button>Settings</button>
)}
{permissions.canManageBilling && (
<button>Billing</button>
)}
</div>
);
}
```
### Subscription-gated features
```tsx
'use client';
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
export function PremiumFeature({ children }: { children: React.ReactNode }) {
const { account } = useTeamAccountWorkspace();
const hasActiveSubscription =
account.subscription_status === 'active' ||
account.subscription_status === 'trialing';
if (!hasActiveSubscription) {
return (
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<h3 className="font-medium">Premium Feature</h3>
<p className="mt-1 text-sm text-muted-foreground">
Upgrade to access this feature
</p>
<a
href={`/home/${account.slug}/settings/billing`}
className="mt-3 inline-block btn btn-primary"
>
Upgrade Plan
</a>
</div>
);
}
return <>{children}</>;
}
```
## Related documentation
- [User Workspace API](/docs/next-supabase-turbo/api/user-workspace-api) - Personal account context
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team operations
- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication
- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing

View File

@@ -0,0 +1,531 @@
---
status: "published"
label: "Authentication API"
order: 2
title: "Authentication API | Next.js Supabase SaaS Kit"
description: "Complete reference for authentication in MakerKit. Use requireUser for server-side auth checks, handle MFA verification, and access user data in client components."
---
The Authentication API verifies user identity, handles MFA (Multi-Factor Authentication), and provides user data to your components. Use `requireUser` on the server for protected routes and `useUser` on the client for reactive user state.
{% sequence title="Authentication API Reference" description="Learn how to authenticate users in MakerKit" %}
[requireUser (Server)](#requireuser-server)
[useUser (Client)](#useuser-client)
[useSupabase (Client)](#usesupabase-client)
[MFA handling](#mfa-handling)
[Common patterns](#common-patterns)
{% /sequence %}
## requireUser (Server)
The `requireUser` function checks authentication status in Server Components, Server Actions, and Route Handlers. It handles both standard auth and MFA verification in a single call.
```tsx
import { redirect } from 'next/navigation';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function ProtectedPage() {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (auth.error) {
redirect(auth.redirectTo);
}
const user = auth.data;
return <div>Welcome, {user.email}</div>;
}
```
### Function signature
```tsx
function requireUser(
client: SupabaseClient,
options?: {
verifyMfa?: boolean; // Default: true
}
): Promise<RequireUserResponse>
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `client` | `SupabaseClient` | required | Supabase server client |
| `options.verifyMfa` | `boolean` | `true` | Check MFA status |
### Response types
**Success response:**
```tsx
{
data: {
id: string; // User UUID
email: string; // User email
phone: string; // User phone (if set)
is_anonymous: boolean; // Anonymous auth flag
aal: 'aal1' | 'aal2'; // Auth Assurance Level
app_metadata: Record<string, unknown>;
user_metadata: Record<string, unknown>;
amr: AMREntry[]; // Auth Methods Reference
};
error: null;
}
```
**Error response:**
```tsx
{
data: null;
error: AuthenticationError | MultiFactorAuthError;
redirectTo: string; // Where to redirect the user
}
```
### Auth Assurance Levels (AAL)
| Level | Meaning |
|-------|---------|
| `aal1` | Basic authentication (password, magic link, OAuth) |
| `aal2` | MFA verified (TOTP app, etc.) |
### Error types
| Error | Cause | Redirect |
|-------|-------|----------|
| `AuthenticationError` | User not logged in | Sign-in page |
| `MultiFactorAuthError` | MFA required but not verified | MFA verification page |
### Usage in Server Components
```tsx
import { redirect } from 'next/navigation';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function DashboardPage() {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (auth.error) {
redirect(auth.redirectTo);
}
return (
<div>
<h1>Dashboard</h1>
<p>Logged in as: {auth.data.email}</p>
<p>MFA status: {auth.data.aal === 'aal2' ? 'Verified' : 'Not verified'}</p>
</div>
);
}
```
### Usage in Server Actions
```tsx
'use server';
import { redirect } from 'next/navigation';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function updateProfile(formData: FormData) {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (auth.error) {
redirect(auth.redirectTo);
}
const name = formData.get('name') as string;
await client
.from('profiles')
.update({ name })
.eq('id', auth.data.id);
return { success: true };
}
```
### Skipping MFA verification
For pages that don't require full MFA verification:
```tsx
const auth = await requireUser(client, { verifyMfa: false });
```
{% callout type="warning" title="MFA security" %}
Only disable MFA verification for non-sensitive pages. Always verify MFA for billing, account deletion, and other high-risk operations.
{% /callout %}
---
## useUser (Client)
The `useUser` hook provides reactive access to user data in client components. It reads from the auth context and updates automatically on auth state changes.
```tsx
'use client';
import { useUser } from '@kit/supabase/hooks/use-user';
function UserMenu() {
const user = useUser();
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<span>{user.email}</span>
<img src={user.user_metadata.avatar_url} alt="Avatar" />
</div>
);
}
```
### Return type
```tsx
User | null
```
The `User` type from Supabase includes:
```tsx
{
id: string;
email: string;
phone: string;
created_at: string;
updated_at: string;
app_metadata: {
provider: string;
providers: string[];
};
user_metadata: {
avatar_url?: string;
full_name?: string;
// Custom metadata fields
};
aal?: 'aal1' | 'aal2';
}
```
### Conditional rendering
```tsx
'use client';
import { useUser } from '@kit/supabase/hooks/use-user';
function ConditionalContent() {
const user = useUser();
// Show loading state
if (user === undefined) {
return <Skeleton />;
}
// Not authenticated
if (!user) {
return <LoginPrompt />;
}
// Authenticated
return <UserDashboard user={user} />;
}
```
---
## useSupabase (Client)
The `useSupabase` hook provides the Supabase browser client for client-side operations.
```tsx
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useQuery } from '@tanstack/react-query';
function TaskList() {
const supabase = useSupabase();
const { data: tasks } = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
});
return (
<ul>
{tasks?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
```
---
## MFA handling
MakerKit automatically handles MFA verification through the `requireUser` function.
### How it works
1. User logs in with password/OAuth (reaches `aal1`)
2. If MFA is enabled, `requireUser` checks AAL
3. If `aal1` but MFA required, redirects to MFA verification
4. After TOTP verification, user reaches `aal2`
5. Protected pages now accessible
### MFA flow diagram
```
Login → aal1 → requireUser() → MFA enabled?
Yes: redirect to /auth/verify
User enters TOTP
aal2 → Access granted
```
### Checking MFA status
```tsx
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function checkMfaStatus() {
const client = getSupabaseServerClient();
const auth = await requireUser(client, { verifyMfa: false });
if (auth.error) {
return { authenticated: false };
}
return {
authenticated: true,
mfaEnabled: auth.data.aal === 'aal2',
authMethods: auth.data.amr.map((m) => m.method),
};
}
```
---
## Common patterns
### Protected API Route Handler
```tsx
// app/api/user/route.ts
import { NextResponse } from 'next/server';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function GET() {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (auth.error) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { data: profile } = await client
.from('profiles')
.select('*')
.eq('id', auth.data.id)
.single();
return NextResponse.json({ user: auth.data, profile });
}
```
### Using authActionClient (recommended)
The `authActionClient` utility handles authentication automatically:
```tsx
'use server';
import * as z from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const UpdateProfileSchema = z.object({
name: z.string().min(2),
});
export const updateProfile = authActionClient
.inputSchema(UpdateProfileSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// user is automatically available and typed
const client = getSupabaseServerClient();
await client
.from('profiles')
.update({ name: data.name })
.eq('id', user.id);
return { success: true };
});
```
### Public actions (no auth)
```tsx
import { publicActionClient } from '@kit/next/safe-action';
export const submitContactForm = publicActionClient
.inputSchema(ContactFormSchema)
.action(async ({ parsedInput: data }) => {
// No user context in public actions
await sendEmail(data);
return { success: true };
});
```
### Role-based access control
Combine authentication with role checks:
```tsx
import { redirect } from 'next/navigation';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isSuperAdmin } from '@kit/admin';
async function AdminPage() {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (auth.error) {
redirect(auth.redirectTo);
}
const isAdmin = await isSuperAdmin(client);
if (!isAdmin) {
redirect('/home');
}
return <AdminDashboard />;
}
```
### Auth state listener (Client)
For real-time auth state changes:
```tsx
'use client';
import { useEffect } from 'react';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
function AuthStateListener({ onAuthChange }) {
const supabase = useSupabase();
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') {
onAuthChange({ type: 'signed_in', user: session?.user });
} else if (event === 'SIGNED_OUT') {
onAuthChange({ type: 'signed_out' });
} else if (event === 'TOKEN_REFRESHED') {
onAuthChange({ type: 'token_refreshed' });
}
});
return () => subscription.unsubscribe();
}, [supabase, onAuthChange]);
return null;
}
```
## Common mistakes
### Creating client at module scope
```tsx
// WRONG: Client created at module scope
const client = getSupabaseServerClient();
export async function handler() {
const auth = await requireUser(client); // Won't work
}
// RIGHT: Client created in request context
export async function handler() {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
}
```
### Ignoring the redirectTo property
```tsx
// WRONG: Not using redirectTo
if (auth.error) {
redirect('/login'); // MFA users sent to wrong page
}
// RIGHT: Use the provided redirectTo
if (auth.error) {
redirect(auth.redirectTo); // Correct handling for auth + MFA
}
```
### Using useUser for server-side checks
```tsx
// WRONG: useUser is client-only
export async function ServerComponent() {
const user = useUser(); // Won't work
}
// RIGHT: Use requireUser on server
export async function ServerComponent() {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
}
```
## Related documentation
- [Account API](/docs/next-supabase-turbo/api/account-api) - Personal account operations
- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Using authActionClient
- [Route Handlers](/docs/next-supabase-turbo/data-fetching/route-handlers) - API authentication

302
docs/api/otp-api.mdoc Normal file
View File

@@ -0,0 +1,302 @@
---
status: "published"
label: "OTP API"
order: 5
title: "OTP API | Next.js Supabase SaaS Kit"
description: "Generate and verify one-time passwords for secure operations in MakerKit. Use the OTP API for account deletion, ownership transfers, and other high-risk actions."
---
The OTP API generates and verifies one-time passwords for secure operations like account deletion, ownership transfers, and email verification. It uses Supabase for secure token storage with automatic expiration and verification tracking.
{% sequence title="How to use the OTP API" description="Learn how to use the OTP API in Makerkit" %}
[OTP API - What is it for?](#otp-api---what-is-it-for)
[Installation](#installation)
[Basic Usage](#basic-usage)
[Server Actions](#server-actions)
[Verification UI Component](#verification-ui-component)
[API Reference](#api-reference)
[Database Schema](#database-schema)
[Best Practices](#best-practices)
[Example Use Cases](#example-use-cases)
{% /sequence %}
It is used for various destructive actions in the SaaS Kit, such as deleting
accounts, deleting teams, and deleting users. However, you can use it for a
variety of other purposes as well, such as:
- Your custom destructive actions
- oAuth account connections
- etc.
## OTP API - What is it for?
The OTP package offers:
- **Secure Token Generation**: Create time-limited tokens with configurable expiration
- **Email Delivery**: Send OTP codes via email with customizable templates
- **Verification UI**: Ready-to-use verification form component
- **Token Management**: Revoke, verify, and check token status
## Installation
If you're using Makerkit, this package is already included. For manual installation:
```bash
pnpm add @kit/otp
```
## Basic Usage
### Creating and Sending an OTP
To create and send an OTP, you can use the `createToken` method:
```typescript
import { createOtpApi } from '@kit/otp/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// Create the API instance
const client = getSupabaseServerClient();
const api = createOtpApi(client);
// Generate and send an OTP email
await api.createToken({
userId: user.id,
purpose: 'email-verification',
expiresInSeconds: 3600, // 1 hour
metadata: { redirectTo: '/verify-email' }
});
// Send the email with the OTP
await api.sendOtpEmail({
email: userEmail,
otp: token.token
});
```
### Verifying an OTP
To verify an OTP, you can use the `verifyToken` method:
```typescript
// Verify the token
const result = await api.verifyToken({
token: submittedToken,
purpose: 'email-verification'
});
if (result.valid) {
// Token is valid, proceed with the operation
const { userId, metadata } = result;
// Handle successful verification
} else {
// Token is invalid or expired
// Handle verification failure
}
```
## Server Actions
The package includes a ready-to-use server action for sending OTP emails:
```typescript
import { sendOtpEmailAction } from '@kit/otp/server/server-actions';
// In a form submission handler
const result = await sendOtpEmailAction({
email: userEmail,
purpose: 'password-reset',
expiresInSeconds: 1800 // 30 minutes
});
if (result.success) {
// OTP was sent successfully
} else {
// Handle error
}
```
**Note:** The `email` parameter is only used as verification mechanism, the actual email address being used is the one associated with the user.
## Verification UI Component
The package includes a ready-to-use OTP verification form:
```tsx
import { VerifyOtpForm } from '@kit/otp/components';
function MyVerificationPage() {
return (
<VerifyOtpForm
purpose="password-reset"
email={userEmail}
onSuccess={(otp) => {
// Handle successful verification
// Use the OTP for verification on the server
}}
CancelButton={
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
}
/>
);
}
```
## API Reference
### `createOtpApi(client)`
Creates an instance of the OTP API.
**Parameters**:
- `client`: A Supabase client instance
- **Returns**: OTP API instance with the following methods:
### `api.createToken(params)`
Creates a new one-time token.
**Parameters**:
- `params.userId` (optional): User ID to associate with the token
- `params.purpose`: Purpose of the token (e.g., 'password-reset')
- `params.expiresInSeconds` (optional): Token expiration time in seconds (default: 3600)
- `params.metadata` (optional): Additional data to store with the token
- `params.description` (optional): Description of the token
- `params.tags` (optional): Array of string tags
- `params.scopes` (optional): Array of permission scopes
- `params.revokePrevious` (optional): Whether to revoke previous tokens with the same purpose (default: true)
**Returns**:
```typescript
{
id: string; // Database ID of the token
token: string; // The actual token to send to the user
expiresAt: string; // Expiration timestamp
revokedPreviousCount: number; // Number of previously revoked tokens
}
```
### `api.verifyToken(params)`
Verifies a one-time token.
**Parameters**:
- `params.token`: The token to verify
- `params.purpose`: Purpose of the token (must match the purpose used when creating)
- `params.userId` (optional): User ID for additional verification
- `params.requiredScopes` (optional): Array of required permission scopes
- `params.maxVerificationAttempts` (optional): Maximum allowed verification attempts
**Returns**:
```typescript
{
valid: boolean; // Whether the token is valid
userId?: string; // User ID associated with the token (if valid)
metadata?: object; // Metadata associated with the token (if valid)
message?: string; // Error message (if invalid)
scopes?: string[]; // Permission scopes (if valid)
purpose?: string; // Token purpose (if valid)
}
```
### `api.revokeToken(params)`
Revokes a token to prevent its future use.
**Parameters**:
- `params.id`: ID of the token to revoke
- `params.reason` (optional): Reason for revocation
**Returns**:
```typescript
{
success: boolean; // Whether the token was successfully revoked
}
```
### `api.getTokenStatus(params)`
Gets the status of a token.
**Parameters**:
- `params.id`: ID of the token
**Returns**:
```typescript
{
exists: boolean; // Whether the token exists
purpose?: string; // Token purpose
userId?: string; // User ID associated with the token
createdAt?: string; // Creation timestamp
expiresAt?: string; // Expiration timestamp
usedAt?: string; // When the token was used (if used)
revoked?: boolean; // Whether the token is revoked
revokedReason?: string; // Reason for revocation (if revoked)
verificationAttempts?: number; // Number of verification attempts
lastVerificationAt?: string; // Last verification attempt timestamp
lastVerificationIp?: string; // IP address of last verification attempt
isValid?: boolean; // Whether the token is still valid
}
```
### `api.sendOtpEmail(params)`
Sends an email containing the OTP code.
**Parameters**:
- `params.email`: Email address to send to
- `params.otp`: OTP code to include in the email
**Returns**: Promise that resolves when the email is sent
## Database Schema
The package uses a `nonces` table in your Supabase database with the following structure:
- `id`: UUID primary key
- `client_token`: Hashed token sent to client
- `nonce`: Securely stored token hash
- `user_id`: Optional reference to auth.users
- `purpose`: Purpose identifier (e.g., 'password-reset')
- Status fields: `expires_at`, `created_at`, `used_at`, etc.
- Audit fields: `verification_attempts`, `last_verification_at`, etc.
- Extensibility fields: `metadata`, `scopes`
## Best Practices
1. **Use Specific Purposes**: Always use descriptive, specific purpose identifiers for your tokens.
2. **Short Expiration Times**: Set token expiration times to the minimum necessary for your use case.
3. **Handle Verification Failures**: Provide clear error messages when verification fails.
4. **Secure Your Tokens**: Never log or expose tokens in client-side code or URLs.
## Example Use Cases
- Email verification
- Two-factor authentication
- Account deletion confirmation
- Important action verification
Each use case should use a distinct purpose identifier. The purpose will
always need to match the one used when creating the token.
When you need to assign a specific data to a token, you can modify the
purpose with a unique identifier, such as `email-verification-12345`.
## Related documentation
- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication and session handling
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team management for ownership transfers
- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration) - Configure email delivery for OTP codes
- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Use OTP verification in server actions

551
docs/api/policies-api.mdoc Normal file
View File

@@ -0,0 +1,551 @@
---
label: "Feature Policies API"
title: "Feature Policies API | Next.js Supabase SaaS Kit"
order: 7
status: "published"
description: "Build declarative business rules with MakerKit's Feature Policies API. Validate team invitations, enforce subscription limits, and create custom authorization flows."
---
The Feature Policy API isolates validation and authorization logic from application code so every feature can reuse consistent, auditable policies.
**Makerkit is built for extensibility**: customers should expand features without patching internals unless they are opting into special cases. The Feature Policy API delivers that promise by turning customization into additive policies instead of edits to core flows.
**Important**: Feature Policies operates at the API and application surface level. It orchestrates business logic, user experience flows, and feature access decisions. For data integrity and security enforcement, **continue using database constraints, Supabase RLS policies, and transactional safeguards as your source of truth**.
## What It's For
- **Application logic**: User flows, feature access, business rule validation
- **API orchestration**: Request processing, workflow coordination, conditional routing
- **User experience**: Dynamic UI behavior, progressive disclosure, personalization
- **Integration patterns**: Third-party service coordination, webhook processing
## What It's NOT For
- **Data integrity**: Use database constraints and foreign keys
- **Security enforcement**: Use Supabase RLS policies and authentication
- **Performance-critical paths**: Use database indexes and query optimization
- **Transactional consistency**: Use database transactions and ACID guarantees
## Key Benefits
- Apply nuanced rules without coupling them to route handlers or services
- Share policy logic across server actions, mutations, and background jobs
- Test policies in isolation while keeping runtime orchestration predictable
- Layer customer-specific extensions on top of Makerkit defaults
## How We Use It Today
Makerkit currently uses the Feature Policy API for team invitation flows to validate **when a team can send invitations**. While supporting customers implement various flows for invitations, it was clear that the SaaS Starter Kit could not assume what rules you want to apply to invitations.
- Some customers wanted to validate the email address of the invited user (ex. validate they all shared the same domain)
- A set of customers wanted only users on a specific plan to be able to invite users (ex. only Pro users can invite users)
- Others simply wanted to limit how many invitations can be sent on a per-plan basis (ex. only 5 invitations can be sent on on a free plan, 20 on a paid plan, etc.)
These rules required a more declarative approach - which is why we created the Policies API - so that users can layer their own requirements without the need to rewrite internals.
Additional features can opt in to the same registry pattern to unlock the shared orchestration and extension tooling.
## Why Feature Policies?
A SaaS starter kit must adapt to **diverse customer requirements** without creating divergent forks.
Imperative checks embedded in controllers quickly become brittle: every variation requires new conditionals, feature flags, or early returns scattered across files.
The Feature Policy API keeps the rule set declarative and centralized, **so product teams can swap, reorder, or extend policies without rewriting the baseline flow**.
Registries turn policy changes into configuration instead of refactors, making it safer for customers to customize logic while continuing to receive upstream updates from Makerkit.
## Overview
The Feature Policy API provides:
- **Feature-specific registries** for organized policy management per feature
- **Configuration support** so policies can accept typed configuration objects
- **Stage-aware evaluation** enabling policies to be filtered by execution stage
- **Immutable contexts** that keep policy execution safe and predictable
- **Perfect DX** through a unified API that just works
## Quick Start
### 1. Create a Feature-Specific Registry
```typescript
import {
createPolicyRegistry,
definePolicy,
createPoliciesEvaluator,
allow,
deny,
} from '@kit/policies';
// Create feature-specific registry
const invitationPolicyRegistry = createPolicyRegistry();
// Register policies
invitationPolicyRegistry.registerPolicy(
definePolicy({
id: 'email-validation',
stages: ['preliminary', 'submission'],
evaluate: async (context) => {
if (!context.invitations.some((inv) => inv.email?.includes('@'))) {
return deny({
code: 'INVALID_EMAIL_FORMAT',
message: 'Invalid email format',
remediation: 'Please provide a valid email address',
});
}
return allow();
},
}),
);
// Register configurable policy
invitationPolicyRegistry.registerPolicy(
definePolicy({
id: 'max-invitations',
stages: ['preliminary', 'submission'],
configSchema: z.object({
maxInvitations: z.number().positive(),
}),
evaluate: async (context, config = { maxInvitations: 5 }) => {
if (context.invitations.length > config.maxInvitations) {
return deny({
code: 'MAX_INVITATIONS_EXCEEDED',
message: `Cannot invite more than ${config.maxInvitations} members`,
remediation: `Reduce invitations to ${config.maxInvitations} or fewer`,
});
}
return allow();
},
}),
);
```
### 2. Create a Feature Policy Evaluator
```typescript
export function createInvitationsPolicyEvaluator() {
const evaluator = createPoliciesEvaluator();
return {
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
},
async canInvite(context, stage: 'preliminary' | 'submission') {
return evaluator.evaluate(invitationPolicyRegistry, context, 'ALL', stage);
},
};
}
```
### 3. Use the Policy Evaluator
```typescript
import { createInvitationsPolicyEvaluator } from './your-policies';
async function validateInvitations(context) {
const evaluator = createInvitationsPolicyEvaluator();
// Performance optimization: only build context if policies exist
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
if (!hasPolicies) {
return; // No policies to evaluate
}
const result = await evaluator.canInvite(context, 'submission');
if (!result.allowed) {
throw new Error(result.reasons.join(', '));
}
}
```
## Error Handling
The `deny()` helper supports both simple strings and structured errors.
### String Errors (Simple)
```typescript
return deny('Email validation failed');
```
### Structured Errors (Enhanced)
```typescript
return deny({
code: 'INVALID_EMAIL_FORMAT',
message: 'Email validation failed',
remediation: 'Please provide a valid email address',
metadata: { fieldName: 'email' },
});
```
### Accessing Error Details
```typescript
const result = await evaluator.canInvite(context, 'submission');
if (!result.allowed) {
console.log('Reasons:', result.reasons);
result.results.forEach((policyResult) => {
if (!policyResult.allowed && policyResult.metadata) {
console.log('Error code:', policyResult.metadata.code);
console.log('Remediation:', policyResult.metadata.remediation);
}
});
}
```
## Performance Optimizations
### 1. Lazy Context Building
Only build expensive context when policies exist:
```typescript
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
if (!hasPolicies) {
return; // Skip expensive operations
}
// Build context now that policies need to run
const context = await buildExpensiveContext();
const result = await evaluator.canInvite(context, 'submission');
```
### 2. Stage-Aware Evaluation
Filter policies by execution stage:
```typescript
// Fast preliminary checks
const prelimResult = await evaluator.canInvite(context, 'preliminary');
// Full submission validation
const submitResult = await evaluator.canInvite(context, 'submission');
```
### 3. AND/OR Logic
Control evaluation behavior:
```typescript
// ALL: Every policy must pass (default)
const result = await evaluator.evaluate(registry, context, 'ALL', stage);
// ANY: At least one policy must pass
const result = await evaluator.evaluate(registry, context, 'ANY', stage);
```
## Real-World Example: Team Invitations
Makerkit uses the Feature Policy API to power team invitation rules.
```typescript
// packages/features/team-accounts/src/server/policies/invitation-policies.ts
import { allow, definePolicy, deny } from '@kit/policies';
import { createPolicyRegistry } from '@kit/policies';
import { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
/**
* Feature-specific registry for invitation policies
*/
export const invitationPolicyRegistry = createPolicyRegistry();
/**
* Subscription required policy
* Checks if the account has an active subscription
*/
export const subscriptionRequiredInvitationsPolicy =
definePolicy<FeaturePolicyInvitationContext>({
id: 'subscription-required',
stages: ['preliminary', 'submission'],
evaluate: async ({ subscription }) => {
if (!subscription || !subscription.active) {
return deny({
code: 'SUBSCRIPTION_REQUIRED',
message: 'teams.policyErrors.subscriptionRequired',
remediation: 'teams.policyRemediation.subscriptionRequired',
});
}
return allow();
},
});
/**
* Paddle billing policy
* Checks if the account has a paddle subscription and is in a trial period
*/
export const paddleBillingInvitationsPolicy =
definePolicy<FeaturePolicyInvitationContext>({
id: 'paddle-billing',
stages: ['preliminary', 'submission'],
evaluate: async ({ subscription }) => {
// combine with subscriptionRequiredPolicy if subscription must be required
if (!subscription) {
return allow();
}
// Paddle specific constraint: cannot update subscription items during trial
if (
subscription.provider === 'paddle' &&
subscription.status === 'trialing'
) {
const hasPerSeatItems = subscription.items.some(
(item) => item.type === 'per_seat',
);
if (hasPerSeatItems) {
return deny({
code: 'PADDLE_TRIAL_RESTRICTION',
message: 'teams.policyErrors.paddleTrialRestriction',
remediation: 'teams.policyRemediation.paddleTrialRestriction',
});
}
}
return allow();
},
});
// Register policies to apply them
invitationPolicyRegistry.registerPolicy(subscriptionRequiredInvitationsPolicy);
invitationPolicyRegistry.registerPolicy(paddleBillingInvitationsPolicy);
export function createInvitationsPolicyEvaluator() {
const evaluator = createPoliciesEvaluator();
return {
async hasPoliciesForStage(stage: 'preliminary' | 'submission') {
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
},
async canInvite(context, stage: 'preliminary' | 'submission') {
return evaluator.evaluate(invitationPolicyRegistry, context, 'ALL', stage);
},
};
}
```
## Customer Extension Pattern
Customers can extend policies by creating their own registries, adding to existing registries, or composing policy evaluators.
### Method 1: Own Registry
```typescript
// customer-invitation-policies.ts
import { createPolicyRegistry, definePolicy } from '@kit/policies';
const customerInvitationRegistry = createPolicyRegistry();
customerInvitationRegistry.registerPolicy(
definePolicy({
id: 'custom-domain-check',
stages: ['preliminary'],
evaluate: async (context) => {
const allowedDomains = ['company.com', 'partner.com'];
for (const invitation of context.invitations) {
const domain = invitation.email?.split('@')[1];
if (!allowedDomains.includes(domain)) {
return deny({
code: 'DOMAIN_NOT_ALLOWED',
message: `Email domain ${domain} is not allowed`,
remediation: 'Use an email from an approved domain',
});
}
}
return allow();
},
}),
);
export function createCustomInvitationPolicyEvaluator() {
const evaluator = createPoliciesEvaluator();
return {
async validateCustomRules(context, stage) {
return evaluator.evaluate(customerInvitationRegistry, context, 'ALL', stage);
},
};
}
```
### Method 2: Compose Policy Evaluators
```typescript
// Use both built-in and custom policies
import { createInvitationsPolicyEvaluator } from '@kit/team-accounts/policies';
import { createCustomInvitationPolicyEvaluator } from './customer-policies';
async function validateInvitations(context, stage) {
const builtinEvaluator = createInvitationsPolicyEvaluator();
const customEvaluator = createCustomInvitationPolicyEvaluator();
// Run built-in policies
const builtinResult = await builtinEvaluator.canInvite(context, stage);
if (!builtinResult.allowed) {
throw new Error(builtinResult.reasons.join(', '));
}
// Run custom policies
const customResult = await customEvaluator.validateCustomRules(context, stage);
if (!customResult.allowed) {
throw new Error(customResult.reasons.join(', '));
}
}
```
## Complex Group Evaluation
For advanced scenarios requiring complex business logic with multiple decision paths:
### Example: Multi-Stage Enterprise Validation
```typescript
// Complex scenario: (Authentication AND Email) AND (Subscription OR Trial) AND Final Validation
async function validateEnterpriseFeatureAccess(context: FeatureContext) {
const evaluator = createPoliciesEvaluator();
// Stage 1: Authentication Requirements (ALL must pass)
const authenticationGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.userId ? allow({ step: 'authenticated' }) : deny('Authentication required')
),
createPolicy(async (ctx) =>
ctx.email?.includes('@') ? allow({ step: 'email-valid' }) : deny('Valid email required')
),
createPolicy(async (ctx) =>
ctx.permissions.includes('enterprise-features')
? allow({ step: 'permissions' })
: deny('Enterprise permissions required')
),
],
};
// Stage 2: Billing Validation (ANY sufficient - flexible payment options)
const billingGroup = {
operator: 'ANY' as const,
policies: [
createPolicy(async (ctx) =>
ctx.subscription?.plan === 'enterprise' && ctx.subscription.active
? allow({ billing: 'enterprise-subscription' })
: deny('Enterprise subscription required')
),
createPolicy(async (ctx) =>
ctx.trial?.type === 'enterprise' && ctx.trial.daysRemaining > 0
? allow({ billing: 'enterprise-trial', daysLeft: ctx.trial.daysRemaining })
: deny('Active enterprise trial required')
),
createPolicy(async (ctx) =>
ctx.adminOverride?.enabled && ctx.user.role === 'super-admin'
? allow({ billing: 'admin-override' })
: deny('Admin override not available')
),
],
};
// Stage 3: Final Constraints (ALL must pass)
const constraintsGroup = {
operator: 'ALL' as const,
policies: [
createPolicy(async (ctx) =>
ctx.team.memberCount <= ctx.maxMembers
? allow({ constraint: 'team-size-valid' })
: deny('Team size exceeds plan limits')
),
createPolicy(async (ctx) =>
ctx.organization.complianceStatus === 'approved'
? allow({ constraint: 'compliance-approved' })
: deny('Organization compliance approval required')
),
],
};
// Execute all groups sequentially - ALL groups must pass
const result = await evaluator.evaluateGroups([
authenticationGroup,
billingGroup,
constraintsGroup
], context);
return {
allowed: result.allowed,
reasons: result.reasons,
metadata: {
authenticationPassed: result.results.some(r => r.metadata?.step === 'authenticated'),
billingMethod: result.results.find(r => r.metadata?.billing)?.metadata?.billing,
constraintsValidated: result.results.some(r => r.metadata?.constraint),
}
};
}
```
### Group Evaluation Flow
1. **Sequential Group Processing**: Groups are evaluated in order
2. **All Groups Must Pass**: If any group fails, entire evaluation fails
3. **Short-Circuiting**: Stops on first group failure for performance
4. **Metadata Preservation**: All policy results and metadata are collected
### Group Operators
- **`ALL` (AND logic)**: All policies in the group must pass
- Short-circuits on first failure for performance
- Use for mandatory requirements where every condition must be met
- **`ANY` (OR logic)**: At least one policy in the group must pass
- Short-circuits on first success for performance
- Use for flexible requirements where multiple options are acceptable
### Performance Considerations
- **Order groups by criticality**: Put fast, critical checks first
- **Group by evaluation cost**: Separate expensive operations
- **Monitor evaluation time**: Track performance for optimization
## API Reference
### Core Functions
- `createPolicyRegistry()` — Create a feature-specific registry
- `definePolicy(config)` — Define a policy with metadata and configuration
- `createPoliciesEvaluator()` — Create a policy evaluator instance
- `allow(metadata?)` — Return a success result with optional metadata
- `deny(reason | error)` — Return a failure result (supports strings and structured errors)
### Policy Evaluator Methods
- `evaluator.evaluate(registry, context, operator, stage?)` — Evaluate registry policies
- `evaluator.evaluateGroups(groups, context)` — Evaluate complex group logic
- `evaluator.hasPoliciesForStage(registry, stage?)` — Check if policies exist for a stage
### Types
- `PolicyContext` — Base context interface
- `PolicyResult` — Policy evaluation result
- `PolicyStage` — Execution stage (`'preliminary' | 'submission' | string`)
- `EvaluationResult` — Contains `allowed`, `reasons`, and `results` arrays
- `PolicyGroup` — Group configuration with `operator` and `policies`
## Related documentation
- [Team Account API](/docs/next-supabase-turbo/api/team-account-api) - Team management operations
- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Payment provider setup
- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) - Database-level security
- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing with seat limits

415
docs/api/registry-api.mdoc Normal file
View File

@@ -0,0 +1,415 @@
---
status: "published"
label: "Registry API"
title: "Registry API for Interchangeable Services | Next.js Supabase SaaS Kit"
description: "Build pluggable infrastructure with MakerKit's Registry API. Swap billing providers, mailers, monitoring services, and CMS clients without changing application code."
order: 6
---
The Registry API provides a type-safe pattern for registering and resolving interchangeable service implementations. Use it to swap between billing providers (Stripe, Lemon Squeezy, Paddle), mailers (Resend, Mailgun), monitoring (Sentry, SignOz), and any other pluggable infrastructure based on environment variables.
{% sequence title="Registry API Reference" description="Build pluggable infrastructure with the Registry API" %}
[Why use a registry](#why-use-a-registry)
[Core API](#core-api)
[Creating a registry](#creating-a-registry)
[Registering implementations](#registering-implementations)
[Resolving implementations](#resolving-implementations)
[Setup hooks](#setup-hooks)
[Real-world examples](#real-world-examples)
{% /sequence %}
## Why use a registry
MakerKit uses registries to decouple your application code from specific service implementations:
| Problem | Registry Solution |
|---------|------------------|
| Billing provider lock-in | Switch from Stripe to Paddle via env var |
| Testing with different backends | Register mock implementations for tests |
| Multi-tenant configurations | Different providers per tenant |
| Lazy initialization | Services only load when first accessed |
| Type safety | Full TypeScript support for implementations |
### How MakerKit uses registries
```
Environment Variable Registry Your Code
───────────────────── ──────── ─────────
BILLING_PROVIDER=stripe → billingRegistry → getBillingGateway()
MAILER_PROVIDER=resend → mailerRegistry → getMailer()
CMS_PROVIDER=keystatic → cmsRegistry → getCmsClient()
```
Your application code calls `getBillingGateway()` and receives the configured implementation without knowing which provider is active.
---
## Core API
The registry helper at `@kit/shared/registry` provides four methods:
| Method | Description |
|--------|-------------|
| `register(name, factory)` | Store an async factory for an implementation |
| `get(...names)` | Resolve one or more implementations |
| `addSetup(group, callback)` | Queue initialization tasks |
| `setup(group?)` | Execute setup tasks (once per group) |
---
## Creating a registry
Use `createRegistry<T, N>()` to create a typed registry:
```tsx
import { createRegistry } from '@kit/shared/registry';
// Define the interface implementations must follow
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
// Define allowed provider names
type EmailProvider = 'resend' | 'mailgun' | 'sendgrid';
// Create the registry
const emailRegistry = createRegistry<EmailService, EmailProvider>();
```
The generic parameters ensure:
- All registered implementations match `EmailService`
- Only valid provider names can be used
- `get()` returns correctly typed implementations
---
## Registering implementations
Use `register()` to add implementations. Factories can be sync or async:
```tsx
// Async factory with dynamic import (recommended for code splitting)
emailRegistry.register('resend', async () => {
const { createResendMailer } = await import('./mailers/resend');
return createResendMailer();
});
// Sync factory
emailRegistry.register('mailgun', () => {
return new MailgunService(process.env.MAILGUN_API_KEY!);
});
// Chaining
emailRegistry
.register('resend', async () => createResendMailer())
.register('mailgun', async () => createMailgunMailer())
.register('sendgrid', async () => createSendgridMailer());
```
{% callout title="Lazy loading" %}
Factories only execute when `get()` is called. This keeps your bundle small since unused providers aren't imported.
{% /callout %}
---
## Resolving implementations
Use `get()` to resolve implementations. Always await the result:
```tsx
// Single implementation
const mailer = await emailRegistry.get('resend');
await mailer.send('user@example.com', 'Welcome', 'Hello!');
// Multiple implementations (returns tuple)
const [primary, fallback] = await emailRegistry.get('resend', 'mailgun');
// Dynamic resolution from environment
const provider = process.env.EMAIL_PROVIDER as EmailProvider;
const mailer = await emailRegistry.get(provider);
```
### Creating a helper function
Wrap the registry in a helper for cleaner usage:
```tsx
export async function getEmailService(): Promise<EmailService> {
const provider = (process.env.EMAIL_PROVIDER ?? 'resend') as EmailProvider;
return emailRegistry.get(provider);
}
// Usage
const mailer = await getEmailService();
await mailer.send('user@example.com', 'Welcome', 'Hello!');
```
---
## Setup hooks
Use `addSetup()` and `setup()` for initialization tasks that should run once:
```tsx
// Add setup tasks
emailRegistry.addSetup('initialize', async () => {
console.log('Initializing email service...');
// Verify API keys, warm up connections, etc.
});
emailRegistry.addSetup('initialize', async () => {
console.log('Loading email templates...');
});
// Run all setup tasks (idempotent)
await emailRegistry.setup('initialize');
await emailRegistry.setup('initialize'); // No-op, already ran
```
### Setup groups
Use different groups to control when initialization happens:
```tsx
emailRegistry.addSetup('verify-credentials', async () => {
// Quick check at startup
});
emailRegistry.addSetup('warm-cache', async () => {
// Expensive operation, run later
});
// At startup
await emailRegistry.setup('verify-credentials');
// Before first email
await emailRegistry.setup('warm-cache');
```
---
## Real-world examples
### Billing provider registry
```tsx
// lib/billing/registry.ts
import { createRegistry } from '@kit/shared/registry';
interface BillingGateway {
createCheckoutSession(params: CheckoutParams): Promise<{ url: string }>;
createBillingPortalSession(customerId: string): Promise<{ url: string }>;
cancelSubscription(subscriptionId: string): Promise<void>;
}
type BillingProvider = 'stripe' | 'lemon-squeezy' | 'paddle';
const billingRegistry = createRegistry<BillingGateway, BillingProvider>();
billingRegistry
.register('stripe', async () => {
const { createStripeGateway } = await import('./gateways/stripe');
return createStripeGateway();
})
.register('lemon-squeezy', async () => {
const { createLemonSqueezyGateway } = await import('./gateways/lemon-squeezy');
return createLemonSqueezyGateway();
})
.register('paddle', async () => {
const { createPaddleGateway } = await import('./gateways/paddle');
return createPaddleGateway();
});
export async function getBillingGateway(): Promise<BillingGateway> {
const provider = (process.env.BILLING_PROVIDER ?? 'stripe') as BillingProvider;
return billingRegistry.get(provider);
}
```
**Usage:**
```tsx
import { getBillingGateway } from '@/lib/billing/registry';
export async function createCheckout(priceId: string, userId: string) {
const billing = await getBillingGateway();
const session = await billing.createCheckoutSession({
priceId,
userId,
successUrl: '/checkout/success',
cancelUrl: '/pricing',
});
return session.url;
}
```
### CMS client registry
```tsx
// lib/cms/registry.ts
import { createRegistry } from '@kit/shared/registry';
interface CmsClient {
getPosts(options?: { limit?: number }): Promise<Post[]>;
getPost(slug: string): Promise<Post | null>;
getPages(): Promise<Page[]>;
}
type CmsProvider = 'keystatic' | 'wordpress' | 'supabase';
const cmsRegistry = createRegistry<CmsClient, CmsProvider>();
cmsRegistry
.register('keystatic', async () => {
const { createKeystaticClient } = await import('./clients/keystatic');
return createKeystaticClient();
})
.register('wordpress', async () => {
const { createWordPressClient } = await import('./clients/wordpress');
return createWordPressClient(process.env.WORDPRESS_URL!);
})
.register('supabase', async () => {
const { createSupabaseCmsClient } = await import('./clients/supabase');
return createSupabaseCmsClient();
});
export async function getCmsClient(): Promise<CmsClient> {
const provider = (process.env.CMS_PROVIDER ?? 'keystatic') as CmsProvider;
return cmsRegistry.get(provider);
}
```
### Logger registry
```tsx
// lib/logger/registry.ts
import { createRegistry } from '@kit/shared/registry';
interface Logger {
info(context: object, message: string): void;
error(context: object, message: string): void;
warn(context: object, message: string): void;
debug(context: object, message: string): void;
}
type LoggerProvider = 'pino' | 'console';
const loggerRegistry = createRegistry<Logger, LoggerProvider>();
loggerRegistry
.register('pino', async () => {
const pino = await import('pino');
return pino.default({
level: process.env.LOG_LEVEL ?? 'info',
});
})
.register('console', () => ({
info: (ctx, msg) => console.log('[INFO]', msg, ctx),
error: (ctx, msg) => console.error('[ERROR]', msg, ctx),
warn: (ctx, msg) => console.warn('[WARN]', msg, ctx),
debug: (ctx, msg) => console.debug('[DEBUG]', msg, ctx),
}));
export async function getLogger(): Promise<Logger> {
const provider = (process.env.LOGGER ?? 'pino') as LoggerProvider;
return loggerRegistry.get(provider);
}
```
### Testing with mock implementations
```tsx
// __tests__/billing.test.ts
import { createRegistry } from '@kit/shared/registry';
const mockBillingRegistry = createRegistry<BillingGateway, 'mock'>();
mockBillingRegistry.register('mock', () => ({
createCheckoutSession: jest.fn().mockResolvedValue({ url: 'https://mock.checkout' }),
createBillingPortalSession: jest.fn().mockResolvedValue({ url: 'https://mock.portal' }),
cancelSubscription: jest.fn().mockResolvedValue(undefined),
}));
test('checkout creates session', async () => {
const billing = await mockBillingRegistry.get('mock');
const result = await billing.createCheckoutSession({
priceId: 'price_123',
userId: 'user_456',
});
expect(result.url).toBe('https://mock.checkout');
});
```
---
## Best practices
### 1. Use environment variables for provider selection
```tsx
// Good: Configuration-driven
const provider = process.env.BILLING_PROVIDER as BillingProvider;
const billing = await registry.get(provider);
// Avoid: Hardcoded providers
const billing = await registry.get('stripe');
```
### 2. Create helper functions for common access
```tsx
// Good: Encapsulated helper
export async function getBillingGateway() {
const provider = process.env.BILLING_PROVIDER ?? 'stripe';
return billingRegistry.get(provider as BillingProvider);
}
// Usage is clean
const billing = await getBillingGateway();
```
### 3. Use dynamic imports for code splitting
```tsx
// Good: Lazy loaded
registry.register('stripe', async () => {
const { createStripeGateway } = await import('./stripe');
return createStripeGateway();
});
// Avoid: Eager imports
import { createStripeGateway } from './stripe';
registry.register('stripe', () => createStripeGateway());
```
### 4. Define strict interfaces
```tsx
// Good: Well-defined interface
interface BillingGateway {
createCheckoutSession(params: CheckoutParams): Promise<CheckoutResult>;
createBillingPortalSession(customerId: string): Promise<PortalResult>;
}
// Avoid: Loose typing
type BillingGateway = Record<string, (...args: any[]) => any>;
```
## Related documentation
- [Billing Configuration](/docs/next-supabase-turbo/billing/overview) - Payment provider setup
- [Monitoring Configuration](/docs/next-supabase-turbo/monitoring/overview) - Logger and APM setup
- [CMS Configuration](/docs/next-supabase-turbo/content) - Content management setup

View File

@@ -0,0 +1,650 @@
---
status: "published"
label: "Team Account API"
order: 1
title: "Team Account API | Next.js Supabase SaaS Kit"
description: "Complete reference for the Team Account API in MakerKit. Manage teams, members, permissions, invitations, subscriptions, and workspace data with type-safe methods."
---
The Team Account API manages team accounts, members, permissions, and invitations. Use it to check user permissions, manage team subscriptions, and handle team invitations in your multi-tenant SaaS application.
{% sequence title="Team Account API Reference" description="Learn how to use the Team Account API in MakerKit" %}
[Setup and initialization](#setup-and-initialization)
[getTeamAccountById](#getteamaccountbyid)
[getAccountWorkspace](#getaccountworkspace)
[getSubscription](#getsubscription)
[getOrder](#getorder)
[hasPermission](#haspermission)
[getMembersCount](#getmemberscount)
[getCustomerId](#getcustomerid)
[getInvitation](#getinvitation)
[Real-world examples](#real-world-examples)
{% /sequence %}
## Setup and initialization
Import `createTeamAccountsApi` from `@kit/team-accounts/api` and pass a Supabase server client.
```tsx
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function ServerComponent() {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
// Use API methods
}
```
In Server Actions:
```tsx
'use server';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function myServerAction() {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
// Use API methods
}
```
{% callout title="Request-scoped clients" %}
Always create the Supabase client and API instance inside your request handler, not at module scope. The client is tied to the current user's session and RLS policies.
{% /callout %}
## API Methods
### getTeamAccountById
Retrieves a team account by its UUID. Also verifies the current user has access to the team.
```tsx
const account = await api.getTeamAccountById(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The team account UUID |
**Returns:**
```tsx
{
id: string;
name: string;
slug: string;
picture_url: string | null;
public_data: Json | null;
primary_owner_user_id: string;
created_at: string;
updated_at: string;
} | null
```
**Usage notes:**
- Returns `null` if the account doesn't exist or user lacks access
- RLS policies ensure users only see teams they belong to
- Use this to verify team membership before operations
---
### getAccountWorkspace
Returns the team workspace data for a given team slug. This is the primary method for loading team context in layouts.
```tsx
const workspace = await api.getAccountWorkspace(slug);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `slug` | `string` | The team URL slug |
**Returns:**
```tsx
{
account: {
id: string;
name: string;
slug: string;
picture_url: string | null;
role: string;
role_hierarchy_level: number;
primary_owner_user_id: string;
subscription_status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused' | null;
permissions: string[];
};
accounts: Array<{
id: string;
name: string;
slug: string;
picture_url: string | null;
role: string;
}>;
}
```
**Usage notes:**
- Called automatically in the `/home/[account]` layout
- The `permissions` array contains all permissions for the current user in this team
- Use `role_hierarchy_level` for role-based comparisons (lower = more permissions)
---
### getSubscription
Returns the subscription data for a team account, including all line items.
```tsx
const subscription = await api.getSubscription(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The team account UUID |
**Returns:**
```tsx
{
id: string;
account_id: string;
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'incomplete' | 'incomplete_expired' | 'paused';
currency: string;
cancel_at_period_end: boolean;
period_starts_at: string;
period_ends_at: string;
trial_starts_at: string | null;
trial_ends_at: string | null;
items: Array<{
id: string;
subscription_id: string;
product_id: string;
variant_id: string;
type: 'flat' | 'per_seat' | 'metered';
quantity: number;
price_amount: number;
interval: 'month' | 'year';
interval_count: number;
}>;
} | null
```
**Example: Check per-seat limits**
```tsx
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function canAddTeamMember(accountId: string) {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
const [subscription, membersCount] = await Promise.all([
api.getSubscription(accountId),
api.getMembersCount(accountId),
]);
if (!subscription) {
// Free tier: allow up to 3 members
return membersCount < 3;
}
const perSeatItem = subscription.items.find((item) => item.type === 'per_seat');
if (perSeatItem) {
return membersCount < perSeatItem.quantity;
}
// Flat-rate plan: no seat limit
return true;
}
```
---
### getOrder
Returns one-time purchase order data for team accounts using lifetime deals.
```tsx
const order = await api.getOrder(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The team account UUID |
**Returns:**
```tsx
{
id: string;
account_id: string;
billing_provider: 'stripe' | 'lemon-squeezy' | 'paddle';
status: 'pending' | 'completed' | 'refunded';
currency: string;
total_amount: number;
items: Array<{
product_id: string;
variant_id: string;
quantity: number;
price_amount: number;
}>;
} | null
```
---
### hasPermission
Checks if a user has a specific permission within a team account. Use this for fine-grained authorization checks.
```tsx
const canManage = await api.hasPermission({
accountId: 'team-uuid',
userId: 'user-uuid',
permission: 'billing.manage',
});
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The team account UUID |
| `userId` | `string` | The user UUID to check |
| `permission` | `string` | The permission identifier |
**Returns:** `boolean`
**Built-in permissions:**
| Permission | Description |
|------------|-------------|
| `billing.manage` | Manage subscription and payment methods |
| `members.invite` | Invite new team members |
| `members.remove` | Remove team members |
| `members.manage` | Update member roles |
| `settings.manage` | Update team settings |
**Example: Permission-gated Server Action**
```tsx
'use server';
import * as z from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const UpdateTeamSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(2).max(50),
});
export const updateTeamSettings = authActionClient
.inputSchema(UpdateTeamSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
const canManage = await api.hasPermission({
accountId: data.accountId,
userId: user.id,
permission: 'settings.manage',
});
if (!canManage) {
return {
success: false,
error: 'You do not have permission to update team settings',
};
}
// Update team...
return { success: true };
});
```
---
### getMembersCount
Returns the total number of members in a team account.
```tsx
const count = await api.getMembersCount(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The team account UUID |
**Returns:** `number | null`
**Example: Display team size**
```tsx
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function TeamStats({ accountId }: { accountId: string }) {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
const membersCount = await api.getMembersCount(accountId);
return (
<div>
<span className="font-medium">{membersCount}</span>
<span className="text-muted-foreground"> team members</span>
</div>
);
}
```
---
### getCustomerId
Returns the billing provider customer ID for a team account.
```tsx
const customerId = await api.getCustomerId(accountId);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountId` | `string` | The team account UUID |
**Returns:** `string | null`
---
### getInvitation
Retrieves invitation data from an invite token. Requires an admin client to bypass RLS for pending invitations.
```tsx
const invitation = await api.getInvitation(adminClient, token);
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `adminClient` | `SupabaseClient` | Admin client (bypasses RLS) |
| `token` | `string` | The invitation token |
**Returns:**
```tsx
{
id: number;
email: string;
account: {
id: string;
name: string;
slug: string;
};
role: string;
expires_at: string;
} | null
```
**Example: Accept invitation flow**
```tsx
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function getInvitationDetails(token: string) {
const client = getSupabaseServerClient();
const adminClient = getSupabaseServerAdminClient();
const api = createTeamAccountsApi(client);
const invitation = await api.getInvitation(adminClient, token);
if (!invitation) {
return { error: 'Invalid or expired invitation' };
}
const now = new Date();
const expiresAt = new Date(invitation.expires_at);
if (now > expiresAt) {
return { error: 'This invitation has expired' };
}
return {
teamName: invitation.account.name,
role: invitation.role,
email: invitation.email,
};
}
```
{% callout type="warning" title="Admin client security" %}
The admin client bypasses Row Level Security. Only use it for operations that require elevated privileges, and always validate authorization separately.
{% /callout %}
---
## Real-world examples
### Complete team management Server Actions
```tsx
// lib/server/team-actions.ts
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const InviteMemberSchema = z.object({
accountId: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'member']),
});
const RemoveMemberSchema = z.object({
accountId: z.string().uuid(),
userId: z.string().uuid(),
});
export const inviteMember = authActionClient
.inputSchema(InviteMemberSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
// Check permission
const canInvite = await api.hasPermission({
accountId: data.accountId,
userId: user.id,
permission: 'members.invite',
});
if (!canInvite) {
return { success: false, error: 'Permission denied' };
}
// Check seat limits
const [subscription, membersCount] = await Promise.all([
api.getSubscription(data.accountId),
api.getMembersCount(data.accountId),
]);
if (subscription) {
const perSeatItem = subscription.items.find((i) => i.type === 'per_seat');
if (perSeatItem && membersCount >= perSeatItem.quantity) {
return {
success: false,
error: 'Team has reached maximum seats. Please upgrade your plan.',
};
}
}
// Create invitation...
const { error } = await client.from('invitations').insert({
account_id: data.accountId,
email: data.email,
role: data.role,
invited_by: user.id,
});
if (error) {
return { success: false, error: 'Failed to send invitation' };
}
revalidatePath(`/home/[account]/settings/members`, 'page');
return { success: true };
});
export const removeMember = authActionClient
.inputSchema(RemoveMemberSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
// Cannot remove yourself
if (data.userId === user.id) {
return { success: false, error: 'You cannot remove yourself' };
}
// Check permission
const canRemove = await api.hasPermission({
accountId: data.accountId,
userId: user.id,
permission: 'members.remove',
});
if (!canRemove) {
return { success: false, error: 'Permission denied' };
}
// Check if target is owner
const account = await api.getTeamAccountById(data.accountId);
if (account?.primary_owner_user_id === data.userId) {
return { success: false, error: 'Cannot remove the team owner' };
}
// Remove member...
const { error } = await client
.from('accounts_memberships')
.delete()
.eq('account_id', data.accountId)
.eq('user_id', data.userId);
if (error) {
return { success: false, error: 'Failed to remove member' };
}
revalidatePath(`/home/[account]/settings/members`, 'page');
return { success: true };
});
```
### Permission-based UI rendering
```tsx
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { requireUser } from '@kit/supabase/require-user';
import { redirect } from 'next/navigation';
async function TeamSettingsPage({ params }: { params: { account: string } }) {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (auth.error) {
redirect(auth.redirectTo);
}
const api = createTeamAccountsApi(client);
const workspace = await api.getAccountWorkspace(params.account);
const permissions = {
canManageSettings: workspace.account.permissions.includes('settings.manage'),
canManageBilling: workspace.account.permissions.includes('billing.manage'),
canInviteMembers: workspace.account.permissions.includes('members.invite'),
canRemoveMembers: workspace.account.permissions.includes('members.remove'),
};
return (
<div>
<h1>Team Settings</h1>
{permissions.canManageSettings && (
<section>
<h2>General Settings</h2>
{/* Settings form */}
</section>
)}
{permissions.canManageBilling && (
<section>
<h2>Billing</h2>
{/* Billing management */}
</section>
)}
{permissions.canInviteMembers && (
<section>
<h2>Invite Members</h2>
{/* Invitation form */}
</section>
)}
{!permissions.canManageSettings &&
!permissions.canManageBilling &&
!permissions.canInviteMembers && (
<p>You don't have permission to manage this team.</p>
)}
</div>
);
}
```
## Related documentation
- [Account API](/docs/next-supabase-turbo/api/account-api) - Personal account management
- [Team Workspace API](/docs/next-supabase-turbo/api/account-workspace-api) - Workspace context for layouts
- [Policies API](/docs/next-supabase-turbo/api/policies-api) - Business rule validation
- [Per-seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing

View File

@@ -0,0 +1,358 @@
---
status: "published"
label: "User Workspace API"
order: 3
title: "User Workspace API | Next.js Supabase SaaS Kit"
description: "Access personal workspace data in MakerKit layouts. Load user account information, subscription status, and account switcher data with the User Workspace API."
---
The User Workspace API provides personal account context for pages under `/home/(user)`. It loads user data, subscription status, and all accounts the user belongs to, making this information available to both server and client components.
{% sequence title="User Workspace API Reference" description="Access personal workspace data in layouts and components" %}
[loadUserWorkspace (Server)](#loaduserworkspace-server)
[useUserWorkspace (Client)](#useuserworkspace-client)
[Data structure](#data-structure)
[Usage patterns](#usage-patterns)
{% /sequence %}
## loadUserWorkspace (Server)
Loads the personal workspace data for the authenticated user. Use this in Server Components within the `/home/(user)` route group.
```tsx
import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace';
export default async function PersonalDashboard() {
const data = await loadUserWorkspace();
return (
<div>
<h1>Welcome, {data.user.email}</h1>
<p>Account: {data.workspace.name}</p>
</div>
);
}
```
### Function signature
```tsx
async function loadUserWorkspace(): Promise<UserWorkspaceData>
```
### Caching behavior
The function uses React's `cache()` to deduplicate calls within a single request. You can call it multiple times in nested components without additional database queries.
```tsx
// Both calls use the same cached data
const layout = await loadUserWorkspace(); // First call: hits database
const page = await loadUserWorkspace(); // Second call: returns cached data
```
{% callout title="Performance consideration" %}
While calls are deduplicated within a request, the data is fetched on every navigation. If you only need a subset of the data (like subscription status), consider making a more targeted query.
{% /callout %}
---
## useUserWorkspace (Client)
Access the workspace data in client components using the `useUserWorkspace` hook. The data is provided through React Context from the layout.
```tsx
'use client';
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
export function ProfileCard() {
const { workspace, user, accounts } = useUserWorkspace();
return (
<div className="rounded-lg border p-4">
<div className="flex items-center gap-3">
{workspace.picture_url && (
<img
src={workspace.picture_url}
alt={workspace.name ?? 'Profile'}
className="h-10 w-10 rounded-full"
/>
)}
<div>
<p className="font-medium">{workspace.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
{workspace.subscription_status && (
<div className="mt-3">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Plan: {workspace.subscription_status}
</span>
</div>
)}
</div>
);
}
```
{% callout type="warning" title="Context requirement" %}
The `useUserWorkspace` hook only works within the `/home/(user)` route group where the context provider is set up. Using it outside this layout will throw an error.
{% /callout %}
---
## Data structure
### UserWorkspaceData
```tsx
import type { User } from '@supabase/supabase-js';
interface UserWorkspaceData {
workspace: {
id: string | null;
name: string | null;
picture_url: string | null;
public_data: Json | null;
subscription_status: SubscriptionStatus | null;
};
user: User;
accounts: Array<{
id: string | null;
name: string | null;
picture_url: string | null;
role: string | null;
slug: string | null;
}>;
}
```
### subscription_status values
| Status | Description |
|--------|-------------|
| `active` | Active subscription |
| `trialing` | In trial period |
| `past_due` | Payment failed, grace period |
| `canceled` | Subscription canceled |
| `unpaid` | Payment required |
| `incomplete` | Setup incomplete |
| `incomplete_expired` | Setup expired |
| `paused` | Subscription paused |
### accounts array
The `accounts` array contains all accounts the user belongs to, including:
- Their personal account
- Team accounts where they're a member
- The user's role in each account
This data powers the account switcher component.
---
## Usage patterns
### Personal dashboard page
```tsx
import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace';
export default async function DashboardPage() {
const { workspace, user, accounts } = await loadUserWorkspace();
const hasActiveSubscription =
workspace.subscription_status === 'active' ||
workspace.subscription_status === 'trialing';
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back, {user.user_metadata.full_name || user.email}
</p>
</header>
{!hasActiveSubscription && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p>Upgrade to unlock premium features</p>
<a href="/pricing" className="text-primary underline">
View plans
</a>
</div>
)}
<section>
<h2 className="text-lg font-medium">Your teams</h2>
<ul className="mt-2 space-y-2">
{accounts
.filter((a) => a.slug !== null)
.map((account) => (
<li key={account.id}>
<a
href={`/home/${account.slug}`}
className="flex items-center gap-2 rounded-lg p-2 hover:bg-muted"
>
{account.picture_url && (
<img
src={account.picture_url}
alt=""
className="h-8 w-8 rounded"
/>
)}
<span>{account.name}</span>
<span className="ml-auto text-xs text-muted-foreground">
{account.role}
</span>
</a>
</li>
))}
</ul>
</section>
</div>
);
}
```
### Account switcher component
```tsx
'use client';
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
import { useRouter } from 'next/navigation';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
export function AccountSwitcher() {
const { workspace, accounts } = useUserWorkspace();
const router = useRouter();
const handleChange = (value: string) => {
if (value === 'personal') {
router.push('/home');
} else {
router.push(`/home/${value}`);
}
};
return (
<Select
defaultValue={workspace.id ?? 'personal'}
onValueChange={handleChange}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="personal">
Personal Account
</SelectItem>
{accounts
.filter((a) => a.slug)
.map((account) => (
<SelectItem key={account.id} value={account.slug!}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
```
### Feature gating with subscription status
```tsx
'use client';
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
interface FeatureGateProps {
children: React.ReactNode;
fallback?: React.ReactNode;
requiredStatus?: string[];
}
export function FeatureGate({
children,
fallback,
requiredStatus = ['active', 'trialing'],
}: FeatureGateProps) {
const { workspace } = useUserWorkspace();
const hasAccess = requiredStatus.includes(
workspace.subscription_status ?? ''
);
if (!hasAccess) {
return fallback ?? null;
}
return <>{children}</>;
}
// Usage
function PremiumFeature() {
return (
<FeatureGate
fallback={
<div className="text-center p-4">
<p>This feature requires a paid plan</p>
<a href="/pricing">Upgrade now</a>
</div>
}
>
<ExpensiveComponent />
</FeatureGate>
);
}
```
### Combining with server data
```tsx
import { loadUserWorkspace } from '~/home/(user)/_lib/server/load-user-workspace';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function TasksPage() {
const { workspace, user } = await loadUserWorkspace();
const client = getSupabaseServerClient();
// Fetch additional data using the workspace context
const { data: tasks } = await client
.from('tasks')
.select('*')
.eq('account_id', workspace.id)
.eq('created_by', user.id)
.order('created_at', { ascending: false });
return (
<div>
<h1>My Tasks</h1>
<TaskList tasks={tasks ?? []} />
</div>
);
}
```
## Related documentation
- [Team Workspace API](/docs/next-supabase-turbo/api/account-workspace-api) - Team account context
- [Account API](/docs/next-supabase-turbo/api/account-api) - Account operations
- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - User authentication