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

View File

@@ -0,0 +1,495 @@
---
status: "published"
title: "Using Drizzle as a client for interacting with Supabase"
label: "Drizzle"
order: 6
description: "Add Drizzle ORM to your MakerKit project for type-safe database queries while respecting Supabase Row Level Security."
---
Drizzle ORM is a TypeScript-first database toolkit that provides type-safe query building and automatic TypeScript type inference from your PostgreSQL database. When combined with Supabase, you get the best of both worlds: Drizzle's query builder with Supabase's Row Level Security.
Drizzle ORM provides type-safe database queries for PostgreSQL. With MakerKit's RLS-aware client, you get full TypeScript inference while respecting Supabase Row Level Security policies. This guide shows how to add Drizzle to your project, generate types from your existing database, and query data with proper RLS enforcement.
MakerKit uses the standard [Supabase client](/docs/next-supabase-turbo/data-fetching/supabase-clients) by default. This guide covers adding Drizzle as an alternative query layer while keeping your RLS policies intact. For more data fetching patterns, see the [data fetching overview](/docs/next-supabase-turbo/data-fetching). Tested with Drizzle ORM 0.45.x and drizzle-kit 0.31.x (January 2025).
The RLS integration is the tricky part. Most Drizzle tutorials skip it because they assume you're either using service role (bypassing RLS) or don't need row-level permissions. For a multi-tenant SaaS, you need both: type-safe queries that still respect your security policies.
This guide adapts the [official Drizzle + Supabase tutorial](https://orm.drizzle.team/docs/tutorials/drizzle-with-supabase) with MakerKit-specific patterns.
## When to Use Drizzle
**Use Drizzle when:**
- You need complex joins across multiple tables
- You want full TypeScript inference on query results
- You're writing many database queries and IDE autocomplete matters
- You prefer SQL-like syntax over the Supabase query builder
**Stick with Supabase client when:**
- You need real-time subscriptions
- You're doing file storage operations
- You're working with auth flows
- Simple CRUD is sufficient
## Prerequisites
- Working MakerKit project with Supabase running locally
- Basic TypeScript knowledge
- Database with existing tables (we'll generate the schema from your DB)
## Step 1: Install Dependencies
Add the required packages to the `@kit/supabase` package:
```bash
pnpm --filter "@kit/supabase" add drizzle-orm postgres jwt-decode
pnpm --filter "@kit/supabase" add -D drizzle-kit
```
**Package breakdown:**
- `drizzle-orm` - The ORM itself (runtime dependency)
- `postgres` - postgres.js driver, faster than node-postgres for this use case
- `jwt-decode` - Decodes Supabase JWT to extract user role for RLS
- `drizzle-kit` - CLI for schema introspection and migrations (dev only)
## Step 2: Create Drizzle Configuration
Create `packages/supabase/drizzle.config.js`:
```javascript {% title="packages/supabase/drizzle.config.js" %}
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/drizzle/schema.ts',
out: './src/drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
},
schemaFilter: ['public'],
verbose: true,
strict: true,
});
```
**Configuration notes:**
- `schemaFilter: ['public']` pulls only the public schema where your application tables live. Supabase's `auth` schema tables require a separate reference (covered in Step 5).
- `schema` points to where the generated schema will be imported from. The `out` directory is where drizzle-kit writes the generated files.
- If you need to pull from multiple schemas (e.g., a custom `app` schema), add them to the array: `['public', 'app']`.
Drizzle Kit will generate a `schema.ts` file containing TypeScript types that match your database structure. This enables full type inference on all your queries.
## Step 3: Update package.json
Add the scripts and exports to `packages/supabase/package.json`:
```json {% title="packages/supabase/package.json" %}
{
"scripts": {
"drizzle": "drizzle-kit",
"pull": "drizzle-kit pull --config drizzle.config.js"
},
"exports": {
"./drizzle-client": "./src/clients/drizzle-client.ts",
"./drizzle-schema": "./src/drizzle/schema.ts"
}
}
```
The `pull` script introspects your database and generates the TypeScript schema. The exports make the Drizzle client and schema available throughout your monorepo.
## Step 4: Create the Drizzle Client
This is where MakerKit differs from standard Drizzle setups. We need two clients:
1. **Admin client** - Bypasses RLS for webhooks, admin operations, and background jobs
2. **RLS client** - Sets JWT claims in a transaction to respect your security policies
Create `packages/supabase/src/clients/drizzle-client.ts`:
```typescript {% title="packages/supabase/src/clients/drizzle-client.ts" %}
import 'server-only';
import { DrizzleConfig, sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import { JwtPayload, jwtDecode } from 'jwt-decode';
import postgres from 'postgres';
import * as z from 'zod';
import * as schema from '../drizzle/schema';
import { getSupabaseServerClient } from './server-client';
const SUPABASE_DATABASE_URL = z
.string({
description: 'The URL of the Supabase database.',
required_error: 'SUPABASE_DATABASE_URL is required',
})
.url()
.parse(process.env.SUPABASE_DATABASE_URL!);
const config = {
casing: 'snake_case',
schema,
} satisfies DrizzleConfig<typeof schema>;
// Admin client bypasses RLS
const adminClient = drizzle({
client: postgres(SUPABASE_DATABASE_URL, { prepare: false }),
...config,
});
// RLS protected client
const rlsClient = drizzle({
client: postgres(SUPABASE_DATABASE_URL, { prepare: false }),
...config,
});
/**
* Returns admin Drizzle client that bypasses RLS.
* Use for webhooks, admin operations, and migrations.
*/
export function getDrizzleSupabaseAdminClient() {
return adminClient;
}
/**
* Returns RLS-aware Drizzle client.
* All queries must run inside runTransaction to respect RLS policies.
*/
export async function getDrizzleSupabaseClient() {
const client = getSupabaseServerClient();
const { data } = await client.auth.getSession();
const accessToken = data.session?.access_token ?? '';
const token = decode(accessToken);
const runTransaction = ((transaction, txConfig) => {
return rlsClient.transaction(async (tx) => {
try {
// Set Supabase auth context for RLS
await tx.execute(sql`
select set_config('request.jwt.claims', '${sql.raw(
JSON.stringify(token),
)}', TRUE);
select set_config('request.jwt.claim.sub', '${sql.raw(
token.sub ?? '',
)}', TRUE);
set local role ${sql.raw(token.role ?? 'anon')};
`);
return await transaction(tx);
} finally {
// Reset context
await tx.execute(sql`
select set_config('request.jwt.claims', NULL, TRUE);
select set_config('request.jwt.claim.sub', NULL, TRUE);
reset role;
`);
}
}, txConfig);
}) as typeof rlsClient.transaction;
return { runTransaction };
}
function decode(accessToken: string) {
try {
return jwtDecode<JwtPayload & { role: string }>(accessToken);
} catch {
return { role: 'anon' } as JwtPayload & { role: string };
}
}
// Export type for external use
export type DrizzleDatabase = typeof rlsClient;
```
**Why `prepare: false`?** Supabase's connection pooler (Transaction mode) doesn't support prepared statements. Without this flag, you'll get "prepared statement already exists" errors in production.
**Why transactions for RLS?** PostgreSQL's `set_config` and `SET LOCAL ROLE` only persist within a transaction. If you run queries outside a transaction, the JWT context isn't set and RLS policies see an anonymous user.
## Step 5: Generate the Schema
With your local Supabase running, generate the TypeScript schema:
```bash
pnpm --filter "@kit/supabase" pull
```
Expected output:
```
Pulling from ['public'] list of schemas
Using 'postgres' driver for database querying
[✓] 14 tables fetched
[✓] 104 columns fetched
[✓] 9 enums fetched
[✓] 18 indexes fetched
[✓] 23 foreign keys fetched
[✓] 28 policies fetched
[✓] 3 check constraints fetched
[✓] 2 views fetched
[✓] Your schema file is ready ➜ src/drizzle/schema.ts
[✓] Your relations file is ready ➜ src/drizzle/relations.ts
```
### Add the Auth Schema Reference
Some MakerKit tables reference `auth.users`. Since we only pulled `public`, add this to the top of `packages/supabase/src/drizzle/schema.ts`:
```typescript {% title="packages/supabase/src/drizzle/schema.ts" %}
/* eslint-disable */
import { pgSchema, uuid } from 'drizzle-orm/pg-core';
// Reference to auth.users for foreign key constraints
const authSchema = pgSchema('auth');
export const usersInAuth = authSchema.table('users', {
id: uuid('id').primaryKey(),
});
// ... rest of generated schema
```
The `/* eslint-disable */` comment prevents lint errors on the generated code. The `authSchema` reference allows foreign key relationships to work correctly.
### Verify the Schema
After generating, check that `packages/supabase/src/drizzle/schema.ts` contains your tables. You should see exports like `accounts`, `subscriptions`, and other tables from your database.
## Step 6: Configure Environment Variable
Add `SUPABASE_DATABASE_URL` to your [environment variables](/docs/next-supabase-turbo/configuration/environment-variables). For local development, add to `.env.development`:
```bash {% title=".env.development" %}
SUPABASE_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
```
{% alert type="warning" title="Keep SUPABASE_DATABASE_URL private" %}
This URL contains database credentials. Never commit it to your repository. For production, set it through your hosting provider's environment variables (Vercel, Railway, etc.).
{% /alert %}
Find your production connection string in Supabase Dashboard → Project Settings → Database. Use the **connection pooler** URL in Transaction mode.
## Using the Drizzle Client
### In Server Components
```typescript
import { getDrizzleSupabaseClient } from '@kit/supabase/drizzle-client';
import { accounts } from '@kit/supabase/drizzle-schema';
async function AccountsList() {
const client = await getDrizzleSupabaseClient();
// All queries run inside runTransaction to respect RLS
const data = await client.runTransaction((tx) => {
return tx.select().from(accounts);
});
return (
<ul>
{data.map((account) => (
<li key={account.id}>{account.name}</li>
))}
</ul>
);
}
```
### In Server Actions
Use with the [authActionClient utility](/docs/next-supabase-turbo/data-fetching/server-actions) for authentication and validation:
```typescript
'use server';
import { getDrizzleSupabaseClient } from '@kit/supabase/drizzle-client';
import { tasks } from '@kit/supabase/drizzle-schema';
import { authActionClient } from '@kit/next/safe-action';
import * as z from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const createTaskAction = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data }) => {
const client = await getDrizzleSupabaseClient();
const [task] = await client.runTransaction((tx) => {
return tx
.insert(tasks)
.values({ title: data.title, accountId: data.accountId })
.returning();
});
return { task };
});
```
### Using the Admin Client
For operations that bypass RLS (webhooks, admin tasks, background jobs):
```typescript
import { getDrizzleSupabaseAdminClient } from '@kit/supabase/drizzle-client';
import { accounts } from '@kit/supabase/drizzle-schema';
import { eq } from 'drizzle-orm';
async function deleteAccountAdmin(accountId: string) {
const db = getDrizzleSupabaseAdminClient();
// Bypasses RLS - use only for admin operations
await db.delete(accounts).where(eq(accounts.id, accountId));
}
```
{% alert type="warning" title="Admin client bypasses RLS" %}
The admin client ignores all Row Level Security policies. Use it only for operations that genuinely need elevated permissions. For user-facing features, always use the RLS client with `runTransaction`.
{% /alert %}
## Common Query Patterns
### Queries with Filters
```typescript
import { eq, and, gt } from 'drizzle-orm';
import { tasks } from '@kit/supabase/drizzle-schema';
const client = await getDrizzleSupabaseClient();
const recentTasks = await client.runTransaction((tx) => {
return tx
.select()
.from(tasks)
.where(
and(
eq(tasks.accountId, accountId),
gt(tasks.createdAt, lastWeek)
)
);
});
// Returns: Array<{ id: string; title: string; accountId: string; createdAt: Date; ... }>
```
### Joins
```typescript
import { eq } from 'drizzle-orm';
import { tasks, accounts } from '@kit/supabase/drizzle-schema';
// Assumes client from getDrizzleSupabaseClient() is in scope
const tasksWithAccounts = await client.runTransaction((tx) => {
return tx
.select({
task: tasks,
account: accounts,
})
.from(tasks)
.leftJoin(accounts, eq(tasks.accountId, accounts.id));
});
// Returns: Array<{ task: Task; account: Account | null }>
```
### Aggregations
```typescript
import { count, sql, eq } from 'drizzle-orm';
import { tasks } from '@kit/supabase/drizzle-schema';
// Assumes client from getDrizzleSupabaseClient() is in scope
const stats = await client.runTransaction((tx) => {
return tx
.select({
total: count(),
completed: sql<number>`count(*) filter (where completed = true)`,
})
.from(tasks)
.where(eq(tasks.accountId, accountId));
});
// Returns: [{ total: 42, completed: 18 }]
```
## Common Pitfalls
**1. Running queries outside `runTransaction`**
The RLS client doesn't expose direct query methods. You must use `runTransaction`:
```typescript
// Wrong - this doesn't exist
const data = await client.select().from(tasks);
// Correct
const data = await client.runTransaction((tx) => {
return tx.select().from(tasks);
});
```
**2. Forgetting `prepare: false`**
Supabase's connection pooler doesn't support prepared statements. Without this flag:
```
Error: prepared statement "s1" already exists
```
**3. Schema out of sync**
After database changes, re-run `pnpm --filter "@kit/supabase" pull` to regenerate the schema. Remember to re-add the auth schema reference at the top of the file.
**4. Bundling in client components**
The Drizzle client uses `server-only`. If you accidentally import it in a client component:
```
Error: This module cannot be imported from a Client Component
```
Move your database logic to a Server Component, Server Action, or Route Handler.
**5. Using the wrong environment variable**
The Drizzle client expects `SUPABASE_DATABASE_URL` (your production connection pooler URL). The `drizzle.config.js` uses `DATABASE_URL` for local CLI operations like `pull`. For local development, both can point to `postgresql://postgres:postgres@127.0.0.1:54322/postgres`. In production, `SUPABASE_DATABASE_URL` should be your Supabase connection pooler URL in Transaction mode.
## Migrations with Drizzle
The default setup uses schema introspection (`pull`). If you want Drizzle to manage migrations instead:
1. Move the `src/drizzle` folder to your project root
2. Update `drizzle.config.js` paths
3. Use `drizzle-kit generate` to create migrations
4. Use `drizzle-kit migrate` to apply them
For most MakerKit projects, sticking with Supabase migrations and using Drizzle only as a query builder keeps things simpler. See the [migrations documentation](/docs/next-supabase-turbo/development/migrations) for the standard approach.
## Server-Only Requirement
The Drizzle client can only run on the server. Use it in:
- Server Components
- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions)
- [Route Handlers](/docs/next-supabase-turbo/data-fetching/route-handlers)
The `'server-only'` import at the top of the client file enforces this at build time.
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Does Drizzle work with Supabase RLS?", "answer": "Yes. The getDrizzleSupabaseClient function sets JWT claims inside a transaction, so your RLS policies evaluate correctly. All queries must run inside runTransaction for RLS to apply."},
{"question": "Can I use Drizzle for migrations with Supabase?", "answer": "You can, but it's not recommended for MakerKit projects. The kit uses Supabase migrations for schema changes. Use Drizzle as a query builder and keep using Supabase CLI for migrations."},
{"question": "Why do I need runTransaction for every query?", "answer": "PostgreSQL's set_config and SET LOCAL ROLE only persist within a transaction. Without the transaction wrapper, the JWT context isn't set and RLS policies see an anonymous user."},
{"question": "What's the difference between admin and RLS client?", "answer": "getDrizzleSupabaseAdminClient bypasses all RLS policies - use it for webhooks, admin tasks, and background jobs. getDrizzleSupabaseClient respects RLS and should be used for all user-facing features."},
{"question": "Why do I get 'prepared statement already exists' errors?", "answer": "Supabase's connection pooler in Transaction mode doesn't support prepared statements. Add prepare: false to your postgres client options as shown in the setup."}
]
/%}
## Related Documentation
- [Data Fetching Overview](/docs/next-supabase-turbo/data-fetching) - All data fetching patterns in MakerKit
- [Supabase Clients](/docs/next-supabase-turbo/data-fetching/supabase-clients) - Understanding client types and RLS
- [Server Actions](/docs/next-supabase-turbo/data-fetching/server-actions) - Using authActionClient with database operations
- [Database Schema](/docs/next-supabase-turbo/development/database-schema) - MakerKit's database structure
- [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview) - Full API reference

View File

@@ -0,0 +1,927 @@
---
status: "published"
title: 'Creating an Onboarding and Checkout flows'
label: 'Onboarding Checkout'
order: 2
description: 'Learn how to create an onboarding and checkout flow in the Next.js Supabase Starter Kit.'
---
One popular request from customers is to have a way to onboard new users and guide them through the app, and have customers checkout before they can use the app.
In this guide, we'll show you how to create an onboarding and checkout flow in the Next.js Supabase Starter Kit.
In this guide, we will cover:
1. Creating an onboarding flow when a user signs up.
2. Creating a multi-step form to have customers create a new Team Account
3. Creating a checkout flow to have customers pay before they can use the app.
4. Use Webhooks to update the user's record after they have paid.
Remember: you can customize the onboarding and checkout flow to fit your app's needs. This is a starting point, and you can build on top of it.
**Important:** Please make sure you have pulled the latest changes from the main branch before you start this guide.
## Step 0: Adding an Onboarding Table
Before we create the onboarding flow, let's add a new table to store the onboarding data.
Create a new migration using the following command:
```bash
pnpm --filter web supabase migration new onboarding
```
This will create a new migration file at `apps/web/supabase/migrations/<timestamp>_onboarding.sql`. Open this file and add the following SQL code:
```sql {% title="apps/web/supabase/migrations/<timestamp>_onboarding.sql" %}
create table if not exists public.onboarding (
id uuid primary key default uuid_generate_v4(),
account_id uuid references public.accounts(id) not null unique,
data jsonb default '{}',
completed boolean default false,
created_at timestamp with time zone default current_timestamp,
updated_at timestamp with time zone default current_timestamp
);
revoke all on public.onboarding from public, service_role;
grant select, update, insert on public.onboarding to authenticated;
grant select, delete on public.onboarding to service_role;
alter table onboarding enable row level security;
create policy read_onboarding
on public.onboarding
for select
to authenticated
using (account_id = (select auth.uid()));
create policy insert_onboarding
on public.onboarding
for insert
to authenticated
with check (account_id = (select auth.uid()));
create policy update_onboarding
on public.onboarding
for update
to authenticated
using (account_id = (select auth.uid()))
with check (account_id = (select auth.uid()));
```
This migration creates a new `onboarding` table with the following columns:
- `id`: A unique identifier for the onboarding record.
- `account_id`: A foreign key reference to the `accounts` table.
- `data`: A JSONB column to store the onboarding data.
- `completed`: A boolean flag to indicate if the onboarding is completed.
- `created_at` and `updated_at`: Timestamps for when the record was created and updated.
The migration also sets up row-level security policies to ensure that users can only access their own onboarding records.
Update your DB schema by running the following command:
```bash
pnpm run supabase:web:reset
```
And update your DB types by running the following command:
```bash
pnpm run supabase:web:typegen
```
Now that we have the `onboarding` table set up, let's create the onboarding flow.
## Step 1: Create the Onboarding Page
First, let's create the main onboarding page. This will be the entry point for our onboarding flow.
Create a new file at `apps/web/app/onboarding/page.tsx`:
```tsx {% title="apps/web/app/onboarding/page.tsx" %}
import { AppLogo } from '~/components/app-logo';
import { OnboardingForm } from './_components/onboarding-form';
function OnboardingPage() {
return (
<div className="flex h-screen flex-col items-center justify-center space-y-16">
<AppLogo />
<div>
<OnboardingForm />
</div>
</div>
);
}
export default OnboardingPage;
```
This page is simple. It displays your app logo and the `OnboardingForm` component, which we'll create next.
## Step 2: Create the Onboarding Form Schema
Before we create the form, let's define its schema. This will help us validate the form data.
Create a new file at `apps/web/app/onboarding/_lib/onboarding-form.schema.ts`:
```typescript {% title="apps/web/app/onboarding/_lib/onboarding-form.schema.ts" %}
import * as z from 'zod';
export const OnboardingFormSchema = z.object({
profile: z.object({
name: z.string().min(1).max(255),
}),
team: z.object({
name: z.string().min(1).max(255),
}),
checkout: z.object({
planId: z.string().min(1),
productId: z.string().min(1),
}),
});
```
This schema defines the structure of our onboarding form. It has three main sections: profile, team, and checkout.
## Step 3: Create the Onboarding Form Component
Now, let's create the main `OnboardingForm` component. This is where the magic happens!
Create a new file at `apps/web/app/onboarding/_components/onboarding-form.tsx`:
```tsx {% title="apps/web/app/onboarding/_components/onboarding-form.tsx" %}
'use client';
import { useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import dynamic from 'next/dynamic';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { PlanPicker } from '@kit/billing-gateway/components';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
MultiStepForm,
MultiStepFormContextProvider,
MultiStepFormHeader,
MultiStepFormStep,
useMultiStepFormContext,
} from '@kit/ui/multi-step-form';
import { Stepper } from '@kit/ui/stepper';
import billingConfig from '~/config/billing.config';
import { OnboardingFormSchema } from '~/onboarding/_lib/onboarding-form.schema';
import { submitOnboardingFormAction } from '~/onboarding/_lib/server/server-actions';
const EmbeddedCheckout = dynamic(
async () => {
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
return {
default: EmbeddedCheckout,
};
},
{
ssr: false,
},
);
export function OnboardingForm() {
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
undefined,
);
const form = useForm({
resolver: zodResolver(OnboardingFormSchema),
defaultValues: {
profile: {
name: '',
},
team: {
name: '',
},
checkout: {
planId: '',
productId: '',
},
},
mode: 'onBlur',
});
const onSubmit = useCallback(
async (data: z.infer<typeof OnboardingFormSchema>) => {
try {
const { checkoutToken } = await submitOnboardingFormAction(data);
setCheckoutToken(checkoutToken);
} catch (error) {
console.error('Failed to submit form:', error);
}
},
[],
);
const checkoutPortalRef = useRef<HTMLDivElement>(null);
if (checkoutToken) {
return (
<EmbeddedCheckout
checkoutToken={checkoutToken}
provider={billingConfig.provider}
onClose={() => setCheckoutToken(undefined)}
/>
);
}
return (
<div
className={
'w-full rounded-lg p-8 shadow-sm duration-500 animate-in fade-in-90 zoom-in-95 slide-in-from-bottom-12 lg:border'
}
>
<MultiStepForm
className={'space-y-8 p-1'}
schema={OnboardingFormSchema}
form={form}
onSubmit={onSubmit}
>
<MultiStepFormHeader>
<MultiStepFormContextProvider>
{({ currentStepIndex }) => (
<Stepper
variant={'numbers'}
steps={['Profile', 'Team', 'Complete']}
currentStep={currentStepIndex}
/>
)}
</MultiStepFormContextProvider>
</MultiStepFormHeader>
<MultiStepFormStep name={'profile'}>
<ProfileStep />
</MultiStepFormStep>
<MultiStepFormStep name={'team'}>
<TeamStep />
</MultiStepFormStep>
<MultiStepFormStep name={'checkout'}>
<If condition={checkoutPortalRef.current}>
{(portalRef) => createPortal(<CheckoutStep />, portalRef)}
</If>
</MultiStepFormStep>
</MultiStepForm>
<div className={'p-1'} ref={checkoutPortalRef}></div>
</div>
);
}
function ProfileStep() {
const { nextStep, form } = useMultiStepFormContext();
return (
<Form {...form}>
<div className={'flex flex-col space-y-6'}>
<div className={'flex flex-col space-y-2'}>
<h1 className={'text-xl font-semibold'}>Welcome to Makerkit</h1>
<p className={'text-sm text-muted-foreground'}>
Welcome to the onboarding process! Let&apos;s get started by
entering your name.
</p>
</div>
<FormField
render={({ field }) => {
return (
<FormItem>
<FormLabel>Your Name</FormLabel>
<FormControl>
<Input {...field} placeholder={'Name'} />
</FormControl>
<FormDescription>Enter your full name here</FormDescription>
</FormItem>
);
}}
name={'profile.name'}
/>
<div className={'flex justify-end'}>
<Button onClick={nextStep}>Continue</Button>
</div>
</div>
</Form>
);
}
function TeamStep() {
const { nextStep, prevStep, form } = useMultiStepFormContext();
return (
<Form {...form}>
<div className={'flex w-full flex-col space-y-6'}>
<div className={'flex flex-col space-y-2'}>
<h1 className={'text-xl font-semibold'}>Create Your Team</h1>
<p className={'text-sm text-muted-foreground'}>
Let&apos;s create your team. Enter your team name below.
</p>
</div>
<FormField
render={({ field }) => {
return (
<FormItem>
<FormLabel>Your Team Name</FormLabel>
<FormControl>
<Input {...field} placeholder={'Name'} />
</FormControl>
<FormDescription>
This is the name of your team.
</FormDescription>
</FormItem>
);
}}
name={'team.name'}
/>
<div className={'flex justify-end space-x-2'}>
<Button variant={'ghost'} onClick={prevStep}>
Go Back
</Button>
<Button onClick={nextStep}>Continue</Button>
</div>
</div>
</Form>
);
}
function CheckoutStep() {
const { form, mutation } = useMultiStepFormContext();
return (
<Form {...form}>
<div className={'flex w-full flex-col space-y-6 lg:min-w-[55rem]'}>
<div className={'flex flex-col space-y-2'}>
<PlanPicker
pending={mutation.isPending}
config={billingConfig}
onSubmit={({ planId, productId }) => {
form.setValue('checkout.planId', planId);
form.setValue('checkout.productId', productId);
mutation.mutate();
}}
/>
</div>
</div>
</Form>
);
}
```
This component creates a multi-step form for the onboarding process. It includes steps for profile information, team creation, and plan selection.
## Step 4: Create the Server Action
Now, let's create the server action that will handle the form submission.
Create a new file at `apps/web/app/onboarding/_lib/server/server-actions.ts`:
```typescript {% title="apps/web/app/onboarding/_lib/server/server-actions.ts" %}
'use server';
import { redirect } from 'next/navigation';
import { createBillingGatewayService } from '@kit/billing-gateway';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { OnboardingFormSchema } from '~/onboarding/_lib/onboarding-form.schema';
export const submitOnboardingFormAction = authActionClient
.inputSchema(OnboardingFormSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const logger = await getLogger();
logger.info({ userId: user.id }, `Submitting onboarding form...`);
const isOnboarded = user.app_metadata.onboarded === true;
if (isOnboarded) {
logger.info(
{ userId: user.id },
`User is already onboarded. Redirecting...`,
);
redirect(pathsConfig.app.home);
}
const client = getSupabaseServerClient();
const createTeamResponse = await client
.from('accounts')
.insert({
name: data.team.name,
primary_owner_user_id: user.id,
is_personal_account: false,
})
.select('id')
.single();
if (createTeamResponse.error) {
logger.error(
{
error: createTeamResponse.error,
},
`Failed to create team`,
);
throw createTeamResponse.error;
} else {
logger.info(
{ userId: user.id, teamId: createTeamResponse.data.id },
`Team created. Creating onboarding data...`,
);
}
const response = await client.from('onboarding').upsert(
{
account_id: user.id,
data: {
userName: data.profile.name,
teamAccountId: createTeamResponse.data.id,
},
completed: true,
},
{
onConflict: 'account_id',
},
);
if (response.error) {
throw response.error;
}
logger.info(
{ userId: user.id, teamId: createTeamResponse.data.id },
`Onboarding data created. Creating checkout session...`,
);
const billingService = createBillingGatewayService(billingConfig.provider);
const { plan, product } = getPlanDetails(
data.checkout.productId,
data.checkout.planId,
);
const returnUrl = new URL('/onboarding/complete', appConfig.url).href;
const checkoutSession = await billingService.createCheckoutSession({
returnUrl,
customerEmail: user.email,
accountId: createTeamResponse.data.id,
plan,
variantQuantities: [],
enableDiscountField: product.enableDiscountField,
metadata: {
source: 'onboarding',
userId: user.id,
},
});
return {
checkoutToken: checkoutSession.checkoutToken,
};
});
function getPlanDetails(productId: string, planId: string) {
const product = billingConfig.products.find(
(product) => product.id === productId,
);
if (!product) {
throw new Error('Product not found');
}
const plan = product?.plans.find((plan) => plan.id === planId);
if (!plan) {
throw new Error('Plan not found');
}
return { plan, product };
}
```
This server action handles the form submission, inserts the onboarding data into Supabase, create a team (so we can assign it a subscription), and creates a checkout session for the selected plan.
Once the checkout is completed, the user will be redirected to the `/onboarding/complete` page. This page will be created in the next step.
## Step 6: Enhancing the Stripe Webhook Handler
This change extends the functionality of the Stripe webhook handler to complete the onboarding process after a successful checkout. Here's what's happening:
In the `handleCheckoutSessionCompleted` method, we add new logic to handle onboarding-specific actions.
First, define the `completeOnboarding` function to process the onboarding data and create a team based on the user's input.
**Note:** This is valid for Stripe, but you can adapt it to any other payment provider.
```typescript {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %}
async function completeOnboarding(accountId: string) {
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
logger.info(
{ accountId },
`Checkout comes from onboarding. Processing onboarding data...`,
);
const onboarding = await adminClient
.from('onboarding')
.select('*')
.eq('account_id', accountId)
.single();
if (onboarding.error) {
logger.error(
{ error: onboarding.error, accountId },
`Failed to retrieve onboarding data`,
);
// if there's an error, we can't continue
return;
} else {
logger.info({ accountId }, `Onboarding data retrieved. Processing...`);
const data = onboarding.data.data as {
userName: string;
teamAccountId: string;
};
const teamAccountId = data.teamAccountId;
logger.info(
{ userId: accountId, teamAccountId },
`Assigning membership...`,
);
const assignMembershipResponse = await adminClient
.from('accounts_memberships')
.insert({
account_id: teamAccountId,
user_id: accountId,
account_role: 'owner',
});
if (assignMembershipResponse.error) {
logger.error(
{
error: assignMembershipResponse.error,
},
`Failed to assign membership`,
);
} else {
logger.info({ accountId }, `Membership assigned. Updating account...`);
}
const accountResponse = await adminClient
.from('accounts')
.update({
name: data.userName,
})
.eq('id', accountId);
if (accountResponse.error) {
logger.error(
{
error: accountResponse.error,
},
`Failed to update account`,
);
} else {
logger.info(
{ accountId },
`Account updated. Cleaning up onboarding data...`,
);
}
// set onboarded flag on user account
const updateUserResponse = await adminClient.auth.admin.updateUserById(
accountId,
{
app_metadata: {
onboarded: true,
},
},
);
if (updateUserResponse.error) {
logger.error(
{
error: updateUserResponse.error,
},
`Failed to update user`,
);
} else {
logger.info({ accountId }, `User updated. Cleaning up...`);
}
// clean up onboarding data
const deleteOnboardingResponse = await adminClient
.from('onboarding')
.delete()
.eq('account_id', accountId);
if (deleteOnboardingResponse.error) {
logger.error(
{
error: deleteOnboardingResponse.error,
},
`Failed to delete onboarding data`,
);
} else {
logger.info(
{ accountId },
`Onboarding data cleaned up. Completed webhook handler.`,
);
}
}
}
```
Now, we handle this function in the `handleCheckoutSessionCompleted` method, right before the `onCheckoutCompletedCallback` is called.
```typescript {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %}
// ...
const subscriptionData =
await stripe.subscriptions.retrieve(subscriptionId);
const metadata = subscriptionData.metadata as {
source: string;
userId: string;
} | undefined;
// if the checkout comes from onboarding
// we need to complete the onboarding process
if (metadata?.source === 'onboarding') {
const userId = metadata.userId;
await completeOnboarding(userId);
}
return onCheckoutCompletedCallback(payload);
```
This enhanced webhook handler completes the onboarding process by creating a team account, updating the user's personal account, and marking the user as "onboarded" in their Supabase user metadata.
## Step 7: Handling the Onboarding Completion Page
The checkout will rediret to the `/onboarding/complete` page after the onboarding process is completed. This is because there is no telling when the webhook will be triggered.
In this page, we will start fetching the user data and verify if the user has been onboarded.
If the user has been onboarded correctly, we will redirect the user to the `/home` page. If not, we will show a loading spinner and keep checking the user's onboarded status until it is true.
```tsx {% title="/apps/web/app/onboarding/complete/page.tsx" %}
'use client';
import { useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import pathsConfig from '~/config/paths.config';
export default function OnboardingCompletePage() {
const { error } = useCheckUserOnboarded();
if (error) {
return (
<div className={'flex flex-col items-center justify-center'}>
<p>Something went wrong...</p>
</div>
);
}
return <LoadingOverlay>Setting up your account...</LoadingOverlay>;
}
/**
* @description
* This function checks if the user is onboarded
* If the user is onboarded, it redirects them to the home page
* it retries every second until the user is onboarded
*/
function useCheckUserOnboarded() {
const client = useSupabase();
const countRef = useRef(0);
const maxCount = 10;
const error = countRef.current >= maxCount;
useQuery({
queryKey: ['onboarding-complete'],
refetchInterval: () => (error ? false : 1000),
queryFn: async () => {
if (error) {
return false;
}
countRef.current++;
const response = await client.auth.getUser();
if (response.error) {
throw response.error;
}
const onboarded = response.data.user.app_metadata.onboarded;
// if the user is onboarded, redirect them to the home page
if (onboarded) {
return window.location.assign(pathsConfig.app.home);
}
return false;
},
});
return {
error,
};
}
```
This page
## What This Means for Your Onboarding Flow
With these changes, your onboarding process now includes these additional steps:
1. When a user completes the checkout during onboarding, it triggers this enhanced webhook handler.
2. The handler retrieves the onboarding data that was saved earlier in the process.
3. It creates a new team account with the name provided during onboarding.
4. It updates the user's personal account with their name.
5. Finally, it marks the user as "onboarded" in their Supabase user metadata.
This completes the onboarding process, ensuring that all the information collected during onboarding is properly saved and the user's account is fully set up.
## Step 8: Update Your App's Routing
To integrate this onboarding flow into your app, you'll need to update your routing logic.
We can do this in the middleware, in the logic branch that handles the `/home` route. If the user is not logged in, we'll redirect them to the sign-in page. If the user is logged in but has not completed onboarding, we'll redirect them to the onboarding flow.
Update the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16):
```typescript {% title="apps/web/proxy.ts" %}
{
pattern: new URLPattern({ pathname: '/home/*?' }),
handler: async (req: NextRequest, res: NextResponse) => {
const {
data,
} = await getUser(req, res);
const origin = req.nextUrl.origin;
const next = req.nextUrl.pathname;
const claims = data?.claims;
// If user is not logged in, redirect to sign in page.
if (!claims) {
const signIn = pathsConfig.auth.signIn;
const redirectPath = `${signIn}?next=${next}`;
return NextResponse.redirect(new URL(redirectPath, origin).href);
}
// verify if user has completed onboarding
const isOnboarded = claims.app_metadata.onboarded;
// If user is logged in but has not completed onboarding,
if (!isOnboarded) {
return NextResponse.redirect(new URL('/onboarding', origin).href);
}
const supabase = createMiddlewareClient(req, res);
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(supabase);
// If user requires multi-factor authentication, redirect to MFA page.
if (requiresMultiFactorAuthentication) {
return NextResponse.redirect(
new URL(pathsConfig.auth.verifyMfa, origin).href,
);
}
},
}
```
This code checks if the user is logged in and has completed onboarding. If the user has not completed onboarding, they are redirected to the onboarding flow.
Also add the following pattern to make sure the user is authenticated when visiting the `/onboarding` route:
```typescript {% title="apps/web/proxy.ts" %}
{
pattern: new URLPattern({ pathname: '/onboarding/*?' }),
handler: async (req: NextRequest, res: NextResponse) => {
const {
data,
} = await getUser(req, res);
const claims = data?.claims;
// If user is not logged in, redirect to sign in page.
if (!claims) {
const signIn = pathsConfig.auth.signIn;
const redirectPath = `${signIn}?next=${next}`;
return NextResponse.redirect(new URL(redirectPath, origin).href);
}
const supabase = createMiddlewareClient(req, res);
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(supabase);
// If user requires multi-factor authentication, redirect to MFA page.
if (requiresMultiFactorAuthentication) {
return NextResponse.redirect(
new URL(pathsConfig.auth.verifyMfa, origin).href,
);
}
// verify if user has completed onboarding
const isOnboarded = claims.app_metadata.onboarded;
// If user completed onboarding, redirect to home page
if (isOnboarded) {
return NextResponse.redirect(new URL(pathsConfig.app.home, origin).href);
}
},
}
```
### Marking invited users as onboarded
When a user gets invited to a team account, you don't want to show them the onboarding flow again. You can use the `onboarded` property to mark the user as onboarded.
Update the `packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts` server action `acceptInvitationAction` at line 125 (eg. before increasing seats):
```tsx
// mark user as onboarded
await adminClient.auth.admin.updateUserById(user.id, {
app_metadata: {
onboarded: true,
},
});
```
In this way, the user will be redirected to the `/home` page after accepting the invite.
### Considerations
Remember, the user can always unsubscribe from the plan they selected during onboarding. You should handle this case in your billing system if your app always requires a plan to be active.
## Conclusion
That's it! You've now added an onboarding flow to your Makerkit Next.js Supabase Turbo project.
This flow includes:
1. A profile information step
2. A team creation step
3. A plan selection step
4. Integration with your billing system
Remember to style your components, handle errors gracefully, and test thoroughly. Happy coding! 🚀

View File

@@ -0,0 +1,18 @@
---
status: "published"
title: 'Disabling Team Accounts in Next.js Supabase'
label: 'Disabling Team Accounts'
order:
description: 'Learn how to disable team accounts in the Next.js Supabase Turbo SaaS kit and only allow personal accounts'
---
Disabling team accounts and only allowing personal accounts is a common requirement for B2C SaaS applications.
To do so, you can tweak the following environment variables:
```bash
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false
```
That's it! Team account UI components will be hidden, and users will only be able to access their personal account.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,801 @@
---
status: "published"
title: 'Checkout Addons with Stripe Billing'
label: 'Checkout Addons with Stripe Billing'
order: 3
description: 'Learn how to create a subscription with addons using Stripe Billing.'
---
Stripe allows us to add multiple line items to a single subscription. This is useful when you want to offer additional features or services to your customers.
This feature is not supported by default in Makerkit. However, in this guide, I will show you how to create a subscription with addons using Stripe Billing, and how to customize Makerkit to support this feature.
Let's get started!
## 1. Personal Account Checkout Form
File: `apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx`
Update your `PersonalAccountCheckoutForm` component to pass addon data to the checkout session creation process:
```typescript {% title="home/(user)/billing/_components/personal-account-checkout-form.tsx" %}
<CheckoutForm
// ...existing props
onSubmit={({ planId, productId, addons }) => {
startTransition(async () => {
try {
const { checkoutToken } = await createPersonalAccountCheckoutSession({
planId,
productId,
addons, // Add this line
});
setCheckoutToken(checkoutToken);
} catch {
setError(true);
}
});
}}
/>
```
This change allows the checkout form to handle addon selections and pass them to the checkout session creation process.
## 2. Personal Account Checkout Schema
Let's add addon support to the personal account checkout schema. The `addons` is an array of objects, each containing a `productId` and `planId`. By default, the `addons` array is empty.
Update your `PersonalAccountCheckoutSchema`:
```typescript {% title="home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts" %}
export const PersonalAccountCheckoutSchema = z.object({
planId: z.string().min(1),
productId: z.string().min(1),
addons: z
.array(
z.object({
productId: z.string().min(1),
planId: z.string().min(1),
}),
)
.default([]),
});
```
This schema update ensures that the addon data is properly validated before being processed.
## 3. User Billing Service
Update your `createCheckoutSession` method. This method is responsible for creating a checkout session with the billing gateway. We need to pass the addon data to the billing gateway:
```typescript {% title="home/(user)/billing/_lib/server/user-billing.service.ts" %}
async createCheckoutSession({
planId,
productId,
addons,
}: z.infer<typeof PersonalAccountCheckoutSchema>) {
// ...existing code
const checkoutToken = await this.billingGateway.createCheckoutSession({
// ...existing props
addons,
});
// ...rest of the method
}
```
This change ensures that the addon information is passed to the billing gateway when creating a checkout session.
## 4. Team Account Checkout Form
File: `apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx`
Make similar changes to the `TeamAccountCheckoutForm` as we did for the personal account form.
## 5. Team Billing Schema
File: `apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts`
Update your `TeamCheckoutSchema` similar to the personal account schema.
## 6. Team Billing Service
File: `apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts`
Update the `createCheckoutSession` method similar to the user billing service.
## 7. Billing Configuration
We can now add addons to our billing configuration. Update your billing configuration file to include addons:
```typescript {% title="apps/web/config/billing.sample.config.ts" %}
plans: [
{
// ...existing plan config
addons: [
{
id: 'price_1J4J9zL2c7J1J4J9zL2c7J1',
name: 'Extra Feature',
cost: 9.99,
type: 'flat' as const,
},
],
},
],
```
**Note:** The `ID` of the addon should match the `planId` in your Stripe account.
## 8. Localization
Add a new translation key for translating the term "Add-ons" in your billing locale file:
```json {% title="apps/web/i18n/messages/en/billing.json" %}
{
// ...existing translations
"addons": "Add-ons"
}
```
## 9. Billing Schema
File: `packages/billing/core/src/create-billing-schema.ts`
The billing schema has been updated to include addons. You don't need to change this file, but be aware that the schema now supports addons.
## 10. Create Billing Checkout Schema
File: `packages/billing/core/src/schema/create-billing-checkout.schema.ts`
The checkout schema now includes addons. Again, you don't need to change this file, but your checkout process will now support addons.
## 11. Plan Picker Component
File: `packages/billing/gateway/src/components/plan-picker.tsx`
This component has been significantly updated to handle addons. It now displays addons as checkboxes and manages their state.
Here's the updated Plan Picker component:
```tsx {% title="packages/billing/gateway/src/components/plan-picker.tsx" %}
'use client';
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight, CheckCircle } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useLocale, useTranslations } from 'next-intl';
import * as z from 'zod';
import {
BillingConfig,
type LineItemSchema,
getPlanIntervals,
getPrimaryLineItem,
getProductPlanPair,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Label } from '@kit/ui/label';
import {
RadioGroup,
RadioGroupItem,
RadioGroupItemLabel,
} from '@kit/ui/radio-group';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
const AddonSchema = z.object({
name: z.string(),
id: z.string(),
productId: z.string(),
planId: z.string(),
cost: z.number(),
});
type OnSubmitData = {
planId: string;
productId: string;
addons: z.infer<typeof AddonSchema>[];
};
export function PlanPicker(
props: React.PropsWithChildren<{
config: BillingConfig;
onSubmit: (data: OnSubmitData) => void;
canStartTrial?: boolean;
pending?: boolean;
}>,
) {
const t = useTranslations(`billing`);
const intervals = useMemo(
() => getPlanIntervals(props.config),
[props.config],
) as string[];
const form = useForm({
reValidateMode: 'onChange',
mode: 'onChange',
resolver: zodResolver(
z
.object({
planId: z.string(),
productId: z.string(),
interval: z.string().optional(),
addons: z.array(AddonSchema).optional(),
})
.refine(
(data) => {
try {
const { product, plan } = getProductPlanPair(
props.config,
data.planId,
);
return product && plan;
} catch {
return false;
}
},
{ message: t('noPlanChosen'), path: ['planId'] },
),
),
defaultValues: {
interval: intervals[0],
planId: '',
productId: '',
addons: [] as z.infer<typeof AddonSchema>[],
},
});
const { interval: selectedInterval } = form.watch();
const planId = form.getValues('planId');
const { plan: selectedPlan, product: selectedProduct } = useMemo(() => {
try {
return getProductPlanPair(props.config, planId);
} catch {
return {
plan: null,
product: null,
};
}
}, [props.config, planId]);
const addons = form.watch('addons');
const onAddonAdded = (data: z.infer<typeof AddonSchema>) => {
form.setValue('addons', [...addons, data], { shouldValidate: true });
};
const onAddonRemoved = (id: string) => {
form.setValue(
'addons',
addons.filter((item) => item.id !== id),
{ shouldValidate: true },
);
};
// display the period picker if the selected plan is recurring or if no plan is selected
const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
const locale = useLocale();
return (
<Form {...form}>
<div
className={
'flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0'
}
>
<form
className={'flex w-full max-w-xl flex-col space-y-6'}
onSubmit={form.handleSubmit(props.onSubmit)}
>
<If condition={intervals.length}>
<div
className={cn('transition-all', {
['pointer-events-none opacity-50']: !isRecurringPlan,
})}
>
<FormField
name={'interval'}
render={({ field }) => {
return (
<FormItem className={'rounded-md border p-4'}>
<FormLabel htmlFor={'plan-picker-id'}>
<Trans i18nKey={'common.billingInterval.label'} />
</FormLabel>
<FormControl id={'plan-picker-id'}>
<RadioGroup name={field.name} value={field.value}>
<div className={'flex space-x-2.5'}>
{intervals.map((interval) => {
const selected = field.value === interval;
return (
<label
htmlFor={interval}
key={interval}
className={cn(
'flex items-center space-x-2 rounded-md border border-transparent px-4 py-2 transition-colors',
{
['border-primary']: selected,
['hover:border-primary']: !selected,
},
)}
>
<RadioGroupItem
id={interval}
value={interval}
onClick={() => {
form.setValue('interval', interval, {
shouldValidate: true,
});
form.setValue('addons', [], {
shouldValidate: true,
});
if (selectedProduct) {
const plan = selectedProduct.plans.find(
(item) => item.interval === interval,
);
form.setValue(
'planId',
plan?.id ?? '',
{
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
},
);
}
}}
/>
<span
className={cn('text-sm', {
['cursor-pointer']: !selected,
})}
>
<Trans
i18nKey={`billing.billingInterval.${interval}`}
/>
</span>
</label>
);
})}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
</If>
<FormField
name={'planId'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common.planPickerLabel'} />
</FormLabel>
<FormControl>
<RadioGroup value={field.value} name={field.name}>
{props.config.products.map((product) => {
const plan = product.plans.find((item) => {
if (item.paymentType === 'one-time') {
return true;
}
return item.interval === selectedInterval;
});
if (!plan || plan.custom) {
return null;
}
const planId = plan.id;
const selected = field.value === planId;
const primaryLineItem = getPrimaryLineItem(
props.config,
planId,
);
if (!primaryLineItem) {
throw new Error(`Base line item was not found`);
}
return (
<RadioGroupItemLabel
selected={selected}
key={primaryLineItem.id}
>
<RadioGroupItem
data-test-plan={plan.id}
key={plan.id + selected}
id={plan.id}
value={plan.id}
onClick={() => {
if (selected) {
return;
}
form.setValue('planId', planId, {
shouldValidate: true,
});
form.setValue('productId', product.id, {
shouldValidate: true,
});
form.setValue('addons', [], {
shouldValidate: true,
});
}}
/>
<div
className={
'flex w-full flex-col content-center space-y-2 lg:flex-row lg:items-center lg:justify-between lg:space-y-0'
}
>
<Label
htmlFor={plan.id}
className={
'flex flex-col justify-center space-y-2'
}
>
<div className={'flex items-center space-x-2.5'}>
<span className="font-semibold">
<Trans
i18nKey={`billing.plans.${product.id}.name`}
defaults={product.name}
/>
</span>
<If
condition={
plan.trialDays && props.canStartTrial
}
>
<div>
<Badge
className={'px-1 py-0.5 text-xs'}
variant={'success'}
>
<Trans
i18nKey={`billing.trialPeriod`}
values={{
period: plan.trialDays,
}}
/>
</Badge>
</div>
</If>
</div>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing.plans.${product.id}.description`}
defaults={product.description}
/>
</span>
</Label>
<div
className={
'flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-x-4 lg:space-y-0 lg:text-right'
}
>
<div>
<Price key={plan.id}>
<span>
{formatCurrency({
currencyCode:
product.currency.toLowerCase(),
value: primaryLineItem.cost,
locale,
})}
</span>
</Price>
<div>
<span className={'text-muted-foreground'}>
<If
condition={
plan.paymentType === 'recurring'
}
fallback={
<Trans i18nKey={`billing.lifetime`} />
}
>
<Trans
i18nKey={`billing.perPeriod`}
values={{
period: selectedInterval,
}}
/>
</If>
</span>
</div>
</div>
</div>
</div>
</RadioGroupItemLabel>
);
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<If condition={selectedPlan?.addons}>
<div className={'flex flex-col space-y-2.5'}>
<span className={'text-sm font-medium'}>Addons</span>
<div className={'flex flex-col space-y-2'}>
{selectedPlan?.addons?.map((addon) => {
return (
<div
className={'flex items-center space-x-2 text-sm'}
key={addon.id}
>
<Checkbox
value={addon.id}
onCheckedChange={() => {
if (addons.some((item) => item.id === addon.id)) {
onAddonRemoved(addon.id);
} else {
onAddonAdded({
productId: selectedProduct.id,
planId: selectedPlan.id,
id: addon.id,
name: addon.name,
cost: addon.cost,
});
}
}}
/>
<span>{addon.name}</span>
</div>
);
})}
</div>
</div>
</If>
<div>
<Button
data-test="checkout-submit-button"
disabled={props.pending ?? !form.formState.isValid}
>
{props.pending ? (
t('redirectingToPayment')
) : (
<>
<If
condition={selectedPlan?.trialDays && props.canStartTrial}
fallback={t(`proceedToPayment`)}
>
<span>{t(`startTrial`)}</span>
</If>
<ArrowRight className={'ml-2 h-4 w-4'} />
</>
)}
</Button>
</div>
</form>
{selectedPlan && selectedInterval && selectedProduct ? (
<PlanDetails
selectedInterval={selectedInterval}
selectedPlan={selectedPlan}
selectedProduct={selectedProduct}
addons={addons}
/>
) : null}
</div>
</Form>
);
}
function PlanDetails({
selectedProduct,
selectedInterval,
selectedPlan,
addons = [],
}: {
selectedProduct: {
id: string;
name: string;
description: string;
currency: string;
features: string[];
};
selectedInterval: string;
selectedPlan: {
lineItems: z.infer<typeof LineItemSchema>[];
paymentType: string;
};
addons: z.infer<typeof AddonSchema>[];
}) {
const isRecurring = selectedPlan.paymentType === 'recurring';
const locale = useLocale();
// trick to force animation on re-render
const key = Math.random();
return (
<div
key={key}
className={
'fade-in animate-in zoom-in-95 flex w-full flex-col space-y-4 py-2 lg:px-8'
}
>
<div className={'flex flex-col space-y-0.5'}>
<span className={'text-sm font-medium'}>
<b>
<Trans
i18nKey={`billing:plans.${selectedProduct.id}.name`}
defaults={selectedProduct.name}
/>
</b>{' '}
<If condition={isRecurring}>
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</If>
</span>
<p>
<span className={'text-muted-foreground text-sm'}>
<Trans
i18nKey={`billing:plans.${selectedProduct.id}.description`}
defaults={selectedProduct.description}
/>
</span>
</p>
</div>
<If condition={selectedPlan.lineItems.length > 0}>
<Separator />
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-semibold'}>
<Trans i18nKey={'billing.detailsLabel'} />
</span>
<LineItemDetails
lineItems={selectedPlan.lineItems ?? []}
selectedInterval={isRecurring ? selectedInterval : undefined}
currency={selectedProduct.currency}
/>
</div>
</If>
<Separator />
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-semibold'}>
<Trans i18nKey={'billing.featuresLabel'} />
</span>
{selectedProduct.features.map((item) => {
return (
<div key={item} className={'flex items-center space-x-1 text-sm'}>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-secondary-foreground'}>
<Trans i18nKey={item} defaults={item} />
</span>
</div>
);
})}
</div>
<If condition={addons.length > 0}>
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-semibold'}>
<Trans i18nKey={'billing.addons'} />
</span>
{addons.map((addon) => {
return (
<div
key={addon.id}
className={'flex items-center space-x-1 text-sm'}
>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-secondary-foreground'}>
<Trans i18nKey={addon.name} defaults={addon.name} />
</span>
<span>-</span>
<span className={'text-xs font-semibold'}>
{formatCurrency({
currencyCode: selectedProduct.currency.toLowerCase(),
value: addon.cost,
locale,
})}
</span>
</div>
);
})}
</div>
</If>
</div>
);
}
function Price(props: React.PropsWithChildren) {
return (
<span
className={
'animate-in slide-in-from-left-4 fade-in text-xl font-semibold tracking-tight duration-500'
}
>
{props.children}
</span>
);
}
```
## 12. Stripe Checkout Creation
File: `packages/billing/stripe/src/services/create-stripe-checkout.ts`
The Stripe checkout creation process now includes addons:
```typescript
if (params.addons.length > 0) {
lineItems.push(
...params.addons.map((addon) => ({
price: addon.planId,
quantity: 1,
})),
);
}
```
This change ensures that selected addons are included in the Stripe checkout session.
## Conclusion
These changes introduce a flexible addon system to Makerkit. By implementing these updates, you'll be able to offer additional features or services alongside your main subscription plans.
Remember, while adding addons to the checkout process is now straightforward, managing them post-purchase (like allowing users to add or remove addons from an active subscription) will require additional custom development. Consider your specific use case and user needs when implementing this feature.

View File

@@ -0,0 +1,892 @@
---
status: "published"
title: 'Subscription Entitlements in Next.js Supabase'
label: 'Subscription Entitlements'
order: 3
description: 'Learn how to effectively manage access and entitlements based on subscriptions in your Next.js Supabase app.'
---
As your SaaS grows, the complexity of managing user entitlements increases. In this guide, well build a flexible, performant, and secure entitlements system using Makerkit, Supabase, and PostgreSQL.
This solution leverages the power of PostgreSQL functions and Supabase RPCs, enforcing business logic at the database level while integrating seamlessly with your Next.js app.
**Note:** This is a generic solution, but you can use it as a starting point to build your own custom entitlements system. In fact, I recommend you to do so, as your needs will evolve and this solution might not cover all your requirements.
### Why a Custom Entitlements System?
Makerkit is built to be flexible and extensible. Instead of offering a one-size-fits-all entitlements system, Makerkit provides a foundation you can customize.
This article will walk you through the complete process:
- **Flexibility & Extensibility:** Easily handle different entitlement types (flat or usage quotas).
- **Performance:** Offload entitlement checks to the database.
- **Consistency & Security:** Ensure rules are enforced both in your app code and via Row Level Security (RLS).
## Step 1: Define Your Database Schema
We start by creating two tables: one to declare the entitlements for each plan variant and another to track feature usage per account. Both tables are set up with strict security rules using RLS policies.
### Creating the `plan_entitlements` Table
This table stores entitlement definitions. Each row defines which features are enabled for a specific plan variant. Note the use of a unique constraint to avoid duplicate entries and strict permission controls to ensure data security.
```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %}
-- Table to store plan entitlements
CREATE TABLE public.plan_entitlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id VARCHAR(255) NOT NULL,
feature VARCHAR(255) NOT NULL,
entitlement JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (variant_id, feature)
);
revoke all on public.plan_entitlements from public;
alter table public.plan_entitlements enable row level security;
grant select on public.plan_entitlements to authenticated;
create policy select_plan_entitlements
on public.plan_entitlements
for select
to authenticated
using (true);
```
### Creating the `feature_usage` Table
This table tracks the usage of features for each account. We use JSONB to support flexible usage metrics and add an index for efficient lookups.
```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %}
e to store feature usage
CREATE TABLE public.feature_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
feature VARCHAR(255) NOT NULL,
usage JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (account_id, feature)
);
revoke all on public.feature_usage from public;
grant select on public.feature_usage to authenticated;
alter table public.feature_usage enable row level security;
create policy select_feature_usage
on public.feature_usage
for select
to authenticated
using (
public.has_role_on_account(account_id) or ((select auth.uid()) = account_id)
);
-- Index for faster lookups
CREATE INDEX idx_feature_usage_account_id ON public.feature_usage(account_id, feature);
```
### Automatically Creating a Usage Row for New Accounts
Use a trigger to ensure that as soon as an account is created, a corresponding row in `feature_usage` is created:
```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %}
-- Function to auto-create a feature_usage row upon account creation
CREATE OR REPLACE FUNCTION public.create_feature_usage_row()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.feature_usage (account_id, feature)
VALUES (NEW.id, '');
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create the trigger to execute the above function after account creation
CREATE TRIGGER create_feature_usage_row
AFTER INSERT ON public.accounts
FOR EACH ROW
EXECUTE FUNCTION public.create_feature_usage_row();
```
---
## Step 2: Develop PostgreSQL Functions for Entitlements
Encapsulate the core entitlement logic inside PostgreSQL functions. Each function is designed to be secure (using `SECURITY INVOKER` or `SECURITY DEFINER` where needed) and atomic.
### Check if an Account Can Use a Feature
This function determines if an account meets the criteria to use a given feature based on its subscription and plan entitlements.
```sql
-- Function to check if an account can use a feature
CREATE OR REPLACE FUNCTION public.can_use_feature(
p_account_id UUID,
p_feature VARCHAR
)
RETURNS BOOLEAN
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
entitlement_data JSONB;
subscription_type public.subscription_item_type;
account_member_count INTEGER;
usage_count INTEGER;
feature_usage_exists BOOLEAN;
BEGIN
-- Verify the account exists
PERFORM 1 FROM public.accounts WHERE id = p_account_id;
IF NOT FOUND THEN
RETURN FALSE;
END IF;
-- verify the user has access to the account
if (select public.has_role_on_account(p_account_id) is false) then
return false;
end if;
-- Simplified approach:
-- 1. Get the subscription items for the account
-- 2. Find which subscription item has a plan entitlement for the feature
-- 3. Choose the most appropriate item based on type priority
SELECT
si.type,
pe.entitlement
INTO
subscription_type,
entitlement_data
FROM
public.subscriptions s
JOIN public.subscription_items si ON s.id = si.subscription_id
JOIN public.plan_entitlements pe ON si.variant_id = pe.variant_id
WHERE
s.account_id = p_account_id
AND s.active = true
AND pe.feature = p_feature
ORDER BY
-- Prioritize by subscription type (flat > per_seat > metered)
CASE
WHEN si.type = 'flat' THEN 1
WHEN si.type = 'per_seat' THEN 2
WHEN si.type = 'metered' THEN 3
ELSE 4
END,
-- Then by max_usage (higher limits first)
COALESCE((pe.entitlement->>'max_usage')::INTEGER, 0) DESC
LIMIT 1;
-- If no subscription or entitlement found for the feature, return false
IF subscription_type IS NULL THEN
RETURN FALSE;
END IF;
-- Check if the subscription type is flat
IF subscription_type = 'flat' THEN
-- If the subscription type is flat, then the account can use the feature
RETURN TRUE;
END IF;
-- Check if the subscription type is per_seat
IF subscription_type = 'per_seat' THEN
-- Get the number of users in the account
SELECT COUNT(*) INTO account_member_count FROM public.accounts_memberships WHERE account_id = p_account_id;
-- Check if the number of users in the account is within the allowed limit
-- Use strict less-than to prevent exceeding the limit
IF account_member_count < (entitlement_data ->> 'max_usage')::INTEGER THEN
-- If the number of users in the account is within the allowed limit, then the account can use the feature
RETURN TRUE;
ELSE
-- If the number of users in the account is not within the allowed limit, then the account cannot use the feature
RETURN FALSE;
END IF;
END IF;
-- Check if the subscription type is metered
IF subscription_type = 'metered' THEN
-- Check if feature usage record exists
SELECT EXISTS (
SELECT 1 FROM public.feature_usage
WHERE account_id = p_account_id AND feature = p_feature
) INTO feature_usage_exists;
-- If no feature usage record exists, create one with zero usage
IF NOT feature_usage_exists THEN
-- insert a new feature usage record with zero usage
INSERT INTO public.feature_usage (account_id, feature, usage)
VALUES (p_account_id, p_feature, '{"count": 0}'::jsonb);
END IF;
-- Get the usage count from the feature usage record
SELECT (usage ->> 'count')::INTEGER INTO usage_count FROM public.feature_usage WHERE account_id = p_account_id AND feature = p_feature;
-- Check if the feature usage is within the allowed limit
-- Use strict less-than to prevent exceeding the limit
IF usage_count < (entitlement_data ->> 'max_usage')::INTEGER THEN
-- If the feature usage is strictly less than the limit, then the account can use the feature
RETURN TRUE;
ELSE
-- If the feature usage has reached or exceeded the limit, then the account cannot use the feature
RETURN FALSE;
END IF;
END IF;
-- If the subscription type is not flat, per_seat, or metered, then the account cannot use the feature
RETURN FALSE;
END;
$$ LANGUAGE plpgsql;
```
### Retrieve Entitlement Details
The following function returns the details of an entitlement along with any usage data for the account.
```sql {% title="apps/web/supabase/migrations/20250205034829_subscription-entitlements.sql" %}
- Function to get entitlement details
CREATE OR REPLACE FUNCTION public.get_entitlement(
p_account_id UUID,
p_feature VARCHAR
)
RETURNS TABLE(variant_id varchar(255), entitlement JSONB, type public.subscription_item_type, usage JSONB)
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
RETURN QUERY
SELECT
si.variant_id,
pe.entitlement,
si.type,
COALESCE(fu.usage, '{}'::jsonb) as usage
FROM
public.subscriptions s
JOIN public.subscription_items si ON s.id = si.subscription_id
JOIN public.plan_entitlements pe ON si.variant_id = pe.variant_id
LEFT JOIN public.feature_usage fu ON s.account_id = fu.account_id AND pe.feature = fu.feature
WHERE
s.account_id = p_account_id
AND pe.feature = p_feature
ORDER BY
-- First by active status
s.active DESC,
-- Then by subscription type (flat > per_seat > metered)
CASE
WHEN si.type = 'flat' THEN 1
WHEN si.type = 'per_seat' THEN 2
WHEN si.type = 'metered' THEN 3
ELSE 4
END,
-- Then by max_usage (higher limits first)
COALESCE((pe.entitlement->>'max_usage')::INTEGER, 0) DESC;
END;
$$ LANGUAGE plpgsql;
```
### Update Feature Usage
These functions update the `feature_usage` table. The first function handles merging JSON usage data, and the second one atomically updates quota usage using an UPSERT pattern.
```sql
-- Function to update feature usage
CREATE OR REPLACE FUNCTION public.update_feature_usage(p_account_id UUID, p_feature VARCHAR, p_usage JSONB)
RETURNS VOID
SET search_path = ''
AS $$
BEGIN
PERFORM 1 FROM public.accounts WHERE id = p_account_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Cannot update feature usage for non-existent account';
END IF;
INSERT INTO public.feature_usage (account_id, feature, usage)
VALUES (p_account_id, p_feature, p_usage)
ON CONFLICT (account_id, feature)
DO UPDATE SET usage = public.feature_usage.usage || p_usage, updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- Atomic update for feature quota usage using UPSERT
CREATE OR REPLACE FUNCTION public.update_feature_quota_usage(
p_account_id UUID,
p_feature VARCHAR,
p_count INTEGER
)
RETURNS VOID
SECURITY INVOKER
SET search_path = ''
AS $$
BEGIN
-- Verify the account exists
PERFORM 1 FROM public.accounts WHERE id = p_account_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Cannot update feature usage for non-existent account';
END IF;
INSERT INTO public.feature_usage (account_id, feature, usage, updated_at)
VALUES (
p_account_id,
p_feature,
jsonb_build_object('count', p_count),
NOW()
)
ON CONFLICT (account_id, feature) DO UPDATE
SET usage = jsonb_set(
COALESCE(public.feature_usage.usage, '{}'::jsonb),
'{count}',
to_jsonb(
p_count
)
),
updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
```
Grant execute permissions securely:
```sql
-- Grant execute permissions
GRANT EXECUTE ON FUNCTION public.can_use_feature(UUID, VARCHAR) TO authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.get_entitlement(UUID, VARCHAR) TO authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.update_feature_usage(UUID, VARCHAR, JSONB) TO service_role;
GRANT EXECUTE ON FUNCTION public.update_feature_quota_usage(UUID, VARCHAR, INTEGER) TO service_role;
```
## Step 3: Create the Entitlements Service in TypeScript
Encapsulate your entitlements logic in a TypeScript service. This service communicates with the Supabase backend via RPC and provides a clean API for your application.
```typescript {% title="apps/web/lib/server/entitlements.service.ts" %}
import type { SupabaseClient } from '@supabase/supabase-js';
import { object } from 'zod';
// Example API route or server action for handling an API request
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { Database, Json } from '~/lib/database.types';
export function createEntitlementsService(
client: SupabaseClient<Database>,
accountId: string,
) {
return new EntitlementsService(client, accountId);
}
class EntitlementsService {
constructor(
private readonly client: SupabaseClient<Database>,
private readonly accountId: string,
) {}
async canUseFeature(feature: string) {
const { data, error } = await this.client.rpc('can_use_feature', {
p_account_id: this.accountId,
p_feature: feature,
});
if (error) throw error;
return data;
}
async getEntitlement(feature: string) {
const { data, error } = await this.client
.rpc('get_entitlement', {
p_account_id: this.accountId,
p_feature: feature,
})
.maybeSingle();
if (error) {
throw error;
}
return data;
}
async updateFeatureUsage(feature: string, usage: Json) {
const { error } = await this.client.rpc('update_feature_usage', {
p_account_id: this.accountId,
p_feature: feature,
p_usage: usage,
});
if (error) throw error;
}
async updateFeatureQuotaUsage(feature: string, count: number) {
const { error } = await this.client.rpc('update_feature_quota_usage', {
p_account_id: this.accountId,
p_feature: feature,
p_count: count,
});
if (error) throw error;
}
}
```
### How to Use the Entitlements Service
In your API route or server component, you can use the service to check for entitlements and update usage data. For example:
```tsx
// Example API route or server action for handling an API request
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createEntitlementsService } from '~/lib/server/entitlements.service';
export async function handleApiRequest(accountId: string, endpoint: string) {
const client = getSupabaseServerClient();
const adminClient = getSupabaseServerAdminClient();
const entitlementsService = createEntitlementsService(client, accountId);
const canUseAPI = await entitlementsService.canUseFeature('api_access');
if (!canUseAPI) {
throw new Error('No access to API');
}
const entitlement = await entitlementsService.getEntitlement('api_calls');
// Adjust processing based on entitlement type (flat, quota, etc.)
if (entitlement && entitlement.entitlement.type === 'flat') {
// NB: processApiRequest is a placeholder for your actual API request processing logic
return processApiRequest(endpoint);
} else if (entitlement && entitlement.entitlement.type === 'quota') {
const currentUsage = Number(entitlement.usage?.count ?? 0);
const limit = entitlement.entitlement.limit;
if (currentUsage < limit) {
// create an admin service to update the feature usage
// because normal users cannot update the feature usage
const adminEntitlementsService
= createEntitlementsService(adminClient, accountId);
// Atomically update usage count
await adminEntitlementsService.updateFeatureUsage
('api_calls', { count: currentUsage + 1 });
// NB: processApiRequest is a placeholder for your actual API request processing logic
return processApiRequest(endpoint);
} else {
throw new Error('API call quota exceeded');
}
}
throw new Error('Invalid entitlement state');
}
```
## Step 4: Enforcing Entitlements in Row Level Security
One of the major benefits of this approach is that you can enforce entitlements at the database level using RLS policies. For example, to restrict access to a table based on entitlement checks:
```sql
-- Example RLS policy using the can_use_feature function
CREATE POLICY "users_can_access_feature" ON public.some_table
FOR SELECT
TO authenticated
USING (
public.can_use_feature(auth.uid(), 'some_feature')
);
```
This ensures that only users with the correct entitlements can access sensitive data.
## Step 5: Integrating with Billing Webhooks
When a billing event occurs (such as an invoice being paid), use a webhook to update entitlements accordingly. Below is an example using a Next.js API route with structured logging for observability:
```typescript
'use server';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { createEntitlementsService } from '~/lib/server/entitlements.service';
import { getLogger } from '@kit/shared/logger';
import { billingConfig } from '~/config/billing';
export const POST = enhanceRouteHandler(
async ({ request }) => {
const provider = billingConfig.provider;
const logger = await getLogger();
const ctx = { name: 'billing.webhook', provider };
logger.info(ctx, 'Received billing webhook. Processing...');
try {
// Handle billing event using your billing event handler service...
await handleInvoicePaidEvent(request, ctx);
logger.info(ctx, 'Successfully processed billing webhook');
return new Response('OK', { status: 200 });
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to process billing webhook');
return new Response('Failed to process billing webhook', { status: 500 });
}
},
{ auth: false }
);
async function handleInvoicePaidEvent(request: Request, ctx: Record<string, unknown>) {
// Assume the request contains the account_id for which the invoice was paid
const accountId = 'extracted-account-id'; // Extract account id securely from the request payload
const entitlementsService = createEntitlementsService(getSupabaseServerAdminClient(), accountId);
const entitlement = await entitlementsService.getEntitlement('api_calls');
if (!entitlement) {
ctx['error'] = `No entitlement found for "api_calls"`;
throw new Error(ctx['error']);
}
const count = entitlement?.entitlement?.limit ?? 0;
if (!count) {
ctx['error'] = 'No limit found for "api_calls" entitlement';
throw new Error(ctx['error']);
}
await entitlementsService.updateFeatureUsage('api_calls', { count });
return;
}
```
## PgTap tests
Please add the following tests to your project and modify them as needed:
```sql
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
select tests.create_supabase_user('foreigner', 'foreigner@makerkit.dev');
-- Create test users
select makerkit.set_identifier('primary_owner', 'test@makerkit.dev');
select makerkit.set_identifier('member', 'member@makerkit.dev');
select makerkit.set_identifier('foreigner', 'foreigner@makerkit.dev');
-- Setup test data
set local role postgres;
-- Insert test plan entitlements
insert into public.plan_entitlements (variant_id, feature, entitlement)
values
('basic_plan', 'api_calls', '{"limit": 1000, "period": "month"}'::jsonb),
('pro_plan', 'api_calls', '{"limit": 10000, "period": "month"}'::jsonb),
('basic_plan', 'storage', '{"limit": 5, "unit": "GB"}'::jsonb),
('pro_plan', 'storage', '{"limit": 50, "unit": "GB"}'::jsonb);
-- Create test billing customers and subscriptions
INSERT INTO public.billing_customers(account_id, provider, customer_id)
VALUES (makerkit.get_account_id_by_slug('makerkit'), 'stripe', 'cus_test');
-- Create a subscription with basic plan
SELECT public.upsert_subscription(
makerkit.get_account_id_by_slug('makerkit'),
'cus_test',
'sub_test_basic',
true,
'active',
'stripe',
false,
'usd',
now(),
now() + interval '1 month',
'[{
"id": "sub_basic",
"product_id": "prod_basic",
"variant_id": "basic_plan",
"type": "flat",
"price_amount": 1000,
"quantity": 1,
"interval": "month",
"interval_count": 1
}]'
);
-- Test as primary owner
select tests.authenticate_as('primary_owner');
-- Test reading plan entitlements
select isnt_empty(
$$ select * from plan_entitlements where variant_id = 'basic_plan' $$,
'Primary owner can read plan entitlements'
);
-- Test can_use_feature function
select is(
(select public.can_use_feature(makerkit.get_account_id_by_slug('makerkit'), 'api_calls')),
true,
'Account with basic plan can use api_calls feature'
);
-- Test get_entitlement function
select row_eq(
$$ select entitlement->>'limit' from public.get_entitlement(makerkit.get_account_id_by_slug('makerkit'), 'api_calls') $$,
row('1000'::text),
'Get entitlement returns correct limit for api_calls'
);
set local role service_role;
-- Test feature usage tracking
select lives_ok(
$$ select public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'api_calls', 100) $$,
'Can update feature quota usage'
);
-- Test as primary owner
select tests.authenticate_as('primary_owner');
-- Verify feature usage was recorded
select row_eq(
$$ select usage->>'count' from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') and feature = 'api_calls' $$,
row('100'::text),
'Feature usage is recorded correctly'
);
-- Test as member
select tests.authenticate_as('member');
-- Members can read plan entitlements
select isnt_empty(
$$ select * from plan_entitlements $$,
'Members can read plan entitlements'
);
-- Members can read feature usage for their account
select isnt_empty(
$$ select * from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') $$,
'Members can read feature usage for their account'
);
-- Test as foreigner
select tests.authenticate_as('foreigner');
-- Foreigners can read plan entitlements (public info)
select isnt_empty(
$$ select * from plan_entitlements $$,
'Foreigners can read plan entitlements'
);
-- Foreigners cannot read feature usage for other accounts
select is_empty(
$$ select * from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') $$,
'Foreigners cannot read feature usage for other accounts'
);
-- Test updating to pro plan
set local role postgres;
SELECT public.upsert_subscription(
makerkit.get_account_id_by_slug('makerkit'),
'cus_test',
'sub_test_basic',
true,
'active',
'stripe',
false,
'usd',
now(),
now() + interval '1 month',
'[{
"id": "sub_pro",
"product_id": "prod_pro",
"variant_id": "pro_plan",
"type": "flat",
"price_amount": 2000,
"quantity": 1,
"interval": "month",
"interval_count": 1
}]'
);
select tests.authenticate_as('primary_owner');
-- Verify pro plan entitlements
select row_eq(
$$ select entitlement->>'limit' from public.get_entitlement(makerkit.get_account_id_by_slug('makerkit'), 'api_calls') $$,
row('10000'::text),
'Get entitlement returns updated limit for api_calls after plan upgrade'
);
-- Test edge cases
-- Test non-existent feature
select is(
(select public.can_use_feature(makerkit.get_account_id_by_slug('makerkit'), 'non_existent_feature')),
false,
'Cannot use non-existent feature'
);
-- Test non-existent account
select is(
(select public.can_use_feature('12345678-1234-1234-1234-123456789012'::uuid, 'api_calls')),
false,
'Cannot use feature for non-existent account'
);
-- Test updating feature usage with invalid data
set local role postgres;
select throws_ok(
$$ select public.update_feature_usage('12345678-1234-1234-1234-123456789012'::uuid, 'api_calls', '{"invalid": true}'::jsonb) $$,
'Cannot update feature usage for non-existent account'
);
-- Additional tests for subscription entitlements
--------------------------------------------------------------------
-- Additional tests for update_feature_quota_usage (storage feature)
--------------------------------------------------------------------
set local role postgres;
-- Create or update a subscription for storage feature if not already set
-- We'll use the basic plan for storage
SELECT public.upsert_subscription(
makerkit.get_account_id_by_slug('makerkit'),
'cus_test',
'sub_test_storage',
true,
'active',
'stripe',
false,
'usd',
now(),
now() + interval '1 month',
'[{
"id": "sub_storage",
"product_id": "prod_storage",
"variant_id": "basic_plan",
"type": "flat",
"price_amount": 500,
"quantity": 1,
"interval": "month",
"interval_count": 1
}]'
);
-- Reset storage usage by updating its quota
select lives_ok(
$$ select public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'storage', 5) $$,
'Initial storage quota update sets usage to 5'
);
select row_eq(
$$ select usage->>'count' from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') and feature = 'storage' $$,
row('5'::text),
'Storage usage should be 5 after initial update'
);
-- Update storage usage by adding 3 more units
select lives_ok(
$$ select public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'storage', 3) $$,
'Additional storage quota update adds 3 units'
);
select row_eq(
$$ select usage->>'count' from feature_usage where account_id = makerkit.get_account_id_by_slug('makerkit') and feature = 'storage' $$,
row('8'::text),
'Accumulated storage usage should be 8'
);
set local role service_role;
-- Update api_calls usage by adding an extra field
select lives_ok(
$$ select public.update_feature_usage(makerkit.get_account_id_by_slug('makerkit'), 'api_calls', '{"extra": 100}'::jsonb) $$,
'Feature usage update concatenates new JSON data for api_calls'
);
-- Verify that the api_calls usage JSON now contains the extra field by checking the "extra" key value directly
select is(
(select usage::json->>'extra' from feature_usage
where account_id = makerkit.get_account_id_by_slug('makerkit')
and feature = 'api_calls'),
'100',
'Feature usage for api_calls contains extra field after update'
);
--------------------------------------------------------------------
-- Additional test for non-existent subscription item entitlement
--------------------------------------------------------------------
select is_empty(
$$ select * from public.get_entitlement(makerkit.get_account_id_by_slug('makerkit'), 'nonexistent_feature') $$,
'Get entitlement returns empty for a non-existent feature'
);
--------------------------------------------------------------------
-- Additional test for atomicity of updating feature usage
--------------------------------------------------------------------
set local role postgres;
CREATE OR REPLACE FUNCTION test_atomicity_feature_usage() RETURNS text AS $$
DECLARE
baseline text;
current_usage text;
BEGIN
-- Capture the baseline storage usage for the 'storage' feature
SELECT usage->>'count' INTO baseline
FROM feature_usage
WHERE account_id = makerkit.get_account_id_by_slug('makerkit')
AND feature = 'storage';
BEGIN
-- Perform a valid update: add 10 units to storage usage
PERFORM public.update_feature_quota_usage(makerkit.get_account_id_by_slug('makerkit'), 'storage', 10);
-- Force an error by updating usage for a non-existent account
PERFORM public.update_feature_usage('00000000-0000-0000-0000-000000000000'::uuid, 'storage', '{"bad":1}'::jsonb);
-- If no error is raised, return an error message (this should not happen)
RETURN 'error not raised';
EXCEPTION WHEN OTHERS THEN
-- Exception caught; the subtransaction should be rolled back
NULL;
END;
-- Capture the current usage after the forced error
SELECT usage->>'count' INTO current_usage
FROM feature_usage
WHERE account_id = makerkit.get_account_id_by_slug('makerkit') AND feature = 'storage';
IF current_usage = baseline THEN
RETURN 'ok';
ELSE
RETURN 'failed';
END IF;
END;
$$ LANGUAGE plpgsql;
select is((select test_atomicity_feature_usage()), 'ok', 'Atomicity of updating feature usage is preserved');
-- End of additional atomicity tests
select * from finish();
rollback;
```
## Benefits of This Approach
1. **Flexibility:**
Handle different entitlement types—from simple feature flags to complex usage quotas—without locking into a rigid model.
2. **Performance:**
Offload entitlement checks to PostgreSQL. This minimizes round-trips between your app and the database.
3. **Consistency & Security:**
The same functions are used in both your application code and in RLS policies, ensuring a uniform level of security.
4. **Maintainability:**
Encapsulating logic in PostgreSQL functions and a dedicated TypeScript service simplifies updates and helps prevent bugs.
## Conclusion
By moving entitlement logic into PostgreSQL functions and encapsulating access in a dedicated TypeScript service, you create a robust and secure system that scales with your application. This approach not only meets the complex needs of SaaS applications but also adheres to best practices for performance, security, and maintainability.
Feel free to evolve these patterns further to suit your specific billing scenarios and business logic needs. Happy coding!

View File

@@ -0,0 +1,355 @@
---
label: "Team Account Creation Policies"
title: "Guarding Team Account Creation with Policies"
description: "Learn how to restrict and validate team account creation using the policy system."
order: 8
---
The Team Account Creation Policies system allows you to define custom business rules that guard when users can create new team accounts using the [Policies API](../api/policies-api).
Common use cases include:
- Requiring an active subscription to create team accounts
- Requiring a specific subscription plan (e.g., Pro or Enterprise)
- Limiting the number of team accounts per user
{% sequence title="Implementation Steps" description="How to implement team account creation policies" %}
[Understanding Policies](#understanding-policies)
[Registering Policies](#registering-policies)
[Common Policy Examples](#common-policy-examples)
[Evaluating Policies](#evaluating-policies)
{% /sequence %}
## Understanding Policies
Policies are defined using the `definePolicy` function and registered in the `createAccountPolicyRegistry`. Each policy:
1. Has a unique ID
2. Specifies which stages it runs at (`preliminary` or `submission`)
3. Returns `allow()` or `deny()` with an error message
```typescript
import { allow, definePolicy, deny } from '@kit/policies';
import type { FeaturePolicyCreateAccountContext } from '@kit/team-accounts/server';
const myPolicy = definePolicy<FeaturePolicyCreateAccountContext>({
id: 'my-policy-id',
stages: ['preliminary', 'submission'],
async evaluate(context) {
// Return allow() to permit the action
// Return deny({ code, message, remediation }) to block it
},
});
```
### Policy Stages
- **preliminary**: Runs before showing the create account form. Use to check if the user can attempt to create an account.
- **submission**: Runs when the form is submitted. Use to validate the account name and final checks.
## Registering Policies
Create a setup file and import it in your layout to register policies at app startup.
### Step 1: Create the Registration File
```typescript
// apps/web/lib/policies/setup-create-account-policies.ts
import 'server-only';
import { createAccountPolicyRegistry } from '@kit/team-accounts/server';
import { subscriptionRequiredPolicy } from './create-account-policies';
createAccountPolicyRegistry.registerPolicy(subscriptionRequiredPolicy);
```
### Step 2: Import in Layout
```typescript
// apps/web/app/home/layout.tsx
import '~/lib/policies/setup-create-account-policies';
export default function HomeLayout({ children }) {
return <>{children}</>;
}
```
{% callout type="default" title="Default Behavior" %}
By default, no policies are registered and all users can create team accounts. You must register policies to enforce restrictions.
{% /callout %}
## Common Policy Examples
### Require Active Subscription
Block team account creation unless the user has an active subscription on their personal account:
```typescript
// apps/web/lib/policies/create-account-policies.ts
import 'server-only';
import { allow, definePolicy, deny } from '@kit/policies';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { FeaturePolicyCreateAccountContext } from '@kit/team-accounts/server';
export const subscriptionRequiredPolicy =
definePolicy<FeaturePolicyCreateAccountContext>({
id: 'subscription-required',
stages: ['preliminary', 'submission'],
async evaluate(context) {
const client = getSupabaseServerClient();
const { data: subscription, error } = await client
.from('subscriptions')
.select('id, status, active')
.eq('account_id', context.userId)
.eq('active', true)
.maybeSingle();
if (error) {
return deny({
code: 'SUBSCRIPTION_CHECK_FAILED',
message: 'Failed to verify subscription status',
});
}
if (!subscription) {
return deny({
code: 'SUBSCRIPTION_REQUIRED',
message: 'An active subscription is required to create team accounts',
remediation: 'Please upgrade your plan to create team accounts',
});
}
return allow();
},
});
```
### Require Specific Plan (Price ID)
Only allow users with a specific subscription plan to create team accounts:
```typescript
export const proPlanRequiredPolicy = definePolicy<
FeaturePolicyCreateAccountContext,
{ allowedPriceIds: string[] }
>({
id: 'pro-plan-required',
stages: ['preliminary', 'submission'],
async evaluate(context, config) {
const allowedPriceIds = config?.allowedPriceIds ?? [
'price_pro_monthly',
'price_pro_yearly',
'price_enterprise_monthly',
'price_enterprise_yearly',
];
const client = getSupabaseServerClient();
const { data: subscription, error } = await client
.from('subscriptions')
.select('id, active, subscription_items(price_id)')
.eq('account_id', context.userId)
.eq('active', true)
.maybeSingle();
if (error) {
return deny({
code: 'SUBSCRIPTION_CHECK_FAILED',
message: 'Failed to verify subscription status',
});
}
if (!subscription) {
return deny({
code: 'SUBSCRIPTION_REQUIRED',
message: 'A subscription is required to create team accounts',
remediation: 'Please subscribe to a plan to create team accounts',
});
}
const priceIds =
subscription.subscription_items?.map((item) => item.price_id) ?? [];
const hasAllowedPlan = priceIds.some((priceId) =>
allowedPriceIds.includes(priceId ?? '')
);
if (!hasAllowedPlan) {
return deny({
code: 'PLAN_NOT_ALLOWED',
message: 'Your current plan does not include team account creation',
remediation: 'Please upgrade to a Pro or Enterprise plan',
});
}
return allow();
},
});
```
### Maximum Accounts Per User
Limit how many team accounts a user can own:
```typescript
export const maxAccountsPolicy = definePolicy<
FeaturePolicyCreateAccountContext,
{ maxAccounts: number }
>({
id: 'max-accounts-per-user',
stages: ['preliminary', 'submission'],
async evaluate(context, config) {
const maxAccounts = config?.maxAccounts ?? 3;
const client = getSupabaseServerClient();
const { count, error } = await client
.from('accounts')
.select('*', { count: 'exact', head: true })
.eq('primary_owner_user_id', context.userId)
.eq('is_personal_account', false);
if (error) {
return deny({
code: 'MAX_ACCOUNTS_CHECK_FAILED',
message: 'Failed to check account count',
});
}
const currentCount = count ?? 0;
if (currentCount >= maxAccounts) {
return deny({
code: 'MAX_ACCOUNTS_REACHED',
message: `You have reached the maximum of ${maxAccounts} team accounts`,
remediation: 'Delete an existing team account to create a new one',
});
}
return allow();
},
});
```
### Rate Limiting Account Creation
Prevent users from creating too many accounts in a short period:
```typescript
export const rateLimitPolicy = definePolicy<
FeaturePolicyCreateAccountContext,
{ maxAccountsPerDay: number }
>({
id: 'account-creation-rate-limit',
stages: ['submission'],
async evaluate(context, config) {
const maxAccountsPerDay = config?.maxAccountsPerDay ?? 5;
const client = getSupabaseServerClient();
const oneDayAgo = new Date();
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
const { count, error } = await client
.from('accounts')
.select('*', { count: 'exact', head: true })
.eq('primary_owner_user_id', context.userId)
.eq('is_personal_account', false)
.gte('created_at', oneDayAgo.toISOString());
if (error) {
return deny({
code: 'RATE_LIMIT_CHECK_FAILED',
message: 'Failed to check rate limit',
});
}
if ((count ?? 0) >= maxAccountsPerDay) {
return deny({
code: 'RATE_LIMIT_EXCEEDED',
message: `You can only create ${maxAccountsPerDay} accounts per day`,
remediation: 'Please wait 24 hours before creating another account',
});
}
return allow();
},
});
```
### Combining Multiple Policies
Register multiple policies to enforce several rules:
```typescript
// apps/web/lib/policies/setup-create-account-policies.ts
import 'server-only';
import { createAccountPolicyRegistry } from '@kit/team-accounts/server';
import {
maxAccountsPolicy,
proPlanRequiredPolicy,
rateLimitPolicy,
} from './create-account-policies';
createAccountPolicyRegistry
.registerPolicy(proPlanRequiredPolicy)
.registerPolicy(maxAccountsPolicy)
.registerPolicy(rateLimitPolicy);
```
## Evaluating Policies
Use `createAccountCreationPolicyEvaluator` to check policies in your server actions:
```typescript
import { createAccountCreationPolicyEvaluator } from '@kit/team-accounts/server';
async function checkCanCreateAccount(userId: string) {
const evaluator = createAccountCreationPolicyEvaluator();
const result = await evaluator.canCreateAccount(
{
userId,
accountName: '',
timestamp: new Date().toISOString(),
},
'preliminary'
);
return {
allowed: result.allowed,
reason: result.reasons[0] ?? null,
};
}
```
### Checking if Policies Exist
Before running evaluations, you can check if any policies are registered:
```typescript
const evaluator = createAccountCreationPolicyEvaluator();
const hasPolicies = await evaluator.hasPoliciesForStage('preliminary');
if (hasPolicies) {
const result = await evaluator.canCreateAccount(context, 'preliminary');
// Handle result...
}
```

View File

@@ -0,0 +1,339 @@
---
status: "published"
title: 'Disabling Personal Accounts in Next.js Supabase'
label: 'Disabling Personal Accounts'
order: 1
description: 'Learn how to disable personal accounts in the Next.js Supabase Turbo SaaS kit and only allow team accounts'
---
{% alert type="warning" title="v2 Recipe" %}
This recipe applies to **v2 only**. In v3, teams-only mode is built-in as a feature flag. Simply set `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true` in your environment variables. See the [Feature Flags Configuration](/docs/next-supabase-turbo/configuration/feature-flags-configuration) for details.
{% /alert %}
The Next.js Supabase Turbo SaaS kit is designed to allow personal accounts by default. However, you can disable the personal account view, and only allow user to access team accounts.
Let's walk through the v2 steps to disable personal accounts in the Next.js Supabase Turbo SaaS kit:
1. **Store team slug in cookies**: When a user logs in, store the team slug in a cookie. If none is provided, redirect the user to the team selection page.
2. **Set up Redirect**: Redirect customers to the latest selected team account
3. **Create a Team Selection Page**: Create a page where users can select the team they want to log in to.
4. **Duplicate the user settings page**: Duplicate the user settings page so they can access their own settings from within the team workspace
## Storing the User Cookie and Redirecting to the Team Selection Page
To make sure that users are always redirected to the team selection page, you need to store the team slug in a cookie. If the team slug is not found, redirect the user to the team selection page. We will do all of this in the middleware.
First, let's create these functions in the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16):
```tsx {% title="apps/web/proxy.ts" %}
const createTeamCookie = (userId: string) => `${userId}-selected-team-slug`;
function handleTeamAccountsOnly(request: NextRequest, userId: string) {
// always allow access to the teams page
if (request.nextUrl.pathname === '/home/teams') {
return NextResponse.next();
}
if (request.nextUrl.pathname === '/home') {
return redirectToTeam(request, userId);
}
if (isTeamAccountRoute(request) && !isUserRoute(request)) {
return storeTeamSlug(request, userId);
}
if (isUserRoute(request)) {
return redirectToTeam(request, userId);
}
return NextResponse.next();
}
function isUserRoute(request: NextRequest) {
const pathName = request.nextUrl.pathname;
return ['settings', 'billing', 'members'].includes(pathName.split('/')[2]!);
}
function isTeamAccountRoute(request: NextRequest) {
const pathName = request.nextUrl.pathname;
return pathName.startsWith('/home/');
}
function storeTeamSlug(request: NextRequest, userId: string): NextResponse {
const accountSlug = request.nextUrl.pathname.split('/')[2];
if (!accountSlug) {
return NextResponse.next();
}
const cookieName = createTeamCookie(userId);
const existing = request.cookies.get(cookieName);
if (existing?.value === accountSlug) {
return NextResponse.next();
}
const response = NextResponse.next();
response.cookies.set({
name: createTeamCookie(userId),
value: accountSlug,
path: '/',
});
return response;
}
function redirectToTeam(request: NextRequest, userId: string): NextResponse {
const cookieName = createTeamCookie(userId);
const lastTeamSlug = request.cookies.get(cookieName);
if (lastTeamSlug) {
return NextResponse.redirect(
new URL(`/home/${lastTeamSlug.value}`, request.url),
);
}
return NextResponse.redirect(new URL('/home/teams', request.url));
}
```
We will now add the `handleTeamAccountsOnly` function to the middleware chain in the `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16). This function will check if the user is on a team account route and store the team slug in a cookie. If the user is on the home route, it will redirect them to the team selection page.
```tsx {% title="apps/web/proxy.ts" %}
{
pattern: new URLPattern({ pathname: '/home/*?' }),
handler: async (req: NextRequest, res: NextResponse) => {
const { data } = await getUser(req, res);
const origin = req.nextUrl.origin;
const next = req.nextUrl.pathname;
// If user is not logged in, redirect to sign in page.
if (!data?.claims) {
const signIn = pathsConfig.auth.signIn;
const redirectPath = `${signIn}?next=${next}`;
return NextResponse.redirect(new URL(redirectPath, origin).href);
}
const supabase = createMiddlewareClient(req, res);
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(supabase);
// If user requires multi-factor authentication, redirect to MFA page.
if (requiresMultiFactorAuthentication) {
return NextResponse.redirect(
new URL(pathsConfig.auth.verifyMfa, origin).href,
);
}
const userId = data.claims.sub;
return handleTeamAccountsOnly(req, userId);
},
}
```
In the above code snippet, we have added the `handleTeamAccountsOnly` function to the middleware chain.
## Creating the Team Selection Page
Next, we need to create a team selection page where users can select the team they want to log in to. We will create a new page at `apps/web/app/home/teams/page.tsx`:
```tsx {% title="apps/web/app/home/teams/page.tsx" %}
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { getTranslations } from 'next-intl/server';
import { HomeAccountsList } from '~/home/(user)/_components/home-accounts-list';
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('homePage');
return {
title,
};
};
function TeamsPage() {
return (
<div className={'container flex flex-col flex-1 h-screen'}>
<PageHeader
title={<Trans i18nKey={'common.routes.home'} />}
description={<Trans i18nKey={'common.homeTabDescription'} />}
/>
<PageBody>
<HomeAccountsList />
</PageBody>
</div>
);
}
export default TeamsPage;
```
The page is extremely minimal, it just displays a list of teams that the user can select from. You can customize this page to fit your application's design.
## Duplicating the User Settings Page
Finally, we need to duplicate the user settings page so that users can access their settings from within the team workspace.
We will create a new page called `user-settings.tsx` in the `apps/web/app/home/[account]` directory.
```tsx {% title="apps/web/app/home/[account]/user-settings/page.tsx" %}
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { PageHeader } from '@kit/ui/page';
import UserSettingsPage, { generateMetadata } from '../../(user)/settings/page';
export { generateMetadata };
export default function Page() {
return (
<>
<PageHeader title={'User Settings'} description={<AppBreadcrumbs />} />
<UserSettingsPage />
</>
);
}
```
Feel free to customize the path or the content of the user settings page.
### Adding the page to the Navigation Menu
Finally, you can add the `Teams` page to the navigation menu.
You can do this by updating the `apps/web/config/team-account-navigation.config.tsx` file:
```tsx {% title="apps/web/config/team-account-navigation.config.tsx" %} {26-30}
import { CreditCard, LayoutDashboard, Settings, User, Users } from 'lucide-react';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const iconClasses = 'w-4';
const getRoutes = (account: string) => [
{
label: 'common.routes.application',
collapsible: false,
children: [
{
label: 'common.routes.dashboard',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />,
end: true,
}
],
},
{
label: 'common.routes.settings',
collapsible: false,
children: [
{
label: 'common.routes.settings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />,
},
{
label: 'common.routes.account',
path: createPath('/home/[account]/user-settings', account),
Icon: <User className={iconClasses} />,
},
{
label: 'common.routes.members',
path: createPath(pathsConfig.app.accountMembers, account),
Icon: <Users className={iconClasses} />,
},
featureFlagsConfig.enableTeamAccountBilling
? {
label: 'common.routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
].filter(Boolean),
},
];
export function getTeamAccountSidebarConfig(account: string) {
return NavigationConfigSchema.parse({
routes: getRoutes(account),
style: process.env.NEXT_PUBLIC_TEAM_NAVIGATION_STYLE,
});
}
function createPath(path: string, account: string) {
return path.replace('[account]', account);
}
```
In the above code snippet, we have added the `User Settings` page to the navigation menu.
## Removing Personal Account menu item
To remove the personal account menu item, you can remove the personal account menu item:
```tsx {% title="packages/features/accounts/src/components/account-selector.tsx" %}
<CommandGroup>
<CommandItem
onSelect={() => onAccountChange(undefined)}
value={PERSONAL_ACCOUNT_SLUG}
>
<PersonalAccountAvatar />
<span className={'ml-2'}>
<Trans i18nKey={'teams.personalAccount'} />
</span>
<Icon item={PERSONAL_ACCOUNT_SLUG} />
</CommandItem>
</CommandGroup>
<CommandSeparator />
```
Once you remove the personal account menu item, users will only see the team accounts in the navigation menu.
## Change Redirect in Layout
We now need to change the redirect (in case of errors) from `/home` to `/home/teams`. This is to avoid infinite redirects in case of errors.
```tsx {% title="apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts" %} {13}
async function workspaceLoader(accountSlug: string) {
const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client);
const [workspace, user] = await Promise.all([
api.getAccountWorkspace(accountSlug),
requireUserInServerComponent(),
]);
// we cannot find any record for the selected account
// so we redirect the user to the home page
if (!workspace.data?.account) {
return redirect('/home/teams');
}
return {
...workspace.data,
user,
};
}
```
## Conclusion
By following these steps, you can disable personal accounts in the Next.js Supabase Turbo SaaS kit and only allow team accounts.
This can help you create a more focused and collaborative environment for your users. Feel free to customize the team selection page and user settings page to fit your application's design and requirements.