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,69 @@
---
status: "published"
label: "Adding a Super Admin"
title: "Adding a Super Admin to your Next.js Supabase application"
description: "In this post, you will learn how to set up a Super Admin in your Next.js Supabase application"
order: 0
---
The Super Admin panel allows you to manage users and accounts.
{% sequence title="Steps to add a Super Admin" description="Learn how to add a Super Admin to your Next.js Supabase application." %}
[How to access the Super Admin panel](#how-to-access-the-super-admin-panel)
[Testing the Super Admin locally](#testing-the-super-admin-locally)
[Assigning a Super Admin role to a user](#assigning-a-super-admin-role-to-a-user)
{% /sequence %}
## How to access the Super Admin panel
To access the super admin panel at `/admin`, you will need to assign a user as a super admin. In addition, you will need to enable MFA for the user from the normal user profile settings.
## Testing the Super Admin locally
By default, we seed the `auth.users` table with a super admin user. To login as this user, you can use the following credentials:
```json
{
"email": "super-admin@makerkit.dev",
"password": "testingpassword"
}
```
Since you require MFA for the Super Admin user, please use the following steps to pass MFA:
1. **TOTP**: Use the following [TOTP generator](https://totp.danhersam.com/) to generate a TOTP code.
2. **Secret Key**: Use the test secret key `NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE` to generate a TOTP code.
3. **Verify**: Use the TOTP code and the secret key to verify the MFA code.
Make sure the TOTP code is not expired when you verify the MFA code.
{% alert type="warning" title="These are test credentials" %}
The flow above is for testing purposes only. For production, you must use an authenticator app such as Google Authenticator, Authy, Bitwarden, Proton, or similar.
{% /alert %}
## Assigning a Super Admin role to a user
To add your own super admin user, you will need to:
1. **Role**: Add the `super-admin` role to the user in your database
2. **Enable MFA**: Enable MFA for the user (mandatory) from the profile
settings of the user
### Modify the `auth.users` record in your database
To assign a user as a super admin, run the following SQL query from your Supabase SQL Query editor:
```sql
UPDATE auth.users SET raw_app_meta_data = raw_app_meta_data || '{"role": "super-admin"}' WHERE id='<user_id>';
```
Please replace `<user_id>` with the user ID you want to assign as a super admin.
### Enable MFA for the Super Admin user
Starting from version `2.5.0`, the Super Admin user will be required to use Multi-Factor Authentication (MFA) to access the Super Admin panel.
Please navigate to the `/home/settings` page and enable MFA for the Super Admin user.

View File

@@ -0,0 +1,363 @@
---
status: "published"
title: 'Understanding Analytics and App Events in MakerKit'
label: 'Analytics and Events'
description: 'Learn how the Analytics and App Events systems work together to provide centralized, maintainable event tracking in your MakerKit SaaS application.'
order: 0
---
MakerKit separates event emission from analytics tracking through two interconnected systems: **App Events** for broadcasting important occurrences in your app, and **Analytics** for tracking user behavior. This separation keeps your components clean and your analytics logic centralized.
## Why Centralized Analytics
Scattering `analytics.trackEvent()` calls throughout your codebase creates maintenance problems. When you need to change event names, add properties, or switch providers, you hunt through dozens of files.
The centralized approach solves this:
```typescript
// Instead of this (scattered analytics)
function CheckoutButton() {
const handleClick = () => {
analytics.trackEvent('checkout_started', { plan: 'pro' });
analytics.identify(userId);
mixpanel.track('Checkout Started');
// More provider-specific code...
};
}
// Do this (centralized via App Events)
function CheckoutButton() {
const { emit } = useAppEvents();
const handleClick = () => {
emit({ type: 'checkout.started', payload: { planId: 'pro' } });
};
}
```
The analytics mapping lives in one place: `apps/web/components/analytics-provider.tsx`.
## How It Works
The system has three parts:
1. **App Events Provider**: A React Context that provides `emit`, `on`, and `off` functions
2. **Analytics Provider**: Subscribes to App Events and maps them to analytics calls
3. **Analytics Manager**: Dispatches events to all registered analytics providers
```
Component Analytics Provider Providers
│ │ │
│ emit('checkout.started') │ │
│────────────────────────────▶│ │
│ │ analytics.trackEvent │
│ │────────────────────────▶│
│ │ analytics.identify │
│ │────────────────────────▶│
```
## Emitting Events
Use the `useAppEvents` hook to emit events from any component:
```typescript
import { useAppEvents } from '@kit/shared/events';
function FeatureButton() {
const { emit } = useAppEvents();
const handleClick = () => {
emit({
type: 'feature.used',
payload: { featureName: 'export' }
});
};
return <button onClick={handleClick}>Export</button>;
}
```
The event is broadcast to all listeners, including the analytics provider.
## Default Event Types
MakerKit defines these base event types in `@kit/shared/events`:
```typescript
interface BaseAppEventTypes {
'user.signedIn': { userId: string };
'user.signedUp': { method: 'magiclink' | 'password' };
'user.updated': Record<string, never>;
'checkout.started': { planId: string; account?: string };
}
```
These events are emitted automatically by MakerKit components and mapped to analytics calls.
### Event Descriptions
| Event | When Emitted | Analytics Action |
|-------|--------------|------------------|
| `user.signedIn` | After successful login | `identify(userId)` |
| `user.signedUp` | After registration | `trackEvent('user.signedUp')` |
| `user.updated` | After profile update | `trackEvent('user.updated')` |
| `checkout.started` | When billing checkout begins | `trackEvent('checkout.started')` |
**Note**: The `user.signedUp` event does not fire automatically for social/OAuth signups. You may need to emit it manually in your OAuth callback handler.
## Creating Custom Events
Define custom events by extending `ConsumerProvidedEventTypes`:
```typescript {% title="lib/events/custom-events.ts" %}
import { ConsumerProvidedEventTypes } from '@kit/shared/events';
export interface MyAppEvents extends ConsumerProvidedEventTypes {
'feature.used': { featureName: string; duration?: number };
'project.created': { projectId: string; template: string };
'export.completed': { format: 'csv' | 'json' | 'pdf'; rowCount: number };
}
```
Use the typed hook in your components:
```typescript
import { useAppEvents } from '@kit/shared/events';
import type { MyAppEvents } from '~/lib/events/custom-events';
function ProjectForm() {
const { emit } = useAppEvents<MyAppEvents>();
const handleCreate = (project: Project) => {
emit({
type: 'project.created',
payload: {
projectId: project.id,
template: project.template,
},
});
};
}
```
TypeScript enforces the correct payload shape for each event type.
## Mapping Events to Analytics
The `AnalyticsProvider` component maps events to analytics calls. Add your custom events here:
```typescript {% title="apps/web/components/analytics-provider.tsx" %}
const analyticsMapping: AnalyticsMapping = {
'user.signedIn': (event) => {
const { userId, ...traits } = event.payload;
if (userId) {
return analytics.identify(userId, traits);
}
},
'user.signedUp': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
'checkout.started': (event) => {
return analytics.trackEvent(event.type, event.payload);
},
// Add custom event mappings
'feature.used': (event) => {
return analytics.trackEvent('Feature Used', {
feature_name: event.payload.featureName,
duration: String(event.payload.duration ?? 0),
});
},
'project.created': (event) => {
return analytics.trackEvent('Project Created', {
project_id: event.payload.projectId,
template: event.payload.template,
});
},
};
```
This is the only place you need to modify when changing analytics behavior.
## Listening to Events
Beyond analytics, you can subscribe to events for other purposes:
```typescript
import { useAppEvents } from '@kit/shared/events';
import { useEffect } from 'react';
function NotificationListener() {
const { on, off } = useAppEvents();
useEffect(() => {
const handler = (event) => {
showToast(`Project ${event.payload.projectId} created!`);
};
on('project.created', handler);
return () => off('project.created', handler);
}, [on, off]);
return null;
}
```
This pattern is useful for triggering side effects like notifications, confetti animations, or feature tours.
## Direct Analytics API
While centralized events are recommended, you can use the analytics API directly when needed:
```typescript
import { analytics } from '@kit/analytics';
// Identify a user
void analytics.identify('user_123', {
email: 'user@example.com',
plan: 'pro',
});
// Track an event
void analytics.trackEvent('Button Clicked', {
button: 'submit',
page: 'settings',
});
// Track a page view (usually automatic)
void analytics.trackPageView('/dashboard');
```
Use direct calls for one-off tracking that does not warrant an event type.
## Automatic Page View Tracking
The `AnalyticsProvider` automatically tracks page views when the Next.js route changes:
```typescript
// This happens automatically in AnalyticsProvider
function useReportPageView(reportFn: (url: string) => unknown) {
const pathname = usePathname();
useEffect(() => {
const url = pathname;
reportFn(url);
}, [pathname]);
}
```
You do not need to manually track page views unless you have a specific use case.
## Common Patterns
### Track Form Submissions
```typescript
function ContactForm() {
const { emit } = useAppEvents<MyAppEvents>();
const handleSubmit = async (data: FormData) => {
await submitForm(data);
emit({
type: 'form.submitted',
payload: { formName: 'contact', fields: Object.keys(data).length },
});
};
}
```
### Track Feature Engagement
```typescript
function AIAssistant() {
const { emit } = useAppEvents<MyAppEvents>();
const startTime = useRef<number>();
const handleOpen = () => {
startTime.current = Date.now();
};
const handleClose = () => {
const duration = Date.now() - (startTime.current ?? Date.now());
emit({
type: 'feature.used',
payload: { featureName: 'ai-assistant', duration },
});
};
}
```
### Track Errors
```typescript
function ErrorBoundary({ children }) {
const { emit } = useAppEvents<MyAppEvents>();
const handleError = (error: Error) => {
emit({
type: 'error.occurred',
payload: {
message: error.message,
stack: error.stack?.slice(0, 500),
},
});
};
}
```
## Debugging Events
During development, add logging to your event handlers to verify events are emitting correctly:
```typescript {% title="apps/web/components/analytics-provider.tsx" %}
const analyticsMapping: AnalyticsMapping = {
'user.signedIn': (event) => {
if (process.env.NODE_ENV === 'development') {
console.log('[Analytics Event]', event.type, event.payload);
}
const { userId, ...traits } = event.payload;
if (userId) {
return analytics.identify(userId, traits);
}
},
'checkout.started': (event) => {
if (process.env.NODE_ENV === 'development') {
console.log('[Analytics Event]', event.type, event.payload);
}
return analytics.trackEvent(event.type, event.payload);
},
// Add logging to other handlers as needed
};
```
You can also use your analytics provider's debug mode (PostHog and GA4 both offer live event views in their dashboards).
## Best Practices
1. **Use App Events for domain events**: Business-relevant events (signup, purchase, feature use) should go through App Events
2. **Keep payloads minimal**: Only include data you will actually analyze
3. **Use consistent naming**: Follow a pattern like `noun.verb` (user.signedUp, project.created)
4. **Type your events**: Define interfaces for compile-time safety
5. **Test event emission**: Verify critical events emit during integration tests
6. **Document your events**: Maintain a list of events and their purposes
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Can I use analytics without App Events?", "answer": "Yes. Import analytics from @kit/analytics and call trackEvent directly. However, the centralized approach through App Events is easier to maintain as your application grows."},
{"question": "How do I track events on the server side?", "answer": "Import analytics from @kit/analytics/server. Note that only PostHog supports server-side analytics out of the box. The App Events system is client-side only."},
{"question": "Are page views tracked automatically?", "answer": "Yes. The AnalyticsProvider component tracks page views whenever the Next.js route changes. You only need manual tracking for virtual page views in SPAs."},
{"question": "How do I debug which events are firing?", "answer": "Add a wildcard handler in your analytics mapping that logs events in development mode. You can also use browser DevTools or your analytics provider's debug mode."},
{"question": "Can I emit events from Server Components?", "answer": "No. App Events use React Context which requires a client component. Emit events from client components or use the server-side analytics API directly."},
{"question": "What happens if no analytics provider is configured?", "answer": "Events dispatch to the NullAnalyticsService which silently ignores them. Your application continues to work without errors."}
]
/%}
## Next Steps
- [Set up Google Analytics](google-analytics-provider) for marketing analytics
- [Set up PostHog](posthog-analytics-provider) for product analytics with feature flags
- [Create a custom provider](custom-analytics-provider) to integrate other services

View File

@@ -0,0 +1,360 @@
---
status: "published"
title: 'Creating a Custom Analytics Provider in MakerKit'
label: 'Custom Analytics Provider'
description: 'Build a custom analytics provider to integrate Mixpanel, Amplitude, Segment, or any analytics service with MakerKit unified analytics API.'
order: 5
---
MakerKit's analytics system is provider-agnostic. If your preferred analytics service is not included (Google Analytics, PostHog, Umami), you can create a custom provider that integrates with the unified analytics API. Events dispatched through `analytics.trackEvent()` or App Events will automatically route to your custom provider alongside any other registered providers.
## The AnalyticsService Interface
Every analytics provider must implement the `AnalyticsService` interface:
```typescript
interface AnalyticsService {
initialize(): Promise<unknown>;
identify(userId: string, traits?: Record<string, string>): Promise<unknown>;
trackPageView(path: string): Promise<unknown>;
trackEvent(
eventName: string,
eventProperties?: Record<string, string | string[]>
): Promise<unknown>;
}
```
| Method | Purpose |
|--------|---------|
| `initialize()` | Load scripts, set up the SDK |
| `identify()` | Associate a user ID with subsequent events |
| `trackPageView()` | Record a page view |
| `trackEvent()` | Record a custom event with properties |
All methods return Promises. Use `void` when calling from non-async contexts.
## Example: Mixpanel Provider
Here is a complete implementation for Mixpanel:
```typescript {% title="packages/analytics/src/mixpanel-service.ts" %}
import { NullAnalyticsService } from './null-analytics-service';
import type { AnalyticsService } from './types';
class MixpanelService implements AnalyticsService {
private mixpanel: typeof import('mixpanel-browser') | null = null;
private token: string;
constructor(token: string) {
this.token = token;
}
async initialize(): Promise<void> {
if (typeof window === 'undefined') {
return;
}
const mixpanel = await import('mixpanel-browser');
mixpanel.init(this.token, {
track_pageview: false, // We handle this manually
persistence: 'localStorage',
});
this.mixpanel = mixpanel;
}
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
if (!this.mixpanel) return;
this.mixpanel.identify(userId);
if (traits) {
this.mixpanel.people.set(traits);
}
}
async trackPageView(path: string): Promise<void> {
if (!this.mixpanel) return;
this.mixpanel.track('Page Viewed', { path });
}
async trackEvent(
eventName: string,
eventProperties?: Record<string, string | string[]>
): Promise<void> {
if (!this.mixpanel) return;
this.mixpanel.track(eventName, eventProperties);
}
}
export function createMixpanelService(): AnalyticsService {
const token = process.env.NEXT_PUBLIC_MIXPANEL_TOKEN;
if (!token) {
console.warn('Mixpanel token not configured');
return new NullAnalyticsService();
}
return new MixpanelService(token);
}
```
Install the Mixpanel SDK:
```bash
pnpm add mixpanel-browser --filter "@kit/analytics"
```
## Registering Your Provider
Add your custom provider to the analytics manager:
```typescript {% title="packages/analytics/src/index.ts" %}
import { createAnalyticsManager } from './analytics-manager';
import { createMixpanelService } from './mixpanel-service';
import type { AnalyticsManager } from './types';
export const analytics: AnalyticsManager = createAnalyticsManager({
providers: {
mixpanel: createMixpanelService,
},
});
```
Add environment variables:
```bash {% title=".env.local" %}
NEXT_PUBLIC_MIXPANEL_TOKEN=your_mixpanel_token
```
## Using Multiple Providers
Register multiple providers to dispatch events to all of them:
```typescript {% title="packages/analytics/src/index.ts" %}
import { createAnalyticsManager } from './analytics-manager';
import { createMixpanelService } from './mixpanel-service';
import { createPostHogAnalyticsService } from '@kit/posthog/client';
export const analytics = createAnalyticsManager({
providers: {
mixpanel: createMixpanelService,
posthog: createPostHogAnalyticsService,
},
});
```
When you call `analytics.trackEvent()`, both Mixpanel and PostHog receive the event.
## Example: Amplitude Provider
Here is a skeleton for Amplitude:
```typescript {% title="packages/analytics/src/amplitude-service.ts" %}
import type { AnalyticsService } from './types';
class AmplitudeService implements AnalyticsService {
private amplitude: typeof import('@amplitude/analytics-browser') | null = null;
async initialize(): Promise<void> {
if (typeof window === 'undefined') return;
const amplitude = await import('@amplitude/analytics-browser');
const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
if (apiKey) {
amplitude.init(apiKey);
this.amplitude = amplitude;
}
}
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
if (!this.amplitude) return;
this.amplitude.setUserId(userId);
if (traits) {
const identifyEvent = new this.amplitude.Identify();
Object.entries(traits).forEach(([key, value]) => {
identifyEvent.set(key, value);
});
this.amplitude.identify(identifyEvent);
}
}
async trackPageView(path: string): Promise<void> {
if (!this.amplitude) return;
this.amplitude.track('Page Viewed', { path });
}
async trackEvent(
eventName: string,
eventProperties?: Record<string, string | string[]>
): Promise<void> {
if (!this.amplitude) return;
this.amplitude.track(eventName, eventProperties);
}
}
export function createAmplitudeService(): AnalyticsService {
return new AmplitudeService();
}
```
## Example: Segment Provider
Segment acts as a data router to multiple destinations:
```typescript {% title="packages/analytics/src/segment-service.ts" %}
import type { AnalyticsService } from './types';
declare global {
interface Window {
analytics: {
identify: (userId: string, traits?: object) => void;
page: (name?: string, properties?: object) => void;
track: (event: string, properties?: object) => void;
};
}
}
class SegmentService implements AnalyticsService {
async initialize(): Promise<void> {
// Segment snippet is typically added via <Script> in layout
// This method can verify it's loaded
if (typeof window === 'undefined' || !window.analytics) {
console.warn('Segment analytics not loaded');
}
}
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
window.analytics?.identify(userId, traits);
}
async trackPageView(path: string): Promise<void> {
window.analytics?.page(undefined, { path });
}
async trackEvent(
eventName: string,
eventProperties?: Record<string, string | string[]>
): Promise<void> {
window.analytics?.track(eventName, eventProperties);
}
}
export function createSegmentService(): AnalyticsService {
return new SegmentService();
}
```
## Server-Side Providers
For server-side analytics, create a separate service file:
```typescript {% title="packages/analytics/src/mixpanel-server.ts" %}
import Mixpanel from 'mixpanel';
import type { AnalyticsService } from './types';
class MixpanelServerService implements AnalyticsService {
private mixpanel: Mixpanel.Mixpanel | null = null;
async initialize(): Promise<void> {
const token = process.env.MIXPANEL_TOKEN; // Note: no NEXT_PUBLIC_ prefix
if (token) {
this.mixpanel = Mixpanel.init(token);
}
}
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
if (!this.mixpanel || !traits) return;
this.mixpanel.people.set(userId, traits);
}
async trackPageView(path: string): Promise<void> {
// Server-side page views are uncommon
}
async trackEvent(
eventName: string,
eventProperties?: Record<string, string | string[]>
): Promise<void> {
if (!this.mixpanel) return;
this.mixpanel.track(eventName, eventProperties);
}
}
```
Register in `packages/analytics/src/server.ts`:
```typescript {% title="packages/analytics/src/server.ts" %}
import 'server-only';
import { createAnalyticsManager } from './analytics-manager';
import { createMixpanelServerService } from './mixpanel-server';
export const analytics = createAnalyticsManager({
providers: {
mixpanel: createMixpanelServerService,
},
});
```
## The NullAnalyticsService
When no providers are configured, MakerKit uses a null service that silently ignores all calls:
```typescript
const NullAnalyticsService: AnalyticsService = {
initialize: () => Promise.resolve(),
identify: () => Promise.resolve(),
trackPageView: () => Promise.resolve(),
trackEvent: () => Promise.resolve(),
};
```
Your provider factory can return this when misconfigured to avoid errors.
## Best Practices
1. **Dynamic imports**: Load SDKs dynamically to reduce bundle size
2. **Environment checks**: Always check `typeof window` before accessing browser APIs
3. **Graceful degradation**: Return early if the SDK fails to load
4. **Typed properties**: Define TypeScript interfaces for your event properties
5. **Consistent naming**: Use the same event names across all providers
## Troubleshooting
### Provider not receiving events
- Verify the provider is registered in `createAnalyticsManager`
- Check that `initialize()` completes without errors
- Confirm environment variables are set
### TypeScript errors
- Ensure your class implements all methods in `AnalyticsService`
- Check that return types are `Promise<unknown>` or more specific
### Events delayed or missing
- Some providers batch events. Check provider-specific settings
- Verify the provider SDK is loaded before events are sent
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Can I use the same provider for client and server?", "answer": "It depends on the SDK. Some analytics SDKs (like PostHog) offer both client and server versions. Others (like Mixpanel) have separate packages. Create separate service files for each environment."},
{"question": "How do I test my custom provider?", "answer": "Add console.log statements in each method during development. Most analytics dashboards also have a debug or live events view."},
{"question": "Can I conditionally load providers?", "answer": "Yes. Your factory function can check environment variables or feature flags and return NullAnalyticsService when the provider should be disabled."},
{"question": "How do I handle errors in providers?", "answer": "Wrap SDK calls in try-catch blocks. Log errors but do not throw them, as this would affect other providers in the chain."}
]
/%}
## Next Steps
- [Learn about Analytics and Events](analytics-and-events) for event patterns
- [See Google Analytics](google-analytics-provider) as a reference implementation
- [Try PostHog](posthog-analytics-provider) for a full-featured option

View File

@@ -0,0 +1,148 @@
---
status: "published"
title: 'Using the Google Analytics Provider in Next.js Supabase Turbo'
label: 'Google Analytics'
description: 'Add Google Analytics 4 (GA4) to your MakerKit application for page views, user tracking, and conversion measurement.'
order: 2
---
Google Analytics 4 (GA4) provides web analytics focused on marketing attribution, conversion tracking, and audience insights. Use it when your marketing team needs Google's ecosystem for ad optimization and reporting.
## Prerequisites
Before starting, you need:
- A Google Analytics 4 property ([create one here](https://analytics.google.com/))
- Your GA4 Measurement ID (format: `G-XXXXXXXXXX`)
Find your Measurement ID in GA4: **Admin > Data Streams > Select your stream > Measurement ID**.
## Installation
Install the Google Analytics plugin using the MakerKit CLI:
```bash
npx @makerkit/cli@latest plugins add google-analytics
```
Our codemod will wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`.
Please add your Measurement ID to environment variables:
```bash {% title="apps/web/.env.local" %}
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
```
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Yes | Your GA4 Measurement ID |
| `NEXT_PUBLIC_GA_DISABLE_PAGE_VIEWS_TRACKING` | No | Set to `true` to disable automatic page view tracking |
| `NEXT_PUBLIC_GA_DISABLE_LOCALHOST_TRACKING` | No | Set to `true` to disable tracking on localhost |
### Development Configuration
Disable localhost tracking to avoid polluting your analytics during development:
```bash {% title=".env.local" %}
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
NEXT_PUBLIC_GA_DISABLE_LOCALHOST_TRACKING=true
```
## Verification
After configuration, verify the integration:
1. Open your application in the browser
2. Open Chrome DevTools > Network tab
3. Filter by `google-analytics` or `gtag`
4. Navigate between pages and confirm requests are sent
5. Check GA4 Realtime reports to see your session
## Using with Other Providers
Google Analytics can run alongside other providers. Events dispatch to all registered providers:
```typescript {% title="packages/analytics/src/index.ts" %}
import { createGoogleAnalyticsService } from '@kit/google-analytics';
import { createPostHogAnalyticsService } from '@kit/posthog/client';
import { createAnalyticsManager } from './analytics-manager';
export const analytics = createAnalyticsManager({
providers: {
'google-analytics': createGoogleAnalyticsService,
posthog: createPostHogAnalyticsService,
},
});
```
This setup is common when marketing uses GA4 for attribution while product uses PostHog for behavior analysis.
## Tracked Events
With the default configuration, Google Analytics receives:
- **Page views**: Automatically tracked on route changes
- **User identification**: When `analytics.identify()` is called
- **Custom events**: All events passed to `analytics.trackEvent()`
Events from the App Events system (user.signedUp, checkout.started, etc.) are forwarded to GA4 through the analytics mapping.
## GDPR Considerations
Google Analytics sets cookies and requires user consent in the EU. Integrate with the [Cookie Banner component](/docs/next-supabase-turbo/components/cookie-banner) to manage consent:
```typescript
import { useCookieConsent, ConsentStatus } from '@kit/ui/cookie-banner';
import { analytics } from '@kit/analytics';
import { useEffect } from 'react';
function AnalyticsGate({ children }) {
const { status } = useCookieConsent();
useEffect(() => {
if (status === ConsentStatus.Accepted) {
// GA is initialized automatically when consent is given
// You may want to delay initialization until consent
}
}, [status]);
return children;
}
```
Consider using [Umami](umami-analytics-provider) for cookie-free, GDPR-compliant analytics.
## Troubleshooting
### Events not appearing in GA4
- Verify your Measurement ID is correct
- Check that `NEXT_PUBLIC_GA_DISABLE_LOCALHOST_TRACKING` is not `true` in production
- GA4 has a delay of up to 24-48 hours for some reports. Use Realtime for immediate verification
### Duplicate page views
- Ensure you have not called `trackPageView` manually. MakerKit tracks page views automatically
### Ad blockers
- Ad blockers often block Google Analytics. Consider using a proxy or server-side tracking for critical metrics
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Does MakerKit support Universal Analytics?", "answer": "No. Universal Analytics was sunset by Google in July 2023. MakerKit only supports Google Analytics 4 (GA4)."},
{"question": "Can I use Google Tag Manager instead?", "answer": "Yes, but you would need to create a custom analytics provider. The built-in plugin uses gtag.js directly."},
{"question": "How do I track conversions?", "answer": "Use analytics.trackEvent() with your conversion event name. Configure the event as a conversion in GA4 Admin > Events > Mark as conversion."},
{"question": "Is server-side tracking supported?", "answer": "No. The Google Analytics plugin is client-side only. Use the Measurement Protocol API directly if you need server-side GA4 tracking."}
]
/%}
## Next Steps
- [Learn about Analytics and Events](analytics-and-events) for custom event tracking
- [Add PostHog](posthog-analytics-provider) for product analytics
- [Create a custom provider](custom-analytics-provider) for other services

View File

@@ -0,0 +1,32 @@
---
status: "published"
title: 'Using the Meshes Analytics Provider in Next.js Supabase Turbo'
label: 'Meshes'
description: 'Add Meshes to your MakerKit application for event tracking and analytics.'
order: 6
---
[Meshes](https://meshes.io/) is a platform for event tracking for user engagement and conversions. It captures custom events (signups, plan upgrades, feature usage) so you can measure what matters without building your own event pipeline.
## Installation
Install the Meshes plugin using the MakerKit CLI:
```bash
npx @makerkit/cli@latest plugins add meshes-analytics
```
The Makerkit CLI will automatically wire up the plugin in your project, so you don't have to do anything manually
The codemod is very complex, so please review the changes with `git diff` and commit them.
## Environment Variables
Set the analytics provider and Meshes configuration:
```bash title=".env.local"
# Meshes configuration
NEXT_PUBLIC_MESHES_PUBLISHABLE_KEY=your_api_key_here
```
Please [read the Meshes documentation](https://meshes.io/docs) for more information on how to use the Meshes analytics provider.

View File

@@ -0,0 +1,202 @@
---
status: "published"
title: 'Using the PostHog Analytics Provider in Next.js Supabase Turbo'
label: 'PostHog'
description: 'Add PostHog to your MakerKit application for product analytics, session replay, and feature flags with client-side and server-side support.'
order: 3
---
PostHog provides product analytics, session replay, feature flags, and A/B testing in one platform.
Unlike marketing-focused tools, PostHog helps you understand how users interact with your product.
Posthog supports both client-side and server-side tracking, and can be self-hosted for full data control.
## Prerequisites
Before starting:
- Create a PostHog account at [posthog.com](https://posthog.com)
- Note your Project API Key (starts with `phc_`)
- Choose your region: `eu.posthog.com` or `us.posthog.com`
Find your API key in PostHog: **Project Settings > Project API Key**.
## Installation
Install the PostHog plugin using the MakerKit CLI:
```bash
npx @makerkit/cli@latest plugins add posthog
```
Our codemod will wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`.
Add environment variables:
```bash {% title=".env.local" %}
NEXT_PUBLIC_POSTHOG_KEY=phc_your_key_here
NEXT_PUBLIC_POSTHOG_HOST=https://eu.posthog.com
```
Use `https://us.posthog.com` if your project is in the US region.
## Server-Side Configuration
PostHog supports server-side analytics for tracking events in API routes and Server Actions:
Use server-side tracking in your code:
```typescript
import { analytics } from '@kit/analytics/server';
export async function createProject(data: ProjectData) {
const project = await db.projects.create(data);
await analytics.trackEvent('project.created', {
projectId: project.id,
userId: data.userId,
});
return project;
}
```
## Bypassing Ad Blockers with Ingestion Rewrites
Ad blockers frequently block PostHog. Use Next.js rewrites to proxy requests through your domain:
### Step 1: Add the Ingestion URL
```bash {% title=".env.local" %}
NEXT_PUBLIC_POSTHOG_KEY=phc_your_key_here
NEXT_PUBLIC_POSTHOG_HOST=https://eu.posthog.com
NEXT_PUBLIC_POSTHOG_INGESTION_URL=http://localhost:3000/ingest
```
In production, replace `localhost:3000` with your domain.
### Step 2: Configure Next.js Rewrites
Add rewrites to your Next.js configuration:
```javascript {% title="apps/web/next.config.mjs" %}
/** @type {import('next').NextConfig} */
const config = {
// Required for PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
async rewrites() {
// Change 'eu' to 'us' if using the US region
return [
{
source: '/ingest/static/:path*',
destination: 'https://eu-assets.i.posthog.com/static/:path*',
},
{
source: '/ingest/:path*',
destination: 'https://eu.i.posthog.com/:path*',
},
];
},
};
export default config;
```
### Step 3: Exclude Ingestion Endpoint from Middleware
Ensure the ingestion endpoint is excluded from the middleware matcher:
```typescript {% title="apps/web/proxy.ts" %}
export const config = {
matcher: [
'/((?!_next/static|_next/image|images|locales|assets|ingest/*|api/*).*)',
],
};
```
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_POSTHOG_KEY` | Yes | Your PostHog Project API Key |
| `NEXT_PUBLIC_POSTHOG_HOST` | Yes | PostHog host (`https://eu.posthog.com` or `https://us.posthog.com`) |
| `NEXT_PUBLIC_POSTHOG_INGESTION_URL` | No | Proxy URL to bypass ad blockers (e.g., `https://yourdomain.com/ingest`) |
## Verification
After configuration:
1. Open your application
2. Navigate between pages
3. Open PostHog > Activity > Live Events
4. Confirm page views and events appear
If using ingestion rewrites, check the Network tab for requests to `/ingest` instead of `posthog.com`.
## Using with Other Providers
PostHog works alongside other analytics providers:
```typescript {% title="packages/analytics/src/index.ts" %}
import { createPostHogAnalyticsService } from '@kit/posthog/client';
import { createGoogleAnalyticsService } from '@kit/google-analytics';
import { createAnalyticsManager } from './analytics-manager';
export const analytics = createAnalyticsManager({
providers: {
posthog: createPostHogAnalyticsService,
'google-analytics': createGoogleAnalyticsService,
},
});
```
## PostHog Features Beyond Analytics
PostHog offers additional features beyond event tracking:
- **Session Replay**: Watch user sessions to debug issues
- **Feature Flags**: Control feature rollouts
- **A/B Testing**: Run experiments on UI variants
- **Surveys**: Collect user feedback
These features are available in the PostHog dashboard once you are capturing events.
For monitoring features (error tracking), see the [PostHog Monitoring guide](/docs/next-supabase-turbo/monitoring/posthog).
## Troubleshooting
### Events not appearing
- Verify your API key starts with `phc_`
- Confirm the host matches your project region (EU vs US)
- Check for ad blockers if not using ingestion rewrites
### CORS errors with ingestion rewrites
- Ensure `skipTrailingSlashRedirect: true` is set in next.config.mjs
- Verify the rewrite destination matches your region
### Server-side events not appearing
- Ensure you import from `@kit/analytics/server`, not `@kit/analytics`
- Server-side tracking requires the same environment variables
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Should I use the EU or US region?", "answer": "Use the EU region (eu.posthog.com) if you have European users and want GDPR-compliant data residency. The US region may have slightly lower latency for US-based users."},
{"question": "Can I self-host PostHog?", "answer": "Yes. PostHog can be self-hosted using Docker. Update NEXT_PUBLIC_POSTHOG_HOST to your self-hosted instance URL."},
{"question": "How do I enable session replay?", "answer": "Session replay is enabled by default in PostHog. Configure recording settings in PostHog > Project Settings > Session Replay."},
{"question": "Do ingestion rewrites work on Vercel?", "answer": "Yes. The rewrites in next.config.mjs work on Vercel and other Next.js hosting platforms."},
{"question": "Is PostHog GDPR compliant?", "answer": "PostHog can be GDPR compliant. Use the EU region for data residency, enable cookie-less tracking, and integrate with a consent management solution."}
]
/%}
## Next Steps
- [Learn about Analytics and Events](analytics-and-events) for custom event tracking
- [Set up PostHog for monitoring](/docs/next-supabase-turbo/monitoring/posthog)
- [Try Umami](umami-analytics-provider) for simpler, privacy-focused analytics

View File

@@ -0,0 +1,151 @@
---
status: "published"
title: 'Using the Umami Analytics Provider in Next.js Supabase Turbo'
label: 'Umami'
description: 'Add Umami to your MakerKit application for privacy-focused, cookie-free analytics that comply with GDPR without consent banners.'
order: 4
---
Umami is a privacy-focused analytics platform that tracks page views and events without cookies.
Because it does not use cookies or collect personal data, you can use Umami without displaying cookie consent banners in the EU. Umami can be self-hosted for complete data ownership or used via Umami Cloud.
## Why Choose Umami
| Feature | Umami | Google Analytics |
|---------|-------|------------------|
| Cookies | None | Yes |
| GDPR consent required | No | Yes |
| Self-hosting | Yes | No |
| Pricing | Free (self-hosted) or paid cloud | Free |
| Data ownership | Full | Google |
| Session replay | No | No |
| Feature flags | No | No |
**Use Umami when**: You want simple, clean metrics without privacy concerns. Ideal for landing pages, documentation sites, and applications where marketing attribution is not critical.
## Prerequisites
Before starting:
- Create an Umami account at [umami.is](https://umami.is) or self-host
- Create a website in your Umami dashboard
- Note your **Website ID** and **Script URL**
In Umami Cloud, find these at: **Settings > Websites > Your Website > Edit**.
## Installation
Install the Umami plugin using the MakerKit CLI:
```bash
npx @makerkit/cli@latest plugins add umami
```
Our codemod will wire up the plugin in your project, so you don't have to do anything manually. Please review the changes with `git diff`.
Add environment variables:
```bash {% title=".env.local" %}
NEXT_PUBLIC_UMAMI_HOST=https://cloud.umami.is/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id
```
### Self-Hosted Configuration
If self-hosting Umami, point to your instance:
```bash {% title=".env.local" %}
NEXT_PUBLIC_UMAMI_HOST=https://analytics.yourdomain.com/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id
```
Replace the URL with the path to your Umami instance's tracking script.
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_UMAMI_HOST` | Yes | URL to the Umami tracking script |
| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | Yes | Your website ID from Umami |
| `NEXT_PUBLIC_UMAMI_DISABLE_LOCALHOST_TRACKING` | No | Set to `false` to enable localhost tracking |
### Development Configuration
By default, Umami does not track localhost. Enable it for development testing:
```bash {% title=".env.local" %}
NEXT_PUBLIC_UMAMI_HOST=https://cloud.umami.is/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id
NEXT_PUBLIC_UMAMI_DISABLE_LOCALHOST_TRACKING=false
```
## Verification
After configuration:
1. Deploy to a non-localhost environment (or enable localhost tracking)
2. Open your application and navigate between pages
3. Check your Umami dashboard > Realtime
4. Confirm page views appear
## Custom Event Tracking
Umami tracks page views automatically. For custom events:
```typescript
import { analytics } from '@kit/analytics';
void analytics.trackEvent('Button Clicked', {
button: 'signup',
location: 'header',
});
```
Events appear in Umami under **Events** in your website dashboard.
## Using with Other Providers
Umami can run alongside other providers:
```typescript {% title="packages/analytics/src/index.ts" %}
import { createUmamiAnalyticsService } from '@kit/umami';
import { createPostHogAnalyticsService } from '@kit/posthog/client';
import { createAnalyticsManager } from './analytics-manager';
export const analytics = createAnalyticsManager({
providers: {
umami: createUmamiAnalyticsService,
posthog: createPostHogAnalyticsService,
},
});
```
This setup provides Umami's clean metrics alongside PostHog's product analytics.
## Troubleshooting
### No data appearing
- Verify you are not on localhost (or enable localhost tracking)
- Check that the Website ID matches your Umami dashboard
- Confirm the script URL is correct and accessible
### Ad blocker interference
Umami is sometimes blocked by ad blockers. If this is an issue:
1. Self-host Umami on a subdomain (e.g., `analytics.yourdomain.com`)
2. Use a generic script path (e.g., `/stats.js` instead of `/script.js`)
### Events not tracked
- Ensure event names and properties are strings
- Check that the event appears in Umami's Events tab, not just Page Views
## Next Steps
- [Learn about Analytics and Events](analytics-and-events) for event tracking patterns
- [Consider PostHog](posthog-analytics-provider) if you need user identification or feature flags
- [Create a custom provider](custom-analytics-provider) for other analytics services

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,461 @@
---
status: "published"
label: "Billing API"
title: "Billing API Reference for Next.js Supabase SaaS Kit"
order: 10
description: "Complete API reference for Makerkit's billing service. Create checkouts, manage subscriptions, report usage, and handle billing operations programmatically."
---
The Billing Gateway Service provides a unified API for all billing operations, regardless of which payment provider you use (Stripe, Lemon Squeezy, or Paddle). This abstraction lets you switch providers without changing your application code.
## Getting the Billing Service
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
// Get service for the configured provider
const service = createBillingGatewayService(
process.env.NEXT_PUBLIC_BILLING_PROVIDER
);
// Or specify a provider explicitly
const stripeService = createBillingGatewayService('stripe');
```
For most operations, get the provider from the user's subscription record:
```tsx
import { createAccountsApi } from '@kit/accounts/api';
const accountsApi = createAccountsApi(supabaseClient);
const subscription = await accountsApi.getSubscription(accountId);
const provider = subscription?.billing_provider ?? 'stripe';
const service = createBillingGatewayService(provider);
```
## Create Checkout Session
Start a new subscription or one-off purchase.
```tsx
const { checkoutToken } = await service.createCheckoutSession({
accountId: 'uuid-of-account',
plan: billingConfig.products[0].plans[0], // From billing.config.ts
returnUrl: 'https://yourapp.com/billing/return',
customerEmail: 'user@example.com', // Optional
customerId: 'cus_xxx', // Optional, if customer already exists
enableDiscountField: true, // Optional, show coupon input
variantQuantities: [ // Optional, for per-seat billing
{ variantId: 'price_xxx', quantity: 5 }
],
});
```
**Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `accountId` | `string` | Yes | UUID of the account making the purchase |
| `plan` | `Plan` | Yes | Plan object from your billing config |
| `returnUrl` | `string` | Yes | URL to redirect after checkout |
| `customerEmail` | `string` | No | Pre-fill customer email |
| `customerId` | `string` | No | Existing customer ID (skips customer creation) |
| `enableDiscountField` | `boolean` | No | Show coupon/discount input |
| `variantQuantities` | `array` | No | Override quantities for line items |
**Returns:**
```tsx
{
checkoutToken: string // Token to open checkout UI
}
```
**Example: Server Action**
```tsx
'use server';
import { createBillingGatewayService } from '@kit/billing-gateway';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import billingConfig from '~/config/billing.config';
export async function createCheckout(planId: string, accountId: string) {
const supabase = getSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Not authenticated');
}
const plan = billingConfig.products
.flatMap(p => p.plans)
.find(p => p.id === planId);
if (!plan) {
throw new Error('Plan not found');
}
const service = createBillingGatewayService(billingConfig.provider);
const { checkoutToken } = await service.createCheckoutSession({
accountId,
plan,
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing/return`,
customerEmail: user.email,
});
return { checkoutToken };
}
```
## Retrieve Checkout Session
Check the status of a checkout session after redirect.
```tsx
const session = await service.retrieveCheckoutSession({
sessionId: 'cs_xxx', // From URL params after redirect
});
```
**Returns:**
```tsx
{
checkoutToken: string | null,
status: 'complete' | 'expired' | 'open',
isSessionOpen: boolean,
customer: {
email: string | null
}
}
```
**Example: Return page handler**
```tsx
// app/[locale]/home/[account]/billing/return/page.tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
export default async function BillingReturnPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>
}) {
const { session_id } = await searchParams;
if (!session_id) {
return <div>Invalid session</div>;
}
const service = createBillingGatewayService('stripe');
const session = await service.retrieveCheckoutSession({
sessionId: session_id,
});
if (session.status === 'complete') {
return <div>Payment successful!</div>;
}
return <div>Payment pending or failed</div>;
}
```
## Create Billing Portal Session
Open the customer portal for subscription management.
```tsx
const { url } = await service.createBillingPortalSession({
customerId: 'cus_xxx', // From billing_customers table
returnUrl: 'https://yourapp.com/billing',
});
// Redirect user to the portal URL
```
**Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `customerId` | `string` | Yes | Customer ID from billing provider |
| `returnUrl` | `string` | Yes | URL to redirect after portal session |
**Example: Server Action**
```tsx
'use server';
import { redirect } from 'next/navigation';
import { createBillingGatewayService } from '@kit/billing-gateway';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function openBillingPortal(accountId: string) {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
const customerId = await api.getCustomerId(accountId);
if (!customerId) {
throw new Error('No billing customer found');
}
const service = createBillingGatewayService('stripe');
const { url } = await service.createBillingPortalSession({
customerId,
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`,
});
redirect(url);
}
```
## Cancel Subscription
Cancel a subscription immediately or at period end.
```tsx
const { success } = await service.cancelSubscription({
subscriptionId: 'sub_xxx',
invoiceNow: false, // Optional: charge immediately for usage
});
```
**Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `subscriptionId` | `string` | Yes | Subscription ID from provider |
| `invoiceNow` | `boolean` | No | Invoice outstanding usage immediately |
**Example: Cancel at period end**
```tsx
'use server';
import { createBillingGatewayService } from '@kit/billing-gateway';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function cancelSubscription(accountId: string) {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
const subscription = await api.getSubscription(accountId);
if (!subscription) {
throw new Error('No subscription found');
}
const service = createBillingGatewayService(subscription.billing_provider);
await service.cancelSubscription({
subscriptionId: subscription.id,
});
return { success: true };
}
```
## Report Usage (Metered Billing)
Report usage for metered billing subscriptions.
### Stripe
Stripe uses customer ID and a meter event name:
```tsx
await service.reportUsage({
id: 'cus_xxx', // Customer ID
eventName: 'api_requests', // Meter name in Stripe
usage: {
quantity: 100,
},
});
```
### Lemon Squeezy
Lemon Squeezy uses subscription item ID:
```tsx
await service.reportUsage({
id: 'sub_item_xxx', // Subscription item ID
usage: {
quantity: 100,
action: 'increment', // or 'set'
},
});
```
**Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | `string` | Yes | Customer ID (Stripe) or subscription item ID (LS) |
| `eventName` | `string` | Stripe only | Meter event name |
| `usage.quantity` | `number` | Yes | Usage amount |
| `usage.action` | `'increment' \| 'set'` | No | How to apply usage (LS only) |
**Example: Track API usage**
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function trackApiUsage(accountId: string, requestCount: number) {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
const subscription = await api.getSubscription(accountId);
if (!subscription || subscription.status !== 'active') {
return; // No active subscription
}
const service = createBillingGatewayService(subscription.billing_provider);
const customerId = await api.getCustomerId(accountId);
if (subscription.billing_provider === 'stripe') {
await service.reportUsage({
id: customerId!,
eventName: 'api_requests',
usage: { quantity: requestCount },
});
} else {
// Lemon Squeezy: need subscription item ID
const { data: item } = await supabase
.from('subscription_items')
.select('id')
.eq('subscription_id', subscription.id)
.eq('type', 'metered')
.single();
if (item) {
await service.reportUsage({
id: item.id,
usage: { quantity: requestCount, action: 'increment' },
});
}
}
}
```
## Query Usage
Retrieve usage data for a metered subscription.
### Stripe
```tsx
const usage = await service.queryUsage({
id: 'meter_xxx', // Stripe Meter ID
customerId: 'cus_xxx',
filter: {
startTime: Math.floor(Date.now() / 1000) - 86400 * 30, // 30 days ago
endTime: Math.floor(Date.now() / 1000),
},
});
```
### Lemon Squeezy
```tsx
const usage = await service.queryUsage({
id: 'sub_item_xxx', // Subscription item ID
customerId: 'cus_xxx',
filter: {
page: 1,
size: 100,
},
});
```
**Returns:**
```tsx
{
value: number // Total usage in period
}
```
## Update Subscription Item
Update the quantity of a subscription item (e.g., seat count).
```tsx
const { success } = await service.updateSubscriptionItem({
subscriptionId: 'sub_xxx',
subscriptionItemId: 'si_xxx',
quantity: 10,
});
```
**Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `subscriptionId` | `string` | Yes | Subscription ID |
| `subscriptionItemId` | `string` | Yes | Line item ID within subscription |
| `quantity` | `number` | Yes | New quantity (minimum 1) |
{% alert type="default" title="Automatic seat updates" %}
For per-seat billing, Makerkit automatically updates seat counts when team members are added or removed. You typically don't need to call this directly.
{% /alert %}
## Get Subscription Details
Retrieve subscription details from the provider.
```tsx
const subscription = await service.getSubscription('sub_xxx');
```
**Returns:** Provider-specific subscription object.
## Get Plan Details
Retrieve plan/price details from the provider.
```tsx
const plan = await service.getPlanById('price_xxx');
```
**Returns:** Provider-specific plan/price object.
## Error Handling
All methods can throw errors. Wrap calls in try-catch:
```tsx
try {
const { checkoutToken } = await service.createCheckoutSession({
// ...
});
} catch (error) {
if (error instanceof Error) {
console.error('Billing error:', error.message);
}
// Handle error appropriately
}
```
Common errors:
- Invalid API keys
- Invalid price/plan IDs
- Customer not found
- Subscription not found
- Network/provider errors
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle billing events
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing guide
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing

View File

@@ -0,0 +1,632 @@
---
status: "published"
label: "Billing Schema"
title: "Configure SaaS Pricing Plans with the Billing Schema"
order: 1
description: "Define your SaaS pricing with Makerkit's billing schema. Configure products, plans, flat subscriptions, per-seat pricing, metered usage, and one-off payments for Stripe, Lemon Squeezy, or Paddle."
---
The billing schema defines your products and pricing in a single configuration file. This schema drives the pricing table UI, checkout sessions, and subscription management across all supported providers (Stripe, Lemon Squeezy, Paddle).
## Schema Structure
The schema has three levels:
```
Products (what you sell)
└── Plans (pricing options: monthly, yearly)
└── Line Items (how you charge: flat, per-seat, metered)
```
**Example:** A "Pro" product might have "Pro Monthly" and "Pro Yearly" plans. Each plan has line items defining the actual charges.
## Quick Start
Create or edit `apps/web/config/billing.config.ts`:
```tsx
import { createBillingSchema } from '@kit/billing';
const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';
export default createBillingSchema({
provider,
products: [
{
id: 'pro',
name: 'Pro',
description: 'For growing teams',
currency: 'USD',
badge: 'Popular',
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_xxxxxxxxxxxxx', // Your Stripe Price ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
},
{
id: 'pro-yearly',
name: 'Pro Yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_yyyyyyyyyyyyy', // Your Stripe Price ID
name: 'Pro Plan',
cost: 290,
type: 'flat',
},
],
},
],
},
],
});
```
{% alert type="warning" title="Match IDs exactly" %}
Line item `id` values **must match** the Price IDs in your billing provider (Stripe, Lemon Squeezy, or Paddle). The schema validates this format but cannot verify the IDs exist in your provider account.
{% /alert %}
## Setting the Billing Provider
Set the provider via environment variable:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle
```
Also update the database configuration:
```sql
UPDATE public.config SET billing_provider = 'stripe';
```
The provider determines which API is called when creating checkouts, managing subscriptions, and processing webhooks.
## Products
Products represent what you're selling (e.g., "Starter", "Pro", "Enterprise"). Each product can have multiple plans with different billing intervals.
```tsx
{
id: 'pro',
name: 'Pro',
description: 'For growing teams',
currency: 'USD',
badge: 'Popular',
highlighted: true,
enableDiscountField: true,
features: [
'Unlimited projects',
'Priority support',
'Advanced analytics',
],
plans: [/* ... */],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier (your choice, not the provider's ID) |
| `name` | Yes | Display name in pricing table |
| `description` | Yes | Short description shown to users |
| `currency` | Yes | ISO currency code (e.g., "USD", "EUR") |
| `plans` | Yes | Array of pricing plans |
| `badge` | No | Badge text (e.g., "Popular", "Best Value") |
| `highlighted` | No | Visually highlight this product |
| `enableDiscountField` | No | Show coupon/discount input at checkout |
| `features` | No | Feature list for pricing table |
| `hidden` | No | Hide from pricing table (for legacy plans) |
The `id` is your internal identifier. It doesn't need to match anything in Stripe or your payment provider.
## Plans
Plans define pricing options within a product. Typically, you'll have monthly and yearly variants.
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14,
lineItems: [/* ... */],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier (your choice) |
| `name` | Yes | Display name |
| `paymentType` | Yes | `'recurring'` or `'one-time'` |
| `interval` | Recurring only | `'month'` or `'year'` |
| `lineItems` | Yes | Array of line items (charges) |
| `trialDays` | No | Free trial period in days |
| `custom` | No | Mark as custom/enterprise plan (see below) |
| `href` | Custom only | Link for custom plans |
| `label` | Custom only | Button label for custom plans |
| `buttonLabel` | No | Custom checkout button text |
**Plan ID validation:** The schema validates that plan IDs are unique across all products.
## Line Items
Line items define how you charge for a plan. Makerkit supports three types:
| Type | Use Case | Example |
|------|----------|---------|
| `flat` | Fixed recurring price | $29/month |
| `per_seat` | Per-user pricing | $10/seat/month |
| `metered` | Usage-based pricing | $0.01 per API call |
**Provider limitations:**
- **Stripe:** Supports multiple line items per plan (mix flat + per-seat + metered)
- **Lemon Squeezy:** One line item per plan only
- **Paddle:** Flat and per-seat only (no metered billing)
### Flat Subscriptions
The most common pricing model. A fixed amount charged at each billing interval.
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', // Stripe Price ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Price (for UI display only) |
| `type` | Yes | `'flat'` |
{% alert type="default" title="Cost is for display only" %}
The `cost` field is used for the pricing table UI. The actual charge comes from your billing provider. Make sure they match to avoid confusing users.
{% /alert %}
### Metered Billing
Charge based on usage (API calls, storage, tokens). You report usage through the billing API, and the provider calculates charges at the end of each billing period.
```tsx
{
id: 'api-monthly',
name: 'API Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 }, // First 1000 free
{ upTo: 10000, cost: 0.001 }, // $0.001 per request
{ upTo: 'unlimited', cost: 0.0005 }, // Volume discount
],
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Base cost (usually 0 for metered) |
| `type` | Yes | `'metered'` |
| `unit` | Yes | Unit label (e.g., "requests", "GBs", "tokens") |
| `tiers` | Yes | Array of pricing tiers |
**Tier structure:**
```tsx
{
upTo: number | 'unlimited', // Usage threshold
cost: number, // Cost per unit in this tier
}
```
The last tier should always have `upTo: 'unlimited'`.
{% alert type="warning" title="Provider-specific metered billing" %}
Stripe and Lemon Squeezy handle metered billing differently. See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for provider-specific implementation details.
{% /alert %}
### Per-Seat Billing
Charge based on team size. Makerkit automatically updates seat counts when members are added or removed from a team account.
```tsx
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 12 }, // $12/seat for 4-10
{ upTo: 'unlimited', cost: 10 }, // Volume discount
],
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Base cost (usually 0 for tiered) |
| `type` | Yes | `'per_seat'` |
| `tiers` | Yes | Array of pricing tiers |
**Common patterns:**
```tsx
// Free tier + flat per-seat
tiers: [
{ upTo: 5, cost: 0 }, // 5 free seats
{ upTo: 'unlimited', cost: 15 },
]
// Volume discounts
tiers: [
{ upTo: 10, cost: 20 }, // $20/seat for 1-10
{ upTo: 50, cost: 15 }, // $15/seat for 11-50
{ upTo: 'unlimited', cost: 10 },
]
// Flat price (no tiers)
tiers: [
{ upTo: 'unlimited', cost: 10 },
]
```
Makerkit handles seat count updates automatically when:
- A new member joins the team
- A member is removed from the team
- A member invitation is accepted
[Full per-seat billing guide →](/docs/next-supabase-turbo/billing/per-seat-billing)
### One-Off Payments
Single charges for lifetime access, add-ons, or credits. One-off payments are stored in the `orders` table instead of `subscriptions`.
```tsx
{
id: 'lifetime',
name: 'Lifetime Access',
paymentType: 'one-time',
// No interval for one-time payments
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Lifetime Access',
cost: 299,
type: 'flat',
},
],
}
```
**Key differences from subscriptions:**
- `paymentType` must be `'one-time'`
- No `interval` field
- Line items can only be `type: 'flat'`
- Data is stored in `orders` and `order_items` tables
[Full one-off payments guide →](/docs/next-supabase-turbo/billing/one-off-payments)
## Combining Line Items (Stripe Only)
With Stripe, you can combine multiple line items in a single plan. This is useful for hybrid pricing models:
```tsx
{
id: 'growth-monthly',
name: 'Growth Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
// Base platform fee
{
id: 'price_base_fee',
name: 'Platform Fee',
cost: 49,
type: 'flat',
},
// Per-seat charges
{
id: 'price_seats',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 10 },
],
},
// Usage-based charges
{
id: 'price_api',
name: 'API Calls',
cost: 0,
type: 'metered',
unit: 'calls',
tiers: [
{ upTo: 10000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
],
},
],
}
```
{% alert type="warning" title="Lemon Squeezy and Paddle limitations" %}
Lemon Squeezy and Paddle only support one line item per plan. The schema validation will fail if you add multiple line items with these providers.
{% /alert %}
## Custom Plans (Enterprise/Contact Us)
Display a plan in the pricing table without checkout functionality. Useful for enterprise tiers or "Contact Us" options.
```tsx
{
id: 'enterprise',
name: 'Enterprise',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: '$5,000+', // or 'common.contactUs' for i18n
href: '/contact',
buttonLabel: 'Contact Sales',
lineItems: [], // Must be empty array
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `custom` | Yes | Set to `true` |
| `label` | Yes | Price label (e.g., "Custom pricing", "$5,000+") |
| `href` | Yes | Link destination (e.g., "/contact", "mailto:sales@...") |
| `buttonLabel` | No | Custom CTA text |
| `lineItems` | Yes | Must be empty array `[]` |
Custom plans appear in the pricing table but clicking them navigates to `href` instead of opening checkout.
## Legacy Plans
When you discontinue a plan but have existing subscribers, use the `hidden` flag to keep the plan in your schema without showing it in the pricing table:
```tsx
{
id: 'old-pro',
name: 'Pro (Legacy)',
description: 'This plan is no longer available',
currency: 'USD',
hidden: true, // Won't appear in pricing table
plans: [
{
id: 'old-pro-monthly',
name: 'Pro Monthly (Legacy)',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_legacy_xxx',
name: 'Pro Plan',
cost: 19,
type: 'flat',
},
],
},
],
}
```
Hidden plans:
- Don't appear in the pricing table
- Still display correctly in the user's billing section
- Allow existing subscribers to continue without issues
**If you remove a plan entirely:** Makerkit will attempt to fetch plan details from the billing provider. This works for `flat` line items only. For complex plans, keep them in your schema with `hidden: true`.
## Schema Validation
The `createBillingSchema` function validates your configuration and throws errors for common mistakes:
| Validation | Rule |
|------------|------|
| Unique Plan IDs | Plan IDs must be unique across all products |
| Unique Line Item IDs | Line item IDs must be unique across all plans |
| Provider constraints | Lemon Squeezy: max 1 line item per plan |
| Required tiers | Metered and per-seat items require `tiers` array |
| One-time payments | Must have `type: 'flat'` line items only |
| Recurring payments | Must specify `interval: 'month'` or `'year'` |
## Complete Example
Here's a full billing schema with multiple products and pricing models:
```tsx
import { createBillingSchema } from '@kit/billing';
const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';
export default createBillingSchema({
provider,
products: [
// Free tier (custom plan, no billing)
{
id: 'free',
name: 'Free',
description: 'Get started for free',
currency: 'USD',
plans: [
{
id: 'free',
name: 'Free',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: '$0',
href: '/auth/sign-up',
buttonLabel: 'Get Started',
lineItems: [],
},
],
},
// Pro tier with monthly/yearly
{
id: 'pro',
name: 'Pro',
description: 'For professionals and small teams',
currency: 'USD',
badge: 'Popular',
highlighted: true,
features: [
'Unlimited projects',
'Priority support',
'Advanced analytics',
'Custom integrations',
],
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14,
lineItems: [
{
id: 'price_pro_monthly',
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
},
{
id: 'pro-yearly',
name: 'Pro Yearly',
paymentType: 'recurring',
interval: 'year',
trialDays: 14,
lineItems: [
{
id: 'price_pro_yearly',
name: 'Pro Plan',
cost: 290,
type: 'flat',
},
],
},
],
},
// Team tier with per-seat pricing
{
id: 'team',
name: 'Team',
description: 'For growing teams',
currency: 'USD',
features: [
'Everything in Pro',
'Team management',
'SSO authentication',
'Audit logs',
],
plans: [
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_team_monthly',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 15 },
],
},
],
},
],
},
// Enterprise tier
{
id: 'enterprise',
name: 'Enterprise',
description: 'For large organizations',
currency: 'USD',
features: [
'Everything in Team',
'Dedicated support',
'Custom contracts',
'SLA guarantees',
],
plans: [
{
id: 'enterprise',
name: 'Enterprise',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: 'Custom',
href: '/contact',
buttonLabel: 'Contact Sales',
lineItems: [],
},
],
},
],
});
```
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe billing
- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure Lemon Squeezy
- [Paddle Setup](/docs/next-supabase-turbo/billing/paddle) - Configure Paddle
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based pricing
- [One-Off Payments](/docs/next-supabase-turbo/billing/one-off-payments) - Lifetime deals and add-ons

View File

@@ -0,0 +1,467 @@
---
status: "published"
label: "Handling Webhooks"
title: "Handle Billing Webhooks in Next.js Supabase SaaS Kit"
order: 9
description: "Learn how to handle billing webhooks from Stripe, Lemon Squeezy, and Paddle. Extend the default webhook handler with custom logic for payment events, subscription changes, and more."
---
Webhooks let your billing provider notify your application about events like successful payments, subscription changes, and cancellations. Makerkit handles the core webhook processing, but you can extend it with custom logic.
## Default Webhook Behavior
Makerkit's webhook handler automatically:
1. Verifies the webhook signature
2. Processes the event based on type
3. Updates the database (`subscriptions`, `subscription_items`, `orders`, `order_items`)
4. Returns appropriate HTTP responses
The webhook endpoint is: `/api/billing/webhook`
## Extending the Webhook Handler
Add custom logic by providing callbacks to `handleWebhookEvent`:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { getPlanTypesMap } from '@kit/billing';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import billingConfig from '~/config/billing.config';
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');
const supabaseClientProvider = () => getSupabaseServerAdminClient();
const service = await getBillingEventHandlerService(
supabaseClientProvider,
provider,
getPlanTypesMap(billingConfig),
);
try {
await service.handleWebhookEvent(request, {
// Add your custom callbacks here
onCheckoutSessionCompleted: async (subscription, customerId) => {
logger.info({ customerId }, 'Checkout completed');
// Send welcome email, provision resources, etc.
},
onSubscriptionUpdated: async (subscription) => {
logger.info({ subscriptionId: subscription.id }, 'Subscription updated');
// Handle plan changes, sync with external systems
},
onSubscriptionDeleted: async (subscriptionId) => {
logger.info({ subscriptionId }, 'Subscription deleted');
// Clean up resources, send cancellation email
},
onPaymentSucceeded: async (sessionId) => {
logger.info({ sessionId }, 'Payment succeeded');
// Send receipt, update analytics
},
onPaymentFailed: async (sessionId) => {
logger.info({ sessionId }, 'Payment failed');
// Send payment failure notification
},
onInvoicePaid: async (data) => {
logger.info({ accountId: data.target_account_id }, 'Invoice paid');
// Recharge credits, send invoice email
},
});
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 webhook', { status: 500 });
}
},
{ auth: false } // Webhooks don't require authentication
);
```
## Available Callbacks
### onCheckoutSessionCompleted
Called when a checkout is successfully completed (new subscription or order).
```tsx
onCheckoutSessionCompleted: async (subscription, customerId) => {
// subscription: UpsertSubscriptionParams | UpsertOrderParams
// customerId: string
const accountId = subscription.target_account_id;
// Send welcome email
await sendEmail({
to: subscription.target_customer_email,
template: 'welcome',
data: { planName: subscription.line_items[0]?.product_id },
});
// Provision resources
await provisionResources(accountId);
// Track analytics
await analytics.track('subscription_created', {
accountId,
plan: subscription.line_items[0]?.variant_id,
});
}
```
### onSubscriptionUpdated
Called when a subscription is updated (plan change, renewal, etc.).
```tsx
onSubscriptionUpdated: async (subscription) => {
// subscription: UpsertSubscriptionParams
const accountId = subscription.target_account_id;
const status = subscription.status;
// Handle plan changes
if (subscription.line_items) {
await syncPlanFeatures(accountId, subscription.line_items);
}
// Handle status changes
if (status === 'past_due') {
await sendPaymentReminder(accountId);
}
if (status === 'canceled') {
await scheduleResourceCleanup(accountId);
}
}
```
### onSubscriptionDeleted
Called when a subscription is fully deleted/expired.
```tsx
onSubscriptionDeleted: async (subscriptionId) => {
// subscriptionId: string
// Look up the subscription in your database
const { data: subscription } = await supabase
.from('subscriptions')
.select('account_id')
.eq('id', subscriptionId)
.single();
if (subscription) {
// Clean up resources
await cleanupResources(subscription.account_id);
// Send cancellation email
await sendCancellationEmail(subscription.account_id);
// Update analytics
await analytics.track('subscription_canceled', {
accountId: subscription.account_id,
});
}
}
```
### onPaymentSucceeded
Called when a payment succeeds (for async payment methods like bank transfers).
```tsx
onPaymentSucceeded: async (sessionId) => {
// sessionId: string (checkout session ID)
// Look up the session details
const session = await billingService.retrieveCheckoutSession({ sessionId });
// Send receipt
await sendReceipt(session.customer.email);
}
```
### onPaymentFailed
Called when a payment fails.
```tsx
onPaymentFailed: async (sessionId) => {
// sessionId: string
// Notify the customer
await sendPaymentFailedEmail(sessionId);
// Log for monitoring
logger.warn({ sessionId }, 'Payment failed');
}
```
### onInvoicePaid
Called when an invoice is paid (subscriptions only, useful for credit recharges).
```tsx
onInvoicePaid: async (data) => {
// data: {
// target_account_id: string,
// target_customer_id: string,
// target_customer_email: string,
// line_items: SubscriptionLineItem[],
// }
const accountId = data.target_account_id;
const variantId = data.line_items[0]?.variant_id;
// Recharge credits based on plan
await rechargeCredits(accountId, variantId);
// Send invoice email
await sendInvoiceEmail(data.target_customer_email);
}
```
### onEvent (Catch-All)
Handle any event not covered by the specific callbacks.
```tsx
onEvent: async (event) => {
// event: unknown (provider-specific event object)
// Example: Handle Stripe-specific events
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object as Stripe.Invoice;
// Custom handling
}
// Example: Handle Lemon Squeezy events
if (event.event_name === 'license_key_created') {
// Handle license key creation
}
}
```
## Provider-Specific Events
### Stripe Events
| Event | Callback | Description |
|-------|----------|-------------|
| `checkout.session.completed` | `onCheckoutSessionCompleted` | Checkout completed |
| `customer.subscription.created` | `onSubscriptionUpdated` | New subscription |
| `customer.subscription.updated` | `onSubscriptionUpdated` | Subscription changed |
| `customer.subscription.deleted` | `onSubscriptionDeleted` | Subscription ended |
| `checkout.session.async_payment_succeeded` | `onPaymentSucceeded` | Async payment succeeded |
| `checkout.session.async_payment_failed` | `onPaymentFailed` | Async payment failed |
| `invoice.paid` | `onInvoicePaid` | Invoice paid |
### Lemon Squeezy Events
| Event | Callback | Description |
|-------|----------|-------------|
| `order_created` | `onCheckoutSessionCompleted` | Order created |
| `subscription_created` | `onCheckoutSessionCompleted` | Subscription created |
| `subscription_updated` | `onSubscriptionUpdated` | Subscription updated |
| `subscription_expired` | `onSubscriptionDeleted` | Subscription expired |
### Paddle Events
| Event | Callback | Description |
|-------|----------|-------------|
| `transaction.completed` | `onCheckoutSessionCompleted` | Transaction completed |
| `subscription.activated` | `onSubscriptionUpdated` | Subscription activated |
| `subscription.updated` | `onSubscriptionUpdated` | Subscription updated |
| `subscription.canceled` | `onSubscriptionDeleted` | Subscription canceled |
## Example: Credit Recharge System
Here's a complete example of recharging credits when an invoice is paid:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { getPlanTypesMap } from '@kit/billing';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import billingConfig from '~/config/billing.config';
export const POST = enhanceRouteHandler(
async ({ request }) => {
const provider = billingConfig.provider;
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
const service = await getBillingEventHandlerService(
() => adminClient,
provider,
getPlanTypesMap(billingConfig),
);
try {
await service.handleWebhookEvent(request, {
onInvoicePaid: async (data) => {
const accountId = data.target_account_id;
const variantId = data.line_items[0]?.variant_id;
if (!variantId) {
logger.error({ accountId }, 'No variant ID in invoice');
return;
}
// Get credits for this plan from your plans table
const { data: plan } = await adminClient
.from('plans')
.select('tokens')
.eq('variant_id', variantId)
.single();
if (!plan) {
logger.error({ variantId }, 'Plan not found');
return;
}
// Reset credits for the account
const { error } = await adminClient
.from('credits')
.upsert({
account_id: accountId,
tokens: plan.tokens,
});
if (error) {
logger.error({ accountId, error }, 'Failed to update credits');
throw error;
}
logger.info({ accountId, tokens: plan.tokens }, 'Credits recharged');
},
});
return new Response('OK', { status: 200 });
} catch (error) {
logger.error({ error }, 'Webhook processing failed');
return new Response('Failed', { status: 500 });
}
},
{ auth: false }
);
```
## Webhook Security
### Signature Verification
Makerkit automatically verifies webhook signatures. Never disable this in production.
The verification uses:
- **Stripe:** `STRIPE_WEBHOOK_SECRET`
- **Lemon Squeezy:** `LEMON_SQUEEZY_SIGNING_SECRET`
- **Paddle:** `PADDLE_WEBHOOK_SECRET_KEY`
### Idempotency
Webhooks can be delivered multiple times. Make your handlers idempotent:
```tsx
onCheckoutSessionCompleted: async (subscription) => {
// Check if already processed
const { data: existing } = await supabase
.from('processed_webhooks')
.select('id')
.eq('subscription_id', subscription.id)
.single();
if (existing) {
logger.info({ id: subscription.id }, 'Already processed, skipping');
return;
}
// Process the webhook
await processSubscription(subscription);
// Mark as processed
await supabase
.from('processed_webhooks')
.insert({ subscription_id: subscription.id });
}
```
### Error Handling
Return appropriate HTTP status codes:
- **200:** Success (even if you skip processing)
- **500:** Temporary failure (provider will retry)
- **400:** Invalid request (provider won't retry)
```tsx
try {
await service.handleWebhookEvent(request, callbacks);
return new Response('OK', { status: 200 });
} catch (error) {
if (isTemporaryError(error)) {
// Provider will retry
return new Response('Temporary failure', { status: 500 });
}
// Don't retry invalid requests
return new Response('Invalid request', { status: 400 });
}
```
## Debugging Webhooks
### Local Development
Use the Stripe CLI or ngrok to test webhooks locally:
```bash
# Stripe CLI
stripe listen --forward-to localhost:3000/api/billing/webhook
# ngrok (for Lemon Squeezy/Paddle)
ngrok http 3000
```
### Logging
Add detailed logging to track webhook processing:
```tsx
const logger = await getLogger();
logger.info({ eventType: event.type }, 'Processing webhook');
logger.debug({ payload: event }, 'Webhook payload');
logger.error({ error }, 'Webhook failed');
```
### Webhook Logs in Provider Dashboards
Check webhook delivery status:
- **Stripe:** Dashboard → Developers → Webhooks → Recent events
- **Lemon Squeezy:** Settings → Webhooks → View logs
- **Paddle:** Developer Tools → Notifications → View logs
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe webhooks
- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure LS webhooks
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Recharge credits on payment

View File

@@ -0,0 +1,487 @@
---
status: "published"
label: 'Credits Based Billing'
title: 'Implement Credit-Based Billing for AI SaaS Apps'
order: 7
description: 'Build a credit/token system for your AI SaaS. Learn how to add credits tables, consumption tracking, and automatic recharge on subscription renewal in Makerkit.'
---
Credit-based billing charges users based on tokens or credits consumed rather than time. This model is common in AI SaaS applications where users pay for API calls, generated content, or compute time.
Makerkit doesn't include credit-based billing out of the box, but you can implement it using subscriptions plus custom database tables. This guide shows you how.
## Architecture Overview
```
User subscribes → Credits allocated → User consumes credits → Invoice paid → Credits recharged
```
Components:
1. **`plans` table**: Maps subscription variants to credit amounts
2. **`credits` table**: Tracks available credits per account
3. **Database functions**: Check and consume credits
4. **Webhook handler**: Recharge credits on subscription renewal
## Step 1: Create the Plans Table
Store the credit allocation for each plan variant:
```sql
CREATE TABLE public.plans (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
variant_id TEXT NOT NULL UNIQUE,
tokens INTEGER NOT NULL
);
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
-- Allow authenticated users to read plans
CREATE POLICY read_plans ON public.plans
FOR SELECT TO authenticated
USING (true);
-- Insert your plans
INSERT INTO public.plans (name, variant_id, tokens) VALUES
('Starter', 'price_starter_monthly', 1000),
('Pro', 'price_pro_monthly', 10000),
('Enterprise', 'price_enterprise_monthly', 100000);
```
The `variant_id` should match the line item ID in your billing schema (e.g., Stripe Price ID).
## Step 2: Create the Credits Table
Track available credits per account:
```sql
CREATE TABLE public.credits (
account_id UUID PRIMARY KEY REFERENCES public.accounts(id) ON DELETE CASCADE,
tokens INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE public.credits ENABLE ROW LEVEL SECURITY;
-- Users can read their own credits
CREATE POLICY read_credits ON public.credits
FOR SELECT TO authenticated
USING (account_id = (SELECT auth.uid()));
-- Only service role can modify credits
-- No INSERT/UPDATE/DELETE policies for authenticated users
```
{% alert type="warning" title="Security: Restrict credit modifications" %}
Users should only read their credits. All modifications should go through the service role (admin client) to prevent manipulation.
{% /alert %}
## Step 3: Create Helper Functions
### Check if account has enough credits
```sql
CREATE OR REPLACE FUNCTION public.has_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS BOOLEAN
SET search_path = ''
AS $$
BEGIN
RETURN (
SELECT tokens >= p_tokens
FROM public.credits
WHERE account_id = p_account_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.has_credits TO authenticated, service_role;
```
### Consume credits
```sql
CREATE OR REPLACE FUNCTION public.consume_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS BOOLEAN
SET search_path = ''
AS $$
DECLARE
v_current_tokens INTEGER;
BEGIN
-- Get current balance with row lock
SELECT tokens INTO v_current_tokens
FROM public.credits
WHERE account_id = p_account_id
FOR UPDATE;
-- Check if enough credits
IF v_current_tokens IS NULL OR v_current_tokens < p_tokens THEN
RETURN FALSE;
END IF;
-- Deduct credits
UPDATE public.credits
SET tokens = tokens - p_tokens,
updated_at = NOW()
WHERE account_id = p_account_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.consume_credits TO service_role;
```
### Add credits (for recharges)
```sql
CREATE OR REPLACE FUNCTION public.add_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS VOID
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.credits (account_id, tokens)
VALUES (p_account_id, p_tokens)
ON CONFLICT (account_id)
DO UPDATE SET
tokens = public.credits.tokens + p_tokens,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.add_credits TO service_role;
```
### Reset credits (for subscription renewal)
```sql
CREATE OR REPLACE FUNCTION public.reset_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS VOID
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.credits (account_id, tokens)
VALUES (p_account_id, p_tokens)
ON CONFLICT (account_id)
DO UPDATE SET
tokens = p_tokens,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.reset_credits TO service_role;
```
## Step 4: Consume Credits in Your Application
When a user performs an action that costs credits:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export async function consumeApiCredits(
accountId: string,
tokensRequired: number
) {
const adminClient = getSupabaseServerAdminClient();
// Consume credits atomically
const { data: success, error } = await adminClient.rpc('consume_credits', {
p_account_id: accountId,
p_tokens: tokensRequired,
});
if (error) {
throw new Error(`Failed to consume credits: ${error.message}`);
}
if (!success) {
throw new Error('Insufficient credits');
}
return true;
}
```
### Example: AI API Route
```tsx
// app/api/ai/generate/route.ts
import { NextResponse } from 'next/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
const TOKENS_PER_REQUEST = 10;
export async function POST(request: Request) {
const client = getSupabaseServerClient();
const adminClient = getSupabaseServerAdminClient();
// Get current user's account
const { data: { user } } = await client.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check credits before processing
const { data: hasCredits } = await client.rpc('has_credits', {
p_account_id: user.id,
p_tokens: TOKENS_PER_REQUEST,
});
if (!hasCredits) {
return NextResponse.json(
{ error: 'Insufficient credits', code: 'INSUFFICIENT_CREDITS' },
{ status: 402 }
);
}
try {
// Call AI API
const { prompt } = await request.json();
const result = await callAIService(prompt);
// Consume credits after successful response
await adminClient.rpc('consume_credits', {
p_account_id: user.id,
p_tokens: TOKENS_PER_REQUEST,
});
return NextResponse.json({ result });
} catch (error) {
return NextResponse.json(
{ error: 'Generation failed' },
{ status: 500 }
);
}
}
```
## Step 5: Display Credits in UI
Create a component to show remaining credits:
```tsx
// components/credits-display.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function CreditsDisplay({ accountId }: { accountId: string }) {
const client = useSupabase();
const { data: credits, isLoading } = useQuery({
queryKey: ['credits', accountId],
queryFn: async () => {
const { data, error } = await client
.from('credits')
.select('tokens')
.eq('account_id', accountId)
.single();
if (error) throw error;
return data?.tokens ?? 0;
},
});
if (isLoading) return <span>Loading...</span>;
return (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Credits:</span>
<span className="font-medium">{credits?.toLocaleString()}</span>
</div>
);
}
```
## Step 6: Recharge Credits on Subscription Renewal
Extend the webhook handler to recharge credits when an invoice is paid:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { getPlanTypesMap } from '@kit/billing';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import billingConfig from '~/config/billing.config';
export const POST = enhanceRouteHandler(
async ({ request }) => {
const provider = billingConfig.provider;
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
const service = await getBillingEventHandlerService(
() => adminClient,
provider,
getPlanTypesMap(billingConfig),
);
try {
await service.handleWebhookEvent(request, {
onInvoicePaid: async (data) => {
const accountId = data.target_account_id;
const variantId = data.line_items[0]?.variant_id;
if (!variantId) {
logger.warn({ accountId }, 'No variant ID in invoice');
return;
}
// Get token allocation for this plan
const { data: plan, error: planError } = await adminClient
.from('plans')
.select('tokens')
.eq('variant_id', variantId)
.single();
if (planError || !plan) {
logger.error({ variantId, planError }, 'Plan not found');
return;
}
// Reset credits to plan allocation
const { error: creditError } = await adminClient.rpc('reset_credits', {
p_account_id: accountId,
p_tokens: plan.tokens,
});
if (creditError) {
logger.error({ accountId, creditError }, 'Failed to reset credits');
throw creditError;
}
logger.info(
{ accountId, tokens: plan.tokens },
'Credits recharged on invoice payment'
);
},
onCheckoutSessionCompleted: async (subscription) => {
// Also allocate credits on initial subscription
const accountId = subscription.target_account_id;
const variantId = subscription.line_items[0]?.variant_id;
if (!variantId) return;
const { data: plan } = await adminClient
.from('plans')
.select('tokens')
.eq('variant_id', variantId)
.single();
if (plan) {
await adminClient.rpc('reset_credits', {
p_account_id: accountId,
p_tokens: plan.tokens,
});
logger.info(
{ accountId, tokens: plan.tokens },
'Initial credits allocated'
);
}
},
});
return new Response('OK', { status: 200 });
} catch (error) {
logger.error({ error }, 'Webhook failed');
return new Response('Failed', { status: 500 });
}
},
{ auth: false }
);
```
## Step 7: Use Credits in RLS Policies (Optional)
Gate features based on credit balance:
```sql
-- Only allow creating tasks if user has credits
CREATE POLICY tasks_insert_with_credits ON public.tasks
FOR INSERT TO authenticated
WITH CHECK (
public.has_credits((SELECT auth.uid()), 1)
);
-- Only allow API calls if user has credits
CREATE POLICY api_calls_with_credits ON public.api_logs
FOR INSERT TO authenticated
WITH CHECK (
public.has_credits(account_id, 1)
);
```
## Testing
1. Create a subscription in test mode
2. Verify initial credits are allocated
3. Consume some credits via your API
4. Trigger a subscription renewal (Stripe: `stripe trigger invoice.paid`)
5. Verify credits are recharged
## Common Patterns
### Rollover Credits
To allow unused credits to roll over:
```sql
-- In onInvoicePaid, add instead of reset:
await adminClient.rpc('add_credits', {
p_account_id: accountId,
p_tokens: plan.tokens,
});
```
### Credit Expiration
Add an expiration date to credits:
```sql
ALTER TABLE public.credits ADD COLUMN expires_at TIMESTAMPTZ;
-- Check expiration in has_credits function
CREATE OR REPLACE FUNCTION public.has_credits(...)
-- Add: AND (expires_at IS NULL OR expires_at > NOW())
```
### Usage Tracking
Track credit consumption for analytics:
```sql
CREATE TABLE public.credit_transactions (
id SERIAL PRIMARY KEY,
account_id UUID REFERENCES accounts(id),
amount INTEGER NOT NULL,
type TEXT NOT NULL, -- 'consume', 'recharge', 'bonus'
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Billing architecture
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Webhook event handling
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Alternative usage-based billing
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) - Creating Postgres functions

View File

@@ -0,0 +1,638 @@
---
status: "published"
label: "Custom Integration"
title: "How to create a custom billing integration in Makerkit"
order: 11
description: "Learn how to create a custom billing integration in Makerkit"
---
This guide explains how to create billing integration plugins for the Makerkit SaaS platform to allow you to use a custom billing provider.
{% sequence title="How to create a custom billing integration in Makerkit" description="Learn how to create a custom billing integration in Makerkit" %}
[Architecture Overview](#architecture-overview)
[Package Structure](#package-structure)
[Core Interface Implementation](#core-interface-implementation)
[Environment Configuration](#environment-configuration)
[Billing Strategy Service](#billing-strategy-service)
[Webhook Handler Service](#webhook-handler-service)
[Client-Side Components](#client-side-components)
[Registration and Integration](#registration-and-integration)
[Testing Strategy](#testing-strategy)
[Security Best Practices](#security-best-practices)
[Example Implementation](#example-implementation)
{% /sequence %}
## Architecture Overview
The Makerkit billing system uses a plugin-based architecture that allows multiple billing providers to coexist. The system consists of:
### Core Components
1. **Billing Strategy Provider Service** - Abstract interface for billing operations
2. **Billing Webhook Handler Service** - Abstract interface for webhook processing
3. **Registry System** - Dynamic loading and management of providers
4. **Schema Validation** - Type-safe configuration and data validation
### Provider Structure
Each billing provider is implemented as a separate package under `packages/{provider-name}/` with:
- **Server-side services** - Billing operations and webhook handling
- **Client-side components** - Checkout flows and UI integration
- **Configuration schemas** - Environment variable validation
- **SDK abstractions** - Provider-specific API integrations
### Data Flow
```
Client Request → Registry → Provider Service → External API → Webhook → Handler → Database
```
## Creating a package
You can create a new package for your billing provider by running the following command:
```bash
pnpm turbo gen package
```
This will create a new package in the packages directory, ready to use. You can move this anywhere in the `packages` directory, but we recommend keeping it in the `packages/billing` directory.
## Package Structure
Once we finalize the package structure, your structure should look like this:
```
packages/{provider-name}/
├── package.json
├── tsconfig.json
├── index.ts
└── src/
├── index.ts
├── components/
│ ├── index.ts
│ └── {provider}-checkout.tsx
├── constants/
│ └── {provider}-events.ts
├── schema/
│ ├── {provider}-client-env.schema.ts
│ └── {provider}-server-env.schema.ts
└── services/
├── {provider}-billing-strategy.service.ts
├── {provider}-webhook-handler.service.ts
├── {provider}-sdk.ts
└── create-{provider}-billing-portal-session.ts
```
### package.json Template
```json
{
"name": "@kit/{provider-name}",
"private": true,
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"typesVersions": {
"*": {
"*": ["src/*"]
}
},
"dependencies": {
"{provider-sdk}": "^x.x.x"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.13",
"next": "16.0.0",
"react": "19.1.1",
"zod": "^3.25.74"
}
}
```
## Core Interface Implementation
### BillingStrategyProviderService
This abstract class defines the contract for all billing operations:
```typescript {% title="packages/{provider}/src/services/{provider}-billing-strategy.service.ts" %}
import { BillingStrategyProviderService } from '@kit/billing';
export class YourProviderBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.{provider}';
async createCheckoutSession(params) {
// Implementation
}
async createBillingPortalSession(params) {
// Implementation
}
async cancelSubscription(params) {
// Implementation
}
async retrieveCheckoutSession(params) {
// Implementation
}
async reportUsage(params) {
// Implementation (if supported)
}
async queryUsage(params) {
// Implementation (if supported)
}
async updateSubscriptionItem(params) {
// Implementation
}
async getPlanById(planId: string) {
// Implementation
}
async getSubscription(subscriptionId: string) {
// Implementation
}
}
```
### BillingWebhookHandlerService
This abstract class handles webhook events from the billing provider:
```typescript {% title="packages/{provider}/src/services/{provider}-webhook-handler.service.ts" %}
import { BillingWebhookHandlerService } from '@kit/billing';
export class YourProviderWebhookHandlerService
implements BillingWebhookHandlerService
{
private readonly provider = '{provider}' as const;
private readonly namespace = 'billing.{provider}';
async verifyWebhookSignature(request: Request) {
// Verify signature using provider's SDK
// Throw error if invalid
}
async handleWebhookEvent(event: unknown, params) {
// Route events to appropriate handlers
switch (event.type) {
case 'subscription.created':
return this.handleSubscriptionCreated(event, params);
case 'subscription.updated':
return this.handleSubscriptionUpdated(event, params);
// ... other events
}
}
}
```
## Environment Configuration
### Server Environment Schema
Create schemas for server-side configuration:
```typescript {% title="packages/{provider}/src/schema/{provider}-server-env.schema.ts" %}
// src/schema/{provider}-server-env.schema.ts
import * as z from 'zod';
export const YourProviderServerEnvSchema = z.object({
apiKey: z.string({
description: '{Provider} API key for server-side operations',
required_error: '{PROVIDER}_API_KEY is required',
}),
webhooksSecret: z.string({
description: '{Provider} webhook secret for verifying signatures',
required_error: '{PROVIDER}_WEBHOOK_SECRET is required',
}),
});
export type YourProviderServerEnv = z.infer<typeof YourProviderServerEnvSchema>;
```
### Client Environment Schema
Create schemas for client-side configuration:
```typescript {% title="packages/{provider}/src/schema/{provider}-client-env.schema.ts" %}
// src/schema/{provider}-client-env.schema.ts
import * as z from 'zod';
export const YourProviderClientEnvSchema = z.object({
publicKey: z.string({
description: '{Provider} public key for client-side operations',
required_error: 'NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY is required',
}),
});
export type YourProviderClientEnv = z.infer<typeof YourProviderClientEnvSchema>;
```
## Billing Strategy Service
### Implementation Example
{% alert type="warning" title="This is an abstract example" %}
The "client" class in the example below is not a real class, it's just an example of how to implement the BillingStrategyProviderService interface. You should refer to the SDK of your billing provider to implement the actual methods.
{% /alert %}
Here's a detailed implementation pattern based on the Paddle service:
```typescript
import 'server-only';
import * as z from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { createYourProviderClient } from './your-provider-sdk';
export class YourProviderBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.{provider}';
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const logger = await getLogger();
const client = await createYourProviderClient();
const ctx = {
name: this.namespace,
customerId: params.customerId,
accountId: params.accountId,
};
logger.info(ctx, 'Creating checkout session...');
try {
const response = await client.checkout.create({
customer: {
id: params.customerId,
email: params.customerEmail,
},
lineItems: params.plan.lineItems.map((item) => ({
priceId: item.id,
quantity: 1,
})),
successUrl: params.returnUrl,
metadata: {
accountId: params.accountId,
},
});
logger.info(ctx, 'Checkout session created successfully');
return {
checkoutToken: response.id,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to create checkout session');
throw new Error('Failed to create checkout session');
}
}
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
const logger = await getLogger();
const client = await createYourProviderClient();
const ctx = {
name: this.namespace,
subscriptionId: params.subscriptionId,
};
logger.info(ctx, 'Cancelling subscription...');
try {
await client.subscriptions.cancel(params.subscriptionId, {
immediate: params.invoiceNow ?? true,
});
logger.info(ctx, 'Subscription cancelled successfully');
return { success: true };
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to cancel subscription');
throw new Error('Failed to cancel subscription');
}
}
// Implement other required methods...
}
```
### SDK Client Wrapper
Create a reusable SDK client:
```typescript
// src/services/{provider}-sdk.ts
import 'server-only';
import { YourProviderServerEnvSchema } from '../schema/{provider}-server-env.schema';
export async function createYourProviderClient() {
// parse the environment variables
const config = YourProviderServerEnvSchema.parse({
apiKey: process.env.{PROVIDER}_API_KEY,
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
});
return new YourProviderSDK({
apiKey: config.apiKey,
});
}
```
## Webhook Handler Service
### Implementation Pattern
```typescript
import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { createYourProviderClient } from './your-provider-sdk';
export class YourProviderWebhookHandlerService
implements BillingWebhookHandlerService
{
constructor(private readonly planTypesMap: PlanTypeMap) {}
private readonly provider = '{provider}' as const;
private readonly namespace = 'billing.{provider}';
async verifyWebhookSignature(request: Request) {
const body = await request.clone().text();
const signature = request.headers.get('{provider}-signature');
if (!signature) {
throw new Error('Missing {provider} signature');
}
const { webhooksSecret } = YourProviderServerEnvSchema.parse({
apiKey: process.env.{PROVIDER}_API_KEY,
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
environment: process.env.{PROVIDER}_ENVIRONMENT || 'sandbox',
});
const client = await createYourProviderClient();
try {
const eventData = await client.webhooks.verify(body, signature, webhooksSecret);
if (!eventData) {
throw new Error('Invalid signature');
}
return eventData;
} catch (error) {
throw new Error(`Webhook signature verification failed: ${error}`);
}
}
async handleWebhookEvent(event: unknown, params) {
const logger = await getLogger();
switch (event.type) {
case 'checkout.session.completed': {
return this.handleCheckoutCompleted(event, params.onCheckoutSessionCompleted);
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
return this.handleSubscriptionUpdated(event, params.onSubscriptionUpdated);
}
case 'customer.subscription.deleted': {
return this.handleSubscriptionDeleted(event, params.onSubscriptionDeleted);
}
default: {
logger.info(
{
name: this.namespace,
eventType: event.type,
},
'Unhandled webhook event type',
);
if (params.onEvent) {
await params.onEvent(event);
}
}
}
}
private async handleCheckoutCompleted(event, onCheckoutSessionCompleted) {
// Extract subscription/order data from event
// Transform to standard format
// Call onCheckoutSessionCompleted with normalized data
}
// Implement other event handlers...
}
```
## Client-Side Components
### Checkout Component
Create a React component for the checkout flow:
```typescript
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { YourProviderClientEnvSchema } from '../schema/{provider}-client-env.schema';
interface YourProviderCheckoutProps {
onClose?: () => void;
checkoutToken: string;
}
const config = YourProviderClientEnvSchema.parse({
publicKey: process.env.NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY,
environment: process.env.NEXT_PUBLIC_{PROVIDER}_ENVIRONMENT || 'sandbox',
});
export function YourProviderCheckout({
onClose,
checkoutToken,
}: YourProviderCheckoutProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function initializeCheckout() {
try {
// Initialize provider's JavaScript SDK
const { YourProviderSDK } = await import('{provider}-js-sdk');
const sdk = new YourProviderSDK({
publicKey: config.publicKey,
environment: config.environment,
});
// Open checkout
await sdk.redirectToCheckout({
sessionId: checkoutToken,
successUrl: window.location.href,
cancelUrl: window.location.href,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Checkout failed';
setError(errorMessage);
onClose?.();
}
}
void initializeCheckout();
}, [checkoutToken, onClose]);
if (error) {
throw new Error(error);
}
return null; // Provider handles the UI
}
```
## Registration and Integration
### Register Billing Strategy
Add your provider to the billing strategy registry:
```typescript
// packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts
// Register {Provider} billing strategy
billingStrategyRegistry.register('{provider}', async () => {
const { YourProviderBillingStrategyService } = await import('@kit/{provider}');
return new YourProviderBillingStrategyService();
});
```
### Register Webhook Handler
Add your provider to the webhook handler factory:
```typescript
// packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts
// Register {Provider} webhook handler
billingWebhookHandlerRegistry.register('{provider}', async () => {
const { YourProviderWebhookHandlerService } = await import('@kit/{provider}');
return new YourProviderWebhookHandlerService(planTypesMap);
});
```
### Update Package Exports
Export your services from the main index file:
```typescript
// packages/{provider}/src/index.ts
export { YourProviderBillingStrategyService } from './services/{provider}-billing-strategy.service';
export { YourProviderWebhookHandlerService } from './services/{provider}-webhook-handler.service';
export * from './components';
export * from './constants/{provider}-events';
export {
YourProviderClientEnvSchema,
type YourProviderClientEnv,
} from './schema/{provider}-client-env.schema';
export {
YourProviderServerEnvSchema,
type YourProviderServerEnv,
} from './schema/{provider}-server-env.schema';
```
## Security Best Practices
### Environment Variables
1. **Never expose secrets in client-side code**
2. **Use different credentials for sandbox and production**
3. **Validate all environment variables with Zod schemas**
4. **Store secrets securely (e.g., in environment variables or secret managers)**
### Webhook Security
1. **Always verify webhook signatures**
2. **Use HTTPS endpoints for webhooks**
3. **Log security events for monitoring**
### Data Handling
1. **Validate all incoming data with Zod schemas**
2. **Sanitize user inputs**
3. **Never log sensitive information (API keys, customer data)**
4. **Use structured logging with appropriate log levels**
### Error Handling
1. **Don't expose internal errors to users**
2. **Log errors with sufficient context for debugging**
3. **Implement proper error boundaries in React components**
4. **Handle rate limiting and API errors gracefully**
## Example Implementation
For a complete reference implementation, see the Stripe integration at `packages/billing/stripe/`. Key files to study:
- `src/services/stripe-billing-strategy.service.ts` - Complete billing strategy implementation
- `src/services/stripe-webhook-handler.service.ts` - Webhook handling patterns
- `src/components/stripe-embedded-checkout.tsx` - Client-side checkout component
- `src/schema/` - Environment configuration schemas
Also take a look at the Lemon Squeezy integration at `packages/billing/lemon-squeezy/` or the Paddle integration at `packages/plugins/paddle/` (in the plugins repository)
## Conclusion
**Important:** Different providers have different APIs, so the implementation will be different for each provider.
Following this guide, you should be able to create a robust billing integration that:
- Implements all required interfaces correctly
- Handles errors gracefully and securely
- Provides a good user experience
- Follows established patterns and best practices
- Integrates seamlessly with the existing billing system
Remember to:
1. Test thoroughly with the provider's sandbox environment
2. Follow security best practices throughout development
3. Document any provider-specific requirements or limitations
4. Consider edge cases and error scenarios
5. Validate your implementation against the existing test suite

View File

@@ -0,0 +1,266 @@
---
status: "published"
label: "Lemon Squeezy"
title: "Configure Lemon Squeezy Billing for Your Next.js SaaS"
order: 3
description: "Complete guide to setting up Lemon Squeezy payments in Makerkit. Lemon Squeezy is a Merchant of Record that handles global tax compliance, billing, and payments for your SaaS."
---
Lemon Squeezy is a Merchant of Record (MoR), meaning they handle all billing complexity for you: VAT, sales tax, invoicing, and compliance across 100+ countries. You receive payouts minus their fees.
## Why Choose Lemon Squeezy?
**Pros:**
- Automatic global tax compliance (VAT, GST, sales tax)
- No need to register for tax collection in different countries
- Simpler setup than Stripe for international sales
- Built-in license key generation (great for desktop apps)
- Lower complexity for solo founders
**Cons:**
- One line item per plan (no mixing flat + metered + per-seat)
- Less flexibility than Stripe
- Higher fees than Stripe in some regions
## Prerequisites
1. Create a [Lemon Squeezy account](https://lemonsqueezy.com)
2. Create a Store in your Lemon Squeezy dashboard
3. Create Products and Variants for your pricing plans
4. Set up a webhook endpoint
## Step 1: Environment Variables
Add these variables to your `.env.local`:
```bash
LEMON_SQUEEZY_SECRET_KEY=your_api_key_here
LEMON_SQUEEZY_SIGNING_SECRET=your_webhook_signing_secret
LEMON_SQUEEZY_STORE_ID=your_store_id
```
| Variable | Description | Where to Find |
|----------|-------------|---------------|
| `LEMON_SQUEEZY_SECRET_KEY` | API key for server-side calls | Settings → API |
| `LEMON_SQUEEZY_SIGNING_SECRET` | Webhook signature verification | Settings → Webhooks |
| `LEMON_SQUEEZY_STORE_ID` | Your store's numeric ID | Settings → Stores |
{% alert type="error" title="Keep secrets secure" %}
Add these to `.env.local` only. Never commit them to your repository or add them to `.env`.
{% /alert %}
## Step 2: Configure Billing Provider
Set Lemon Squeezy as your billing provider in the environment:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy
```
And update the database:
```sql
UPDATE public.config SET billing_provider = 'lemon-squeezy';
```
## Step 3: Create Products in Lemon Squeezy
1. Go to your Lemon Squeezy Dashboard → Products
2. Click **New Product**
3. Configure your product:
- **Name**: "Pro Plan", "Starter Plan", etc.
- **Pricing**: Choose subscription or one-time
- **Variant**: Create variants for different billing intervals
4. Copy the **Variant ID** (not Product ID) for your billing schema
The Variant ID looks like `123456` (numeric). This goes in your line item's `id` field.
## Step 4: Update Billing Schema
Lemon Squeezy has a key limitation: **one line item per plan**. You cannot mix flat, per-seat, and metered billing in a single plan.
```tsx
import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: 'lemon-squeezy',
products: [
{
id: 'pro',
name: 'Pro',
description: 'For professionals',
currency: 'USD',
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: '123456', // Lemon Squeezy Variant ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
// Cannot add more line items with Lemon Squeezy!
],
},
],
},
],
});
```
{% alert type="warning" title="Single line item only" %}
The schema validation will fail if you add multiple line items with Lemon Squeezy. This is a platform limitation.
{% /alert %}
## Step 5: Configure Webhooks
### Local Development
Lemon Squeezy requires a public URL for webhooks. Use a tunneling service like ngrok:
```bash
# Install ngrok
npm install -g ngrok
# Expose your local server
ngrok http 3000
```
Copy the ngrok URL (e.g., `https://abc123.ngrok.io`).
### Create Webhook in Lemon Squeezy
1. Go to Settings → Webhooks
2. Click **Add Webhook**
3. Configure:
- **URL**: `https://your-ngrok-url.ngrok.io/api/billing/webhook` (dev) or `https://yourdomain.com/api/billing/webhook` (prod)
- **Secret**: Generate a secure secret and save it as `LEMON_SQUEEZY_SIGNING_SECRET`
4. Select these events:
- `order_created`
- `subscription_created`
- `subscription_updated`
- `subscription_expired`
5. Click **Save**
### Production Webhooks
For production, replace the ngrok URL with your actual domain:
```
https://yourdomain.com/api/billing/webhook
```
## Metered Usage with Lemon Squeezy
Lemon Squeezy handles metered billing differently than Stripe. Usage applies to the entire subscription, not individual line items.
### Setup Fee + Metered Usage
Use the `setupFee` property for a flat base charge plus usage-based pricing:
```tsx
{
id: 'api-monthly',
name: 'API Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: '123456',
name: 'API Access',
cost: 0,
type: 'metered',
unit: 'requests',
setupFee: 10, // $10 base fee
tiers: [
{ upTo: 1000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
],
},
],
}
```
The setup fee is charged once when the subscription is created.
### Reporting Usage
Report usage using the billing API:
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
async function reportUsage(subscriptionItemId: string, quantity: number) {
const service = createBillingGatewayService('lemon-squeezy');
return service.reportUsage({
id: subscriptionItemId, // From subscription_items table
usage: {
quantity,
action: 'increment',
},
});
}
```
See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for complete implementation details.
## Testing
### Test Mode
Lemon Squeezy has a test mode. Enable it in your dashboard under Settings → Test Mode.
Test mode uses separate products and variants, so create test versions of your products.
### Test Cards
In test mode, use these card numbers:
- **Success**: `4242 4242 4242 4242`
- **Decline**: `4000 0000 0000 0002`
Any future expiry date and any 3-digit CVC will work.
## Common Issues
### Webhook signature verification failed
1. Check that `LEMON_SQUEEZY_SIGNING_SECRET` matches the secret in your Lemon Squeezy webhook settings
2. Ensure the raw request body is used for verification (not parsed JSON)
3. Verify the webhook URL is correct
### Subscription not created
1. Check webhook logs in Lemon Squeezy dashboard
2. Verify the `order_created` event is enabled
3. Check your application logs for errors
### Multiple line items error
Lemon Squeezy only supports one line item per plan. Restructure your pricing to use a single line item, or use Stripe for more complex pricing models.
## Testing Checklist
Before going live:
- [ ] Create test products in Lemon Squeezy test mode
- [ ] Test subscription checkout with test card
- [ ] Verify subscription appears in user's billing section
- [ ] Test subscription cancellation
- [ ] Verify webhook events are processed correctly
- [ ] Test with failing card to verify error handling
- [ ] Switch to production products and webhook URL
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing implementation

View File

@@ -0,0 +1,399 @@
---
status: "published"
label: "Metered Usage"
title: "Implement Metered Usage Billing for APIs and SaaS"
order: 5
description: "Charge customers based on actual usage with metered billing. Learn how to configure usage-based pricing and report usage to Stripe or Lemon Squeezy in your Next.js SaaS."
---
Metered usage billing charges customers based on consumption (API calls, storage, compute time, etc.). You report usage throughout the billing period, and the provider calculates charges at invoice time.
## How It Works
1. Customer subscribes to a metered plan
2. Your application tracks usage and reports it to the billing provider
3. At the end of each billing period, the provider invoices based on total usage
4. Makerkit stores usage data in `subscription_items` for reference
## Schema Configuration
Define a metered line item in your billing schema:
```tsx {% title="apps/web/config/billing.config.ts" %}
{
id: 'api-plan',
name: 'API Plan',
description: 'Pay only for what you use',
currency: 'USD',
plans: [
{
id: 'api-monthly',
name: 'API Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_api_requests', // Provider Price ID
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 }, // First 1000 free
{ upTo: 10000, cost: 0.001 }, // $0.001/request
{ upTo: 'unlimited', cost: 0.0005 }, // Volume discount
],
},
],
},
],
}
```
The `tiers` define progressive pricing. The last tier should always have `upTo: 'unlimited'`.
## Provider Differences
Stripe and Lemon Squeezy handle metered billing differently:
| Feature | Stripe | Lemon Squeezy |
|---------|--------|---------------|
| Report to | Customer ID + meter name | Subscription item ID |
| Usage action | Implicit increment | Explicit `increment` or `set` |
| Multiple meters | Yes (per customer) | No (per subscription) |
| Real-time usage | Yes (Billing Meter) | Limited |
## Stripe Implementation
Stripe uses [Billing Meters](https://docs.stripe.com/billing/subscriptions/usage-based/implementation-guide) for metered billing.
### 1. Create a Meter in Stripe
1. Go to Stripe Dashboard → Billing → Meters
2. Click **Create meter**
3. Configure:
- **Event name**: `api_requests` (you'll use this in your code)
- **Aggregation**: Sum (most common)
- **Value key**: `value` (default)
### 2. Create a Metered Price
1. Go to Products → Your Product
2. Add a price with **Usage-based** pricing
3. Select your meter
4. Configure tier pricing
### 3. Report Usage
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function reportApiUsage(accountId: string, requestCount: number) {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
// Get customer ID for this account
const customerId = await api.getCustomerId(accountId);
if (!customerId) {
throw new Error('No billing customer found');
}
const service = createBillingGatewayService('stripe');
await service.reportUsage({
id: customerId,
eventName: 'api_requests', // Matches your Stripe meter
usage: {
quantity: requestCount,
},
});
}
```
### 4. Integrate with Your API
```tsx
// app/api/data/route.ts
import { NextResponse } from 'next/server';
import { reportApiUsage } from '~/lib/billing';
export async function GET(request: Request) {
const accountId = getAccountIdFromRequest(request);
// Process the request
const data = await fetchData();
// Report usage (fire and forget or await)
reportApiUsage(accountId, 1).catch(console.error);
return NextResponse.json(data);
}
```
For high-volume APIs, batch usage reports:
```tsx
// lib/usage-buffer.ts
const usageBuffer = new Map<string, number>();
export function bufferUsage(accountId: string, quantity: number) {
const current = usageBuffer.get(accountId) ?? 0;
usageBuffer.set(accountId, current + quantity);
}
// Flush every minute
setInterval(async () => {
for (const [accountId, quantity] of usageBuffer.entries()) {
if (quantity > 0) {
await reportApiUsage(accountId, quantity);
usageBuffer.set(accountId, 0);
}
}
}, 60000);
```
## Lemon Squeezy Implementation
Lemon Squeezy requires reporting to a subscription item ID.
### 1. Create a Usage-Based Product
1. Go to Products → New Product
2. Select **Usage-based** pricing
3. Configure your pricing tiers
### 2. Get the Subscription Item ID
After a customer subscribes, find their subscription item:
```tsx
const { data: subscriptionItem } = await supabase
.from('subscription_items')
.select('id')
.eq('subscription_id', subscriptionId)
.eq('type', 'metered')
.single();
```
### 3. Report Usage
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function reportUsageLS(
accountId: string,
quantity: number
) {
const supabase = getSupabaseServerClient();
// Get subscription and item
const { data: subscription } = await supabase
.from('subscriptions')
.select('id')
.eq('account_id', accountId)
.eq('status', 'active')
.single();
if (!subscription) {
throw new Error('No active subscription');
}
const { data: item } = await supabase
.from('subscription_items')
.select('id')
.eq('subscription_id', subscription.id)
.eq('type', 'metered')
.single();
if (!item) {
throw new Error('No metered item found');
}
const service = createBillingGatewayService('lemon-squeezy');
await service.reportUsage({
id: item.id,
usage: {
quantity,
action: 'increment', // or 'set' to replace
},
});
}
```
### Lemon Squeezy Usage Actions
- **`increment`**: Add to existing usage (default)
- **`set`**: Replace the current usage value
```tsx
// Increment by 100
await service.reportUsage({
id: itemId,
usage: { quantity: 100, action: 'increment' },
});
// Set total to 500 (overwrites previous)
await service.reportUsage({
id: itemId,
usage: { quantity: 500, action: 'set' },
});
```
## Querying Usage
### Stripe
```tsx
const usage = await service.queryUsage({
id: 'meter_xxx', // Stripe Meter ID
customerId: 'cus_xxx',
filter: {
startTime: Math.floor(Date.now() / 1000) - 86400 * 30,
endTime: Math.floor(Date.now() / 1000),
},
});
console.log(`Total usage: ${usage.value}`);
```
### Lemon Squeezy
```tsx
const usage = await service.queryUsage({
id: 'sub_item_xxx',
customerId: 'cus_xxx',
filter: {
page: 1,
size: 100,
},
});
```
## Combining Metered + Flat Pricing (Stripe Only)
Charge a base fee plus usage:
```tsx
lineItems: [
{
id: 'price_base',
name: 'Platform Access',
cost: 29,
type: 'flat',
},
{
id: 'price_api',
name: 'API Calls',
cost: 0,
type: 'metered',
unit: 'calls',
tiers: [
{ upTo: 10000, cost: 0 }, // Included in base
{ upTo: 'unlimited', cost: 0.001 },
],
},
]
```
## Setup Fee with Metered Usage (Lemon Squeezy)
Lemon Squeezy supports a one-time setup fee:
```tsx
{
id: '123456',
name: 'API Access',
cost: 0,
type: 'metered',
unit: 'requests',
setupFee: 49, // One-time charge on subscription creation
tiers: [
{ upTo: 1000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
],
}
```
## Displaying Usage to Users
Show customers their current usage:
```tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function UsageDisplay({ accountId }: { accountId: string }) {
const { data: usage, isLoading } = useQuery({
queryKey: ['usage', accountId],
queryFn: () => fetch(`/api/usage/${accountId}`).then(r => r.json()),
refetchInterval: 60000, // Update every minute
});
if (isLoading) return <span>Loading usage...</span>;
return (
<div className="space-y-2">
<div className="flex justify-between">
<span>API Requests</span>
<span>{usage?.requests?.toLocaleString() ?? 0}</span>
</div>
<div className="h-2 bg-muted rounded">
<div
className="h-full bg-primary rounded"
style={{ width: `${Math.min(100, (usage?.requests / 10000) * 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{usage?.requests > 10000
? `${((usage.requests - 10000) * 0.001).toFixed(2)} overage`
: `${10000 - usage?.requests} free requests remaining`}
</p>
</div>
);
}
```
## Testing Metered Billing
1. **Create a metered subscription**
2. **Report some usage:**
```bash
# Stripe CLI
stripe billing_meters create_event \
--event-name api_requests \
--payload customer=cus_xxx,value=100
```
3. **Check usage in dashboard**
4. **Create an invoice to see charges:**
```bash
stripe invoices create --customer cus_xxx
stripe invoices finalize inv_xxx
```
## Common Issues
### Usage not appearing
1. Verify the meter event name matches
2. Check that customer ID is correct
3. Look for errors in your application logs
4. Check Stripe Dashboard → Billing → Meters → Events
### Incorrect charges
1. Verify your tier configuration in Stripe matches your schema
2. Check if using graduated vs. volume pricing
3. Review the invoice line items in Stripe Dashboard
## Related Documentation
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing
- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Full API reference
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Alternative usage model
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration

View File

@@ -0,0 +1,387 @@
---
status: "published"
label: "One-Off Payments"
title: "Configure One-Off Payments for Lifetime Deals and Add-Ons"
order: 8
description: "Implement one-time purchases in your SaaS for lifetime access, add-ons, or credits. Learn how to configure one-off payments with Stripe, Lemon Squeezy, or Paddle in Makerkit."
---
One-off payments are single charges for non-recurring products: lifetime access, add-ons, credit packs, or physical goods. Unlike subscriptions, one-off purchases are stored in the `orders` table.
## Use Cases
- **Lifetime access**: One-time purchase for perpetual access
- **Add-ons**: Additional features or capacity
- **Credit packs**: Buy credits/tokens in bulk
- **Digital products**: Templates, courses, ebooks
- **One-time services**: Setup fees, consulting
## Schema Configuration
Define a one-time payment plan:
```tsx {% title="apps/web/config/billing.config.ts" %}
{
id: 'lifetime',
name: 'Lifetime Access',
description: 'Pay once, access forever',
currency: 'USD',
badge: 'Best Value',
features: [
'All Pro features',
'Lifetime updates',
'Priority support',
],
plans: [
{
id: 'lifetime-deal',
name: 'Lifetime Access',
paymentType: 'one-time', // Not recurring
// No interval for one-time
lineItems: [
{
id: 'price_lifetime_xxx', // Provider Price ID
name: 'Lifetime Access',
cost: 299,
type: 'flat', // Only flat is supported for one-time
},
],
},
],
}
```
**Key differences from subscriptions:**
- `paymentType` is `'one-time'` instead of `'recurring'`
- No `interval` field
- Line items must be `type: 'flat'` (no metered or per-seat)
## Provider Setup
### Stripe
1. Create a product in Stripe Dashboard
2. Add a **One-time** price
3. Copy the Price ID to your billing schema
### Lemon Squeezy
1. Create a product with **Single payment** pricing
2. Copy the Variant ID to your billing schema
### Paddle
1. Create a product with one-time pricing
2. Copy the Price ID to your billing schema
## Database Storage
One-off purchases are stored differently than subscriptions:
| Entity | Table | Description |
|--------|-------|-------------|
| Subscriptions | `subscriptions`, `subscription_items` | Recurring payments |
| One-off | `orders`, `order_items` | Single payments |
### Orders Table Schema
```sql
orders
├── id (text) - Order ID from provider
├── account_id (uuid) - Purchasing account
├── billing_customer_id (int) - Customer reference
├── status (payment_status) - 'pending', 'succeeded', 'failed'
├── billing_provider (enum) - 'stripe', 'lemon-squeezy', 'paddle'
├── total_amount (numeric) - Total charge
├── currency (varchar)
└── created_at, updated_at
order_items
├── id (text) - Item ID
├── order_id (text) - Reference to order
├── product_id (text)
├── variant_id (text)
├── price_amount (numeric)
└── quantity (integer)
```
## Checking Order Status
Query orders to check if a user has purchased a product:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function hasLifetimeAccess(accountId: string): Promise<boolean> {
const supabase = getSupabaseServerClient();
const { data: order } = await supabase
.from('orders')
.select('id, status')
.eq('account_id', accountId)
.eq('status', 'succeeded')
.single();
return !!order;
}
// Check for specific product
export async function hasPurchasedProduct(
accountId: string,
productId: string
): Promise<boolean> {
const supabase = getSupabaseServerClient();
const { data: order } = await supabase
.from('orders')
.select(`
id,
order_items!inner(product_id)
`)
.eq('account_id', accountId)
.eq('status', 'succeeded')
.eq('order_items.product_id', productId)
.single();
return !!order;
}
```
## Gating Features
Use order status to control access:
```tsx
// Server Component
import { hasLifetimeAccess } from '~/lib/orders';
export default async function PremiumFeature({
accountId,
}: {
accountId: string;
}) {
const hasAccess = await hasLifetimeAccess(accountId);
if (!hasAccess) {
return <UpgradePrompt />;
}
return <PremiumContent />;
}
```
### RLS Policy Example
Gate database access based on orders:
```sql
-- Function to check if account has a successful order
CREATE OR REPLACE FUNCTION public.has_lifetime_access(p_account_id UUID)
RETURNS BOOLEAN
SET search_path = ''
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM public.orders
WHERE account_id = p_account_id
AND status = 'succeeded'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Example policy
CREATE POLICY premium_content_access ON public.premium_content
FOR SELECT TO authenticated
USING (
public.has_lifetime_access(account_id)
);
```
## Handling Webhooks
One-off payment webhooks work similarly to subscriptions:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
await service.handleWebhookEvent(request, {
onCheckoutSessionCompleted: async (orderOrSubscription, customerId) => {
// Check if this is an order (one-time) or subscription
if ('order_id' in orderOrSubscription) {
// One-time payment
logger.info({ orderId: orderOrSubscription.order_id }, 'Order completed');
// Provision access, send receipt, etc.
await provisionLifetimeAccess(orderOrSubscription.target_account_id);
await sendOrderReceipt(orderOrSubscription);
} else {
// Subscription
logger.info('Subscription created');
}
},
onPaymentFailed: async (sessionId) => {
// Handle failed one-time payments
await notifyPaymentFailed(sessionId);
},
});
```
### Stripe-Specific Events
For one-off payments, add these webhook events in Stripe:
- `checkout.session.completed`
- `checkout.session.async_payment_failed`
- `checkout.session.async_payment_succeeded`
{% alert type="default" title="Async payment methods" %}
Some payment methods (bank transfers, certain local methods) are asynchronous. Listen for `async_payment_succeeded` to confirm these payments.
{% /alert %}
## Mixing Orders and Subscriptions
You can offer both one-time and recurring products:
```tsx
products: [
// Subscription product
{
id: 'pro',
name: 'Pro',
plans: [
{
id: 'pro-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [{ id: 'price_monthly', cost: 29, type: 'flat' }],
},
],
},
// One-time product
{
id: 'lifetime',
name: 'Lifetime',
plans: [
{
id: 'lifetime-deal',
paymentType: 'one-time',
lineItems: [{ id: 'price_lifetime', cost: 299, type: 'flat' }],
},
],
},
]
```
Check for either type of access:
```tsx
export async function hasAccess(accountId: string): Promise<boolean> {
const supabase = getSupabaseServerClient();
// Check subscription
const { data: subscription } = await supabase
.from('subscriptions')
.select('id')
.eq('account_id', accountId)
.eq('status', 'active')
.single();
if (subscription) return true;
// Check lifetime order
const { data: order } = await supabase
.from('orders')
.select('id')
.eq('account_id', accountId)
.eq('status', 'succeeded')
.single();
return !!order;
}
```
## Billing Mode Configuration
By default, Makerkit checks subscriptions for billing status. To use orders as the primary billing mechanism (versions before 2.12.0):
```bash
BILLING_MODE=one-time
```
When set, the billing section will display orders instead of subscriptions.
{% alert type="default" title="Version 2.12.0+" %}
From version 2.12.0 onwards, orders and subscriptions can coexist. The `BILLING_MODE` setting is only needed if you want to exclusively use one-time payments.
{% /alert %}
## Add-On Purchases
Sell additional items to existing subscribers:
```tsx
// Add-on product
{
id: 'addon-storage',
name: 'Extra Storage',
plans: [
{
id: 'storage-10gb',
name: '10GB Storage',
paymentType: 'one-time',
lineItems: [
{ id: 'price_storage_10gb', name: '10GB Storage', cost: 19, type: 'flat' },
],
},
],
}
```
Track purchased add-ons:
```tsx
export async function getStorageLimit(accountId: string): Promise<number> {
const supabase = getSupabaseServerClient();
// Base storage from subscription
const baseStorage = 5; // GB
// Additional storage from orders
const { data: orders } = await supabase
.from('orders')
.select('order_items(product_id)')
.eq('account_id', accountId)
.eq('status', 'succeeded');
const additionalStorage = orders?.reduce((total, order) => {
const hasStorage = order.order_items.some(
item => item.product_id === 'storage-10gb'
);
return hasStorage ? total + 10 : total;
}, 0) ?? 0;
return baseStorage + additionalStorage;
}
```
## Testing One-Off Payments
1. **Test checkout:**
- Navigate to your pricing page
- Select the one-time product
- Complete checkout with test card `4242 4242 4242 4242`
2. **Verify database:**
```sql
SELECT * FROM orders WHERE account_id = 'your-account-id';
SELECT * FROM order_items WHERE order_id = 'order-id';
```
3. **Test access gating:**
- Verify features are unlocked after purchase
- Test with accounts that haven't purchased
## Related Documentation
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle payment events
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Token/credit systems

268
docs/billing/overview.mdoc Normal file
View File

@@ -0,0 +1,268 @@
---
status: "published"
label: "How Billing Works"
title: "Billing in Next.js Supabase Turbo"
description: "Complete guide to implementing billing in your Next.js Supabase SaaS. Configure subscriptions, one-off payments, metered usage, and per-seat pricing with Stripe, Lemon Squeezy, or Paddle."
order: 0
---
Makerkit's billing system lets you accept payments through Stripe, Lemon Squeezy, or Paddle with a unified API. You define your pricing once in a schema, and the gateway routes requests to your chosen provider. Switching providers requires changing one environment variable.
## Quick Start
Set your billing provider:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle
```
Update the database configuration to match:
```sql
UPDATE public.config SET billing_provider = 'stripe';
```
Then [configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema) with your products and pricing.
## Choose Your Provider
| Provider | Best For | Tax Handling | Multi-line Items |
|----------|----------|--------------|------------------|
| [Stripe](/docs/next-supabase-turbo/billing/stripe) | Maximum flexibility, global reach | You handle (or use Stripe Tax) | Yes |
| [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy) | Simplicity, automatic tax compliance | Merchant of Record | No (1 per plan) |
| [Paddle](/docs/next-supabase-turbo/billing/paddle) | B2B SaaS, automatic tax compliance | Merchant of Record | No (flat + per-seat only) |
**Merchant of Record** means Lemon Squeezy and Paddle handle VAT, sales tax, and compliance globally. With Stripe, you're responsible for tax collection (though Stripe Tax can help).
## Supported Pricing Models
Makerkit supports four billing models out of the box:
### Flat Subscriptions
Fixed monthly or annual pricing. The most common SaaS model.
```tsx
{
id: 'price_xxx',
name: 'Pro Plan',
cost: 29,
type: 'flat',
}
```
[Learn more about configuring flat subscriptions →](/docs/next-supabase-turbo/billing/billing-schema#flat-subscriptions)
### Per-Seat Billing
Charge based on team size. Makerkit automatically updates seat counts when members join or leave.
```tsx
{
id: 'price_xxx',
name: 'Team',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 12 }, // $12/seat up to 10
{ upTo: 'unlimited', cost: 10 },
]
}
```
[Configure per-seat billing →](/docs/next-supabase-turbo/billing/per-seat-billing)
### Metered Usage
Charge based on consumption (API calls, storage, tokens). Report usage through the billing API.
```tsx
{
id: 'price_xxx',
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
]
}
```
[Set up metered billing →](/docs/next-supabase-turbo/billing/metered-usage)
### One-Off Payments
Lifetime deals, add-ons, or credits. Stored in the `orders` table instead of `subscriptions`.
```tsx
{
paymentType: 'one-time',
lineItems: [{
id: 'price_xxx',
name: 'Lifetime Access',
cost: 299,
type: 'flat',
}]
}
```
[Configure one-off payments →](/docs/next-supabase-turbo/billing/one-off-payments)
### Credit-Based Billing
For AI SaaS and token-based systems. Combine subscriptions with a credits table for consumption tracking.
[Implement credit-based billing →](/docs/next-supabase-turbo/billing/credit-based-billing)
## Architecture Overview
The billing system uses a provider-agnostic architecture:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Your App │────▶│ Gateway │────▶│ Provider │
│ (billing.config) │ (routes requests) │ (Stripe/LS/Paddle)
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Database │◀────│ Webhook Handler│◀────│ Webhook │
│ (subscriptions) │ (processes events) │ (payment events)
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**Package structure:**
- `@kit/billing` (core): Schema validation, interfaces, types
- `@kit/billing-gateway`: Provider routing, unified API
- `@kit/stripe`: Stripe-specific implementation
- `@kit/lemon-squeezy`: Lemon Squeezy-specific implementation
- `@kit/paddle`: Paddle-specific implementation (plugin)
This abstraction means your application code stays the same regardless of provider. The billing schema defines what you sell, and each provider package handles the API specifics.
## Database Schema
Billing data is stored in four main tables:
| Table | Purpose |
|-------|---------|
| `billing_customers` | Links accounts to provider customer IDs |
| `subscriptions` | Active and historical subscription records |
| `subscription_items` | Line items within subscriptions (for per-seat, metered) |
| `orders` | One-off payment records |
| `order_items` | Items within one-off orders |
All tables have Row Level Security (RLS) enabled. Users can only read their own billing data.
## Configuration Files
### billing.config.ts
Your pricing schema lives at `apps/web/config/billing.config.ts`:
```tsx
import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
products: [
{
id: 'starter',
name: 'Starter',
description: 'For individuals',
currency: 'USD',
plans: [/* ... */],
},
],
});
```
[Full billing schema documentation →](/docs/next-supabase-turbo/billing/billing-schema)
### Environment Variables
Each provider requires specific environment variables:
**Stripe:**
```bash
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...
```
**Lemon Squeezy:**
```bash
LEMON_SQUEEZY_SECRET_KEY=...
LEMON_SQUEEZY_SIGNING_SECRET=...
LEMON_SQUEEZY_STORE_ID=...
```
**Paddle:**
```bash
PADDLE_API_KEY=...
PADDLE_WEBHOOK_SECRET_KEY=...
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=...
```
## Common Tasks
### Check if an account has a subscription
```tsx
import { createAccountsApi } from '@kit/accounts/api';
const api = createAccountsApi(supabaseClient);
const subscription = await api.getSubscription(accountId);
if (subscription?.status === 'active') {
// User has active subscription
}
```
### Create a checkout session
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
const service = createBillingGatewayService(provider);
const { checkoutToken } = await service.createCheckoutSession({
accountId,
plan,
returnUrl: `${origin}/billing/return`,
customerEmail: user.email,
});
```
### Handle billing webhooks
Webhooks are processed at `/api/billing/webhook`. Extend the handler for custom logic:
```tsx
await service.handleWebhookEvent(request, {
onCheckoutSessionCompleted: async (subscription) => {
// Send welcome email, provision resources, etc.
},
onSubscriptionDeleted: async (subscriptionId) => {
// Clean up, send cancellation email, etc.
},
});
```
[Full webhook documentation →](/docs/next-supabase-turbo/billing/billing-webhooks)
## Next Steps
1. **[Configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema)** to define your products and pricing
2. **Set up your payment provider:** [Stripe](/docs/next-supabase-turbo/billing/stripe), [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy), or [Paddle](/docs/next-supabase-turbo/billing/paddle)
3. **[Handle webhooks](/docs/next-supabase-turbo/billing/billing-webhooks)** for payment events
4. **[Use the billing API](/docs/next-supabase-turbo/billing/billing-api)** to manage subscriptions programmatically
For advanced use cases:
- [Per-seat billing](/docs/next-supabase-turbo/billing/per-seat-billing) for team-based pricing
- [Metered usage](/docs/next-supabase-turbo/billing/metered-usage) for consumption-based billing
- [Credit-based billing](/docs/next-supabase-turbo/billing/credit-based-billing) for AI/token systems
- [Custom integrations](/docs/next-supabase-turbo/billing/custom-integration) for other payment providers

475
docs/billing/paddle.mdoc Normal file
View File

@@ -0,0 +1,475 @@
---
status: "published"
label: 'Paddle'
title: 'Configuring Paddle Billing | Next.js Supabase SaaS Kit Turbo'
order: 4
description: 'Complete guide to integrating Paddle billing with your Next.js Supabase SaaS application. Learn how to set up payment processing, webhooks, and subscription management with Paddle as your Merchant of Record.'
---
Paddle is a comprehensive billing solution that acts as a Merchant of Record (MoR), handling all payment processing, tax calculations, compliance, and regulatory requirements for your SaaS business.
This integration eliminates the complexity of managing global tax compliance, PCI requirements, and payment processing infrastructure.
## Overview
This guide will walk you through:
- Setting up Paddle for development and production
- Configuring webhooks for real-time billing events
- Creating and managing subscription products
- Testing the complete billing flow
- Deploying to production
## Limitations
Paddle currently supports flat and per-seat plans. Metered subscriptions are not supported with Paddle.
## Prerequisites
Before starting, ensure you have:
- A Paddle account (sandbox for development, live for production)
- Access to your application's environment configuration
- A method to expose your local development server (ngrok, LocalTunnel, Localcan, etc.)
## Step 0: Fetch the Paddle package from the plugins repository
The Paddle package is released as a plugin in the Plugins repository. You can fetch it by running the following command:
```bash
npx @makerkit/cli@latest plugins install
```
Please choose the Paddle plugin from the list of available plugins.
## Step 1: Registering Paddle
Now we need to register the services from the Paddle plugin.
### Install the Paddle package
Run the following command to add the Paddle package to our billing package:
```bash
pnpm --filter @kit/billing-gateway add "@kit/paddle@workspace:*"
```
### Registering the Checkout component
Update the function `loadCheckoutComponent` to include the `paddle` block,
which will dynamically import the Paddle checkout component:
```tsx {% title="packages/billing/gateway/src/components/embedded-checkout.tsx" %}
import { Suspense, lazy } from 'react';
import { Enums } from '@kit/supabase/database';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
type BillingProvider = Enums<'billing_provider'>;
// Create lazy components at module level (not during render)
const StripeCheckoutLazy = lazy(async () => {
const { StripeCheckout } = await import('@kit/stripe/components');
return { default: StripeCheckout };
});
const LemonSqueezyCheckoutLazy = lazy(async () => {
const { LemonSqueezyEmbeddedCheckout } =
await import('@kit/lemon-squeezy/components');
return { default: LemonSqueezyEmbeddedCheckout };
});
const PaddleCheckoutLazy = lazy(async () => {
const { PaddleCheckout } = await import(
'@kit/paddle/components'
);
return { default: PaddleCheckout };
});
type CheckoutProps = {
onClose: (() => unknown) | undefined;
checkoutToken: string;
};
export function EmbeddedCheckout(
props: React.PropsWithChildren<{
checkoutToken: string;
provider: BillingProvider;
onClose?: () => void;
}>,
) {
return (
<>
<Suspense fallback={<LoadingOverlay fullPage={false} />}>
<CheckoutSelector
provider={props.provider}
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
</Suspense>
<BlurryBackdrop />
</>
);
}
function CheckoutSelector(
props: CheckoutProps & { provider: BillingProvider },
) {
switch (props.provider) {
case 'stripe':
return (
<StripeCheckoutLazy
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
);
case 'lemon-squeezy':
return (
<LemonSqueezyCheckoutLazy
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
);
case 'paddle':
return (
<PaddleCheckoutLazy
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
)
default:
throw new Error(`Unsupported provider: ${props.provider as string}`);
}
}
function BlurryBackdrop() {
return (
<div
className={
'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' +
' !m-0 h-full'
}
/>
);
}
```
### Registering the Webhook handler
At `packages/billing/gateway/src/server/services/billing-event-handler
/billing-event-handler-factory.service.ts`, add the snippet below at the
bottom of the file:
```tsx {% title="packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts" %}
// Register Paddle webhook handler
billingWebhookHandlerRegistry.register('paddle', async () => {
const { PaddleWebhookHandlerService } = await import('@kit/paddle');
return new PaddleWebhookHandlerService(planTypesMap);
});
```
### Registering the Billing service
Finally, at `packages/billing/gateway/src/server/services/billing-event-handler
/billing-gateway-registry.ts`, add the snippet below at the
bottom of the file:
```tsx {% title="packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts" %}
// Register Paddle billing strategy
billingStrategyRegistry.register('paddle', async () => {
const { PaddleBillingStrategyService } = await import('@kit/paddle');
return new PaddleBillingStrategyService();
});
```
## Step 2: Create Paddle Account
### Development Account (Sandbox)
1. Visit [Paddle Developer Console](https://sandbox-vendors.paddle.com/signup)
2. Complete the registration process
3. Verify your email address
4. Navigate to your sandbox dashboard
### Important Notes
- The sandbox environment allows unlimited testing without processing real payments
- All transactions in sandbox mode use test card numbers
- Webhooks and API calls work identically to production
- The Paddle payment provider currently only supports flat and per-seat plans (metered subscriptions are not supported)
## Step 3: Configure Billing Provider
### Database Configuration
Set Paddle as your billing provider in the database:
```sql
-- Update the billing provider in your configuration table
UPDATE public.config
set billing_provider = 'paddle';
```
### Environment Configuration
Add the following to your `.env.local` file:
```bash
# Set Paddle as the active billing provider
NEXT_PUBLIC_BILLING_PROVIDER=paddle
```
This environment variable tells your application to use Paddle-specific components and API endpoints for billing operations.
## Step 4: API Key Configuration
Paddle requires two types of API keys for complete integration:
### Server-Side API Key (Required)
1. In your Paddle dashboard, navigate to **Developer Tools** → **Authentication**
2. Click **Generate New API Key**
3. Give it a descriptive name (e.g., "Production API Key" or "Development API Key")
4. **Configure the required permissions** for your API key:
- **Write Customer Portal Sessions** - For managing customer billing portals
- **Read Customers** - For retrieving customer information
- **Read Prices** - For displaying pricing information
- **Read Products** - For product catalog access
- **Read/Write Subscriptions** - For subscription management
- **Read Transactions** - For payment and transaction tracking
5. Copy the generated key immediately (it won't be shown again)
6. Add to your `.env.local`:
```bash
PADDLE_API_KEY=your_server_api_key_here
```
**Security Note**: This key has access to the specified Paddle account permissions and should never be exposed to the client-side code. Only grant the minimum permissions required for your integration.
### Client-Side Token (Required)
1. In the same **Authentication** section, look for **Client-side tokens**
2. Click **New Client-Side Token**
3. Copy the client token
4. Add to your `.env.local`:
```bash
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_client_token_here
```
**Important**: This token is safe to expose in client-side code but should be restricted to your specific domains.
## Step 5: Webhook Configuration
Webhooks enable real-time synchronization between Paddle and your application for events like successful payments, subscription changes, and cancellations.
### Set Up Local Development Tunnel
First, expose your local development server to the internet:
#### Using ngrok (Recommended)
```bash
# Install ngrok if not already installed
npm install -g ngrok
# Expose port 3000 (default Next.js port)
ngrok http 3000
```
#### Using LocalTunnel
```bash
# Install localtunnel
npm install -g localtunnel
# Expose port 3000
lt --port 3000
```
### Configure Webhook Destination
1. In Paddle dashboard, go to **Developer Tools** → **Notifications**
2. Click **New Destination**
3. Configure the destination:
- **Destination URL**: `https://your-tunnel-url.ngrok.io/api/billing/webhook`
- **Description**: "Local Development Webhook"
- **Active**: ✅ Checked
### Select Webhook Events
Enable these essential events for proper billing integration:
### Retrieve Webhook Secret
1. After creating the destination, click on it to view details
2. Copy the **Endpoint Secret** (used to verify webhook authenticity)
3. Add to your `.env.local`:
```bash
PADDLE_WEBHOOK_SECRET_KEY=your_webhook_secret_here
```
### Test Webhook Connection
You can test the webhook endpoint by making a GET request to verify it's accessible:
```bash
curl https://your-tunnel-url.ngrok.io/api/billing/webhook
```
Expected response: `200 OK` with a message indicating the webhook endpoint is active.
## Step 6: Product and Pricing Configuration
### Create Products in Paddle
1. Navigate to **Catalog** → **Products** in your Paddle dashboard
2. Click **Create Product**
3. Configure your product:
**Basic Information:**
- **Product Name**: "Starter Plan", "Pro Plan", etc.
- **Description**: Detailed description of the plan features
- **Tax Category**: Select appropriate category (usually "Software")
**Pricing Configuration:**
- **Billing Interval**: Monthly, Yearly, or Custom
- **Price**: Set in your primary currency
- **Trial Period**: Optional free trial duration
### Configure Billing Settings
Update your billing configuration file with the Paddle product IDs:
```typescript
// apps/web/config/billing.config.ts
export const billingConfig = {
provider: 'paddle',
products: [
{
id: 'starter',
name: 'Starter Plan',
description: 'Perfect for individuals and small teams',
badge: 'Most Popular',
features: [
'Up to 5 projects',
'Basic support',
'1GB storage'
],
plans: [
{
name: 'Starter Monthly',
id: 'starter-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'pri_starter_monthly_001', // Paddle Price ID
name: 'Starter',
cost: 9.99,
type: 'flat' as const,
},
],
}
]
}
// Add more products...
]
};
```
## Step 7: Checkout Configuration
### Default Payment Link Configuration
1. Go to **Checkout** → **Checkout Settings** in Paddle dashboard
2. Configure **Default Payment Link**: use`http://localhost:3000` - but when deploying to production, you should use your production domain.
3. Save the configuration
## Step 8: Testing the Integration
### Development Testing Checklist
**Environment Verification:**
- [ ] All environment variables are set correctly
- [ ] Webhook tunnel is active and accessible
- [ ] Destination was defined using the correct URL
- [ ] Database billing provider is set to 'paddle' in both DB and ENV
**Subscription Flow Testing:**
1. Navigate to your billing/pricing page (`/home/billing` or equivalent)
2. Click on a subscription plan
3. Complete the checkout flow using Paddle test cards
4. Verify successful redirect to success page
5. Check that subscription appears in user dashboard
6. Verify webhook events are received in your application logs
You can also test cancellation flows:
- cancel the subscription from the billing portal
- delete the account and verify the subscription is cancelled as well
### Test Card Numbers
[Follow this link to get the test card numbers](https://developer.paddle.com/concepts/payment-methods/credit-debit-card#test-payment-method)
### Webhook Testing
Monitor webhook delivery in your application logs:
```bash
# Watch your development logs
pnpm dev
# In another terminal, monitor webhook requests
tail -f logs/webhook.log
```
## Step 9: Production Deployment
### Apply for Live Paddle Account
1. In your Paddle dashboard, click **Go Live**
2. Complete the application process:
- Business information and verification
- Tax information and documentation
- Banking details for payouts
- Identity verification for key personnel
**Timeline**: Live account approval typically takes 1-3 business days.
### Production Environment Setup
Create production-specific configuration:
```bash
# Production environment variables
NEXT_PUBLIC_BILLING_PROVIDER=paddle
PADDLE_API_KEY=your_production_api_key
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_production_client_token
PADDLE_WEBHOOK_SECRET_KEY=your_production_webhook_secret
```
### Production Webhook Configuration
1. Create a new webhook destination for production
2. Set the destination URL to your production domain:
```
https://yourdomain.com/api/billing/webhook
```
3. Enable the same events as configured for development
4. Update your production environment with the new webhook secret
### Production Products and Pricing
1. Create production versions of your products in the live environment
2. Update your production billing configuration with live Price IDs
3. Test the complete flow on production with small-amount transactions
### Support Resources
Refer to the [Paddle Documentation](https://developer.paddle.com) for more information:
- **Paddle Documentation**: [https://developer.paddle.com](https://developer.paddle.com)
- **Status Page**: [https://status.paddle.com](https://status.paddle.com)

View File

@@ -0,0 +1,293 @@
---
status: "published"
label: "Per Seat Billing"
title: "Configure Per-Seat Billing for Team Subscriptions"
order: 6
description: "Implement per-seat pricing for your SaaS. Makerkit automatically tracks team members and updates seat counts with your billing provider when members join or leave."
---
Per-seat billing charges customers based on the number of users (seats) in their team. Makerkit handles this automatically: when team members are added or removed, the subscription is updated with the new seat count.
## How It Works
1. You define a `per_seat` line item in your billing schema
2. When a team subscribes, Makerkit counts current members and sets the initial quantity
3. When members join or leave, Makerkit updates the subscription quantity
4. Your billing provider (Stripe, Lemon Squeezy, Paddle) handles proration
No custom code required for basic per-seat billing.
## Schema Configuration
Define a per-seat line item in your billing schema:
```tsx {% title="apps/web/config/billing.config.ts" %}
import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
products: [
{
id: 'team',
name: 'Team',
description: 'For growing teams',
currency: 'USD',
features: [
'Unlimited projects',
'Team collaboration',
'Priority support',
],
plans: [
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_team_monthly', // Your Stripe Price ID
name: 'Team Seats',
cost: 0, // Base cost (calculated from tiers)
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 15 }, // $15/seat for seats 4-10
{ upTo: 'unlimited', cost: 12 }, // Volume discount
],
},
],
},
],
},
],
});
```
## Pricing Tier Patterns
### Free Tier + Per-Seat
Include free seats for small teams:
```tsx
tiers: [
{ upTo: 5, cost: 0 }, // 5 free seats
{ upTo: 'unlimited', cost: 10 }, // $10/seat after
]
```
### Flat Per-Seat (No Tiers)
Simple per-seat pricing:
```tsx
tiers: [
{ upTo: 'unlimited', cost: 15 }, // $15/seat for all seats
]
```
### Volume Discounts
Reward larger teams:
```tsx
tiers: [
{ upTo: 10, cost: 20 }, // $20/seat for 1-10
{ upTo: 50, cost: 15 }, // $15/seat for 11-50
{ upTo: 100, cost: 12 }, // $12/seat for 51-100
{ upTo: 'unlimited', cost: 10 }, // $10/seat for 100+
]
```
### Base Fee + Per-Seat (Stripe Only)
Combine a flat fee with per-seat pricing:
```tsx
lineItems: [
{
id: 'price_base_fee',
name: 'Platform Fee',
cost: 49,
type: 'flat',
},
{
id: 'price_seats',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 10 },
],
},
]
```
{% alert type="warning" title="Stripe only" %}
Multiple line items (flat + per-seat) only work with Stripe. Lemon Squeezy and Paddle support one line item per plan.
{% /alert %}
## Provider Setup
### Stripe
1. Create a product in Stripe Dashboard
2. Add a price with **Graduated pricing** or **Volume pricing**
3. Set the pricing tiers to match your schema
4. Copy the Price ID (e.g., `price_xxx`) to your line item `id`
**Stripe pricing types:**
- **Graduated**: Each tier applies to that range only (e.g., seats 1-5 at $0, seats 6-10 at $15)
- **Volume**: The price for all units is determined by the total quantity
### Lemon Squeezy
1. Create a product with **Usage-based pricing**
2. Configure the pricing tiers
3. Copy the Variant ID to your line item `id`
### Paddle
1. Create a product with quantity-based pricing
2. Configure as needed (Paddle handles proration automatically)
3. Copy the Price ID to your line item `id`
{% alert type="default" title="Paddle trial limitation" %}
Paddle doesn't support updating subscription quantities during a trial period. If using per-seat billing with Paddle trials, consider using Feature Policies to restrict invitations during trials.
{% /alert %}
## Automatic Seat Updates
Makerkit automatically updates seat counts when:
| Action | Effect |
|--------|--------|
| Team member accepts invitation | Seat count increases |
| Team member is removed | Seat count decreases |
| Team member leaves | Seat count decreases |
| Account is deleted | Subscription is canceled |
The billing provider handles proration based on your settings.
## Testing Per-Seat Billing
1. **Create a team subscription:**
- Sign up and create a team account
- Subscribe to a per-seat plan
- Verify the initial seat count matches team size
2. **Add a member:**
- Invite a new member to the team
- Have them accept the invitation
- Check Stripe/LS/Paddle: subscription quantity should increase
3. **Remove a member:**
- Remove a member from the team
- Check: subscription quantity should decrease
4. **Verify proration:**
- Check the upcoming invoice in your provider dashboard
- Confirm proration is calculated correctly
## Manual Seat Updates (Advanced)
In rare cases, you might need to manually update seat counts:
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function updateSeatCount(
subscriptionId: string,
subscriptionItemId: string,
newQuantity: number
) {
const supabase = getSupabaseServerClient();
// Get subscription to find the provider
const { data: subscription } = await supabase
.from('subscriptions')
.select('billing_provider')
.eq('id', subscriptionId)
.single();
if (!subscription) {
throw new Error('Subscription not found');
}
const service = createBillingGatewayService(
subscription.billing_provider
);
return service.updateSubscriptionItem({
subscriptionId,
subscriptionItemId,
quantity: newQuantity,
});
}
```
## Checking Seat Limits
To enforce seat limits in your application:
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function canAddMember(accountId: string): Promise<boolean> {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
// Get current subscription
const subscription = await api.getSubscription(accountId);
if (!subscription) {
return false; // No subscription
}
// Get per-seat item
const { data: seatItem } = await supabase
.from('subscription_items')
.select('quantity')
.eq('subscription_id', subscription.id)
.eq('type', 'per_seat')
.single();
// Get current member count
const { count: memberCount } = await supabase
.from('accounts_memberships')
.select('*', { count: 'exact', head: true })
.eq('account_id', accountId);
// Check if under limit (if you have a max seats limit)
const maxSeats = 100; // Your limit
return (memberCount ?? 0) < maxSeats;
}
```
## Common Issues
### Seat count not updating
1. Check that the line item has `type: 'per_seat'`
2. Verify the subscription is active
3. Check webhook logs for errors
4. Ensure the subscription item ID is correct in the database
### Proration not working as expected
Configure proration behavior in your billing provider:
- **Stripe:** Customer Portal settings or API parameters
- **Lemon Squeezy:** Product settings
- **Paddle:** Automatic proration
### "Minimum quantity" errors
Some plans require at least 1 seat. Ensure your tiers start at a valid minimum.
## Related Documentation
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Define pricing plans
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe
- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Manual subscription updates
- [Team Accounts](/docs/next-supabase-turbo/api/team-account-api) - Team management

292
docs/billing/stripe.mdoc Normal file
View File

@@ -0,0 +1,292 @@
---
status: "published"
label: "Stripe"
title: "Configure Stripe Billing for Your Next.js SaaS"
description: "Complete guide to setting up Stripe payments in Makerkit. Configure subscriptions, one-off payments, webhooks, and the Customer Portal for your Next.js Supabase application."
order: 2
---
Stripe is the default billing provider in Makerkit. It offers the most flexibility with support for multiple line items, metered billing, and advanced subscription management.
## Prerequisites
Before you start:
1. Create a [Stripe account](https://dashboard.stripe.com/register)
2. Have your Stripe API keys ready (Dashboard → Developers → API keys)
3. Install the Stripe CLI for local webhook testing
## Step 1: Environment Variables
Add these variables to your `.env.local` file:
```bash
# Stripe API Keys
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
```
| Variable | Description | Where to Find |
|----------|-------------|---------------|
| `STRIPE_SECRET_KEY` | Server-side API key | Dashboard → Developers → API keys |
| `STRIPE_WEBHOOK_SECRET` | Webhook signature verification | Generated by Stripe CLI or Dashboard |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client-side key (safe to expose) | Dashboard → Developers → API keys |
{% alert type="error" title="Never commit secret keys" %}
Add `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to `.env.local` only. Never add them to `.env` or commit them to your repository.
{% /alert %}
## Step 2: Configure Billing Provider
Ensure Stripe is set as your billing provider:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe
```
And in the database:
```sql
UPDATE public.config SET billing_provider = 'stripe';
```
## Step 3: Create Products in Stripe
1. Go to Stripe Dashboard → Products
2. Click **Add product**
3. Configure your product:
- **Name**: "Pro Plan", "Starter Plan", etc.
- **Pricing**: Add prices for monthly and yearly intervals
- **Price ID**: Copy the `price_xxx` ID for your billing schema
**Important:** The Price ID (e.g., `price_1NNwYHI1i3VnbZTqI2UzaHIe`) must match the `id` field in your billing schema's line items.
## Step 4: Set Up Local Webhooks with Stripe CLI
The Stripe CLI forwards webhook events from Stripe to your local development server.
### Using Docker (Recommended)
First, log in to Stripe:
```bash
docker run --rm -it --name=stripe \
-v ~/.config/stripe:/root/.config/stripe \
stripe/stripe-cli:latest login
```
This opens a browser window to authenticate. Complete the login process.
Then start listening for webhooks:
```bash
pnpm run stripe:listen
```
Or manually:
```bash
docker run --rm -it --name=stripe \
-v ~/.config/stripe:/root/.config/stripe \
stripe/stripe-cli:latest listen \
--forward-to http://host.docker.internal:3000/api/billing/webhook
```
### Using Stripe CLI Directly
If you prefer installing Stripe CLI globally:
```bash
# macOS
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Listen for webhooks
stripe listen --forward-to localhost:3000/api/billing/webhook
```
### Copy the Webhook Secret
When you start listening, the CLI displays a webhook signing secret:
```
> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx
```
Copy this value and add it to your `.env.local`:
```bash
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
```
{% alert type="default" title="Re-run after restart" %}
The webhook secret changes each time you restart the Stripe CLI. Update your `.env.local` accordingly.
{% /alert %}
### Linux Troubleshooting
If webhooks aren't reaching your app on Linux, try adding `--network=host`:
```bash
docker run --rm -it --name=stripe \
-v ~/.config/stripe:/root/.config/stripe \
stripe/stripe-cli:latest listen \
--network=host \
--forward-to http://localhost:3000/api/billing/webhook
```
## Step 5: Configure Customer Portal
The Stripe Customer Portal lets users manage their subscriptions, payment methods, and invoices.
1. Go to Stripe Dashboard → Settings → Billing → Customer portal
2. Configure these settings:
**Payment methods:**
- Allow customers to update payment methods: ✅
**Subscriptions:**
- Allow customers to switch plans: ✅
- Choose products customers can switch between
- Configure proration behavior
**Cancellations:**
- Allow customers to cancel subscriptions: ✅
- Configure cancellation behavior (immediate vs. end of period)
**Invoices:**
- Allow customers to view invoice history: ✅
{% img src="/assets/images/docs/stripe-customer-portal.webp" width="2712" height="1870" /%}
## Step 6: Production Webhooks
When deploying to production, configure webhooks in the Stripe Dashboard:
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click **Add endpoint**
3. Enter your webhook URL: `https://yourdomain.com/api/billing/webhook`
4. Select events to listen for:
**Required events:**
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
**For one-off payments (optional):**
- `checkout.session.async_payment_failed`
- `checkout.session.async_payment_succeeded`
5. Click **Add endpoint**
6. Copy the signing secret and add it to your production environment variables
{% alert type="warning" title="Use a public URL" %}
Webhook URLs must be publicly accessible. Vercel preview deployments with authentication enabled won't work. Test by visiting the URL in an incognito browser window.
{% /alert %}
## Free Trials Without Credit Card
Allow users to start a trial without entering payment information:
```bash
STRIPE_ENABLE_TRIAL_WITHOUT_CC=true
```
When enabled, users can start a subscription with a trial period and won't be charged until the trial ends. They'll need to add a payment method before the trial expires.
You must also set `trialDays` in your billing schema:
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14, // 14-day free trial
lineItems: [/* ... */],
}
```
## Migrating Existing Subscriptions
If you're migrating to Makerkit with existing Stripe subscriptions, you need to add metadata to each subscription.
Makerkit expects this metadata on subscriptions:
```json
{
"accountId": "uuid-of-the-account"
}
```
**Option 1: Add metadata manually**
Use the Stripe Dashboard or a migration script to add the `accountId` metadata to existing subscriptions.
**Option 2: Modify the webhook handler**
If you can't update metadata, modify the webhook handler to look up accounts by customer ID:
```tsx {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %}
// Instead of:
const accountId = subscription.metadata.accountId as string;
// Query your database:
const { data: customer } = await supabase
.from('billing_customers')
.select('account_id')
.eq('customer_id', subscription.customer)
.single();
const accountId = customer?.account_id;
```
## Common Issues
### Webhooks not received
1. **Check the CLI is running:** `pnpm run stripe:listen` should show "Ready!"
2. **Verify the secret:** Copy the new webhook secret after each CLI restart
3. **Check the account:** Ensure you're logged into the correct Stripe account
4. **Check the URL:** The webhook endpoint is `/api/billing/webhook`
### "No such price" error
The Price ID in your billing schema doesn't exist in Stripe. Verify:
1. You're using test mode keys with test mode prices (or live with live)
2. The Price ID is copied correctly from Stripe Dashboard
### Subscription not appearing in database
1. Check webhook logs in Stripe Dashboard → Developers → Webhooks
2. Look for errors in your application logs
3. Verify the `accountId` is correctly passed in checkout metadata
### Customer Portal not loading
1. Ensure the Customer Portal is configured in Stripe Dashboard
2. Check that the customer has a valid subscription
3. Verify the `customerId` is correct
## Testing Checklist
Before going live:
- [ ] Test subscription checkout with test card `4242 4242 4242 4242`
- [ ] Verify subscription appears in user's billing section
- [ ] Test subscription upgrade/downgrade via Customer Portal
- [ ] Test subscription cancellation
- [ ] Verify webhook events are processed correctly
- [ ] Test with failing card `4000 0000 0000 0002` to verify error handling
- [ ] For trials: test trial expiration and conversion to paid
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Report usage to Stripe
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing

View File

@@ -0,0 +1,84 @@
---
status: "published"
label: "App Breadcrumbs"
title: "App Breadcrumbs Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the App Breadcrumbs component in the Next.js Supabase SaaS kit"
order: 6
---
The `AppBreadcrumbs` component creates a dynamic breadcrumb navigation based on the current URL path. It's designed to work with Next.js and uses the `usePathname` hook from Next.js for routing information.
## Features
- Automatically generates breadcrumbs from the current URL path
- Supports custom labels for path segments
- Limits the number of displayed breadcrumbs with an ellipsis for long paths
- Internationalization support with the `Trans` component
- Responsive design with different text sizes for mobile and desktop
## Usage
```tsx
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
function MyPage() {
return (
<AppBreadcrumbs
values={{
"custom-slug": "Custom Label"
}}
maxDepth={4}
/>
);
}
```
When you have IDs in your URL, you can use the `values` prop to provide custom labels for those segments. For example, if your URL is `/users/123`, you can set `values={{ "123": "User Profile" }}` to display "User Profile" instead of "123" in the breadcrumb.
```tsx
<AppBreadcrumbs
values={{
"123": "User"
}}
/>
```
This will display "User" instead of "123" in the breadcrumb.
## Props
The component accepts two optional props:
1. `values`: An object where keys are URL segments and values are custom labels.
- Type: `Record<string, string>`
- Default: `{}`
2. `maxDepth`: The maximum number of breadcrumb items to display before using an ellipsis.
- Type: `number`
- Default: `6`
## Functionality
- The component splits the current path into segments and creates a breadcrumb item for each.
- If the number of segments exceeds `maxDepth`, it shows an ellipsis (...) to indicate hidden segments.
- The last breadcrumb item is not clickable and represents the current page.
- Custom labels can be provided through the `values` prop.
- For segments without custom labels, it attempts to use an i18n key (`common.routes.[unslugified-path]`). If no translation is found, it falls back to the unslugified path.
## Styling
- The component uses Tailwind CSS classes for styling.
- Breadcrumb items are capitalized.
- On larger screens (lg breakpoint), the text size is slightly smaller.
## Dependencies
This component relies on several other components and utilities:
- Next.js `usePathname` hook
- Custom UI components (Breadcrumb, BreadcrumbItem, etc.) from Shadcn UI
- `If` component for conditional rendering
- `Trans` component for internationalization
This component provides a flexible and easy-to-use solution for adding breadcrumb navigation to your Next.js application. It's particularly useful for sites with deep hierarchical structures or those requiring dynamic breadcrumb generation.

View File

@@ -0,0 +1,86 @@
---
status: "published"
label: "Bordered Navigation Menu"
title: "Bordered Navigation Menu Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Bordered Navigation Menu component in the Next.js Supabase SaaS kit"
order: 8
---
The BorderedNavigationMenu components provide a stylish and interactive navigation menu with a bordered, underline-style active state indicator. These components are built on top of the NavigationMenu from Shadcn UI and are designed to work seamlessly with Next.js routing.
## BorderedNavigationMenu
This component serves as a container for navigation menu items.
### Usage
```jsx
import { BorderedNavigationMenu, BorderedNavigationMenuItem } from '@kit/ui/bordered-navigation-menu';
function MyNavigation() {
return (
<BorderedNavigationMenu>
<BorderedNavigationMenuItem path="/home" label="Home" />
<BorderedNavigationMenuItem path="/about" label="About" />
{/* Add more menu items as needed */}
</BorderedNavigationMenu>
);
}
```
### Props
- `children: React.ReactNode`: The navigation menu items to be rendered.
## BorderedNavigationMenuItem
This component represents an individual item in the navigation menu.
### Props
- `path: string` (required): The URL path for the navigation item.
- `label: React.ReactNode | string` (required): The text or content to display for the item.
- `end?: boolean | ((path: string) => boolean)`: Determines if the path should match exactly or use a custom function for active state.
- `active?: boolean`: Manually set the active state of the item.
- `className?: string`: Additional CSS classes for the menu item container.
- `buttonClassName?: string`: Additional CSS classes for the button element.
### Features
1. **Automatic Active State**: Uses Next.js's `usePathname` to automatically determine if the item is active based on the current route.
2. **Custom Active State Logic**: Allows for custom active state determination through the `end` prop.
3. **Internationalization**: Supports i18n through the `Trans` component for string labels.
4. **Styling**: Utilizes Tailwind CSS for styling, with active items featuring an underline animation.
### Example
```jsx
<BorderedNavigationMenuItem
path="/dashboard"
label="common.dashboardLabel"
end={true}
className="my-custom-class"
buttonClassName="px-4 py-2"
/>
```
## Styling
The components use Tailwind CSS for styling. Key classes include:
- Menu container: `relative h-full space-x-2`
- Menu item button: `relative active:shadow-sm`
- Active indicator: `absolute -bottom-2.5 left-0 h-0.5 w-full bg-primary animate-in fade-in zoom-in-90`
You can further customize the appearance by passing additional classes through the `className` and `buttonClassName` props.
## Best Practices
1. Use consistent labeling and paths across your application.
2. Leverage the `Trans` component for internationalization of labels.
3. Consider the `end` prop for more precise control over the active state for nested routes.
4. Use the `active` prop sparingly, preferring the automatic active state detection when possible.
These components provide a sleek, accessible way to create navigation menus in your Next.js application, with built-in support for styling active states and internationalization.

View File

@@ -0,0 +1,156 @@
---
status: "published"
label: "Card Button"
title: "Card Button Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Card Button component in the Next.js Supabase SaaS kit"
order: 7
---
The CardButton components provide a set of customizable, interactive card-like buttons for use in React applications. These components are built with flexibility in mind, allowing for easy composition and styling.
{% component path="card-button" /%}
## Components
### CardButton
The main wrapper component for creating a card-like button.
#### Props
- `asChild?: boolean`: If true, the component will render its children directly.
- `className?: string`: Additional CSS classes to apply to the button.
- `children: React.ReactNode`: The content of the button.
- `...props`: Any additional button props.
#### Usage
```jsx
<CardButton onClick={handleClick}>
{/* Card content */}
</CardButton>
```
### CardButtonTitle
Component for rendering the title of the card button.
#### Props
- `className?: string`: Additional CSS classes for the title.
- `asChild?: boolean`: If true, renders children directly.
- `children: React.ReactNode`: The title content.
#### Usage
```jsx
<CardButtonTitle>My Card Title</CardButtonTitle>
```
### CardButtonHeader
Component for the header section of the card button.
#### Props
- `className?: string`: Additional CSS classes for the header.
- `asChild?: boolean`: If true, renders children directly.
- `displayArrow?: boolean`: Whether to display the chevron icon (default: true).
- `children: React.ReactNode`: The header content.
#### Usage
```jsx
<CardButtonHeader displayArrow={false}>
<CardButtonTitle>Header Content</CardButtonTitle>
</CardButtonHeader>
```
### CardButtonContent
Component for the main content area of the card button.
#### Props
- `className?: string`: Additional CSS classes for the content area.
- `asChild?: boolean`: If true, renders children directly.
- `children: React.ReactNode`: The main content.
#### Usage
```jsx
<CardButtonContent>
<p>Main card content goes here</p>
</CardButtonContent>
```
### CardButtonFooter
Component for the footer section of the card button.
#### Props
- `className?: string`: Additional CSS classes for the footer.
- `asChild?: boolean`: If true, renders children directly.
- `children: React.ReactNode`: The footer content.
#### Usage
```jsx
<CardButtonFooter>
<span>Footer information</span>
</CardButtonFooter>
```
## Styling
These components use Tailwind CSS for styling. Key features include:
- Hover and active states for interactive feedback
- Responsive sizing and layout
- Dark mode support
- Customizable through additional class names
## Example
Here's a complete example of how to use these components together:
```jsx
import {
CardButton,
CardButtonTitle,
CardButtonHeader,
CardButtonContent,
CardButtonFooter
} from '@kit/ui/card-button';
function MyCardButton() {
return (
<CardButton onClick={() => console.log('Card clicked')}>
<CardButtonHeader>
<CardButtonTitle>Featured Item</CardButtonTitle>
</CardButtonHeader>
<CardButtonContent>
<p>This is a detailed description of the featured item.</p>
</CardButtonContent>
<CardButtonFooter>
<span>Click to learn more</span>
</CardButtonFooter>
</CardButton>
);
}
```
## Accessibility
- The components use semantic HTML elements when not using the `asChild` prop.
- Interactive elements are keyboard accessible.
## Best Practices
1. Use clear, concise titles in `CardButtonTitle`.
2. Provide meaningful content in `CardButtonContent` for user understanding.
3. Use `CardButtonFooter` for calls-to-action or additional information.
4. Leverage the `asChild` prop when you need to change the underlying element (e.g., for routing with Next.js `Link` component).
These CardButton components provide a flexible and customizable way to create interactive card-like buttons in your React application, suitable for various use cases such as feature showcases, navigation elements, or clickable information cards.

View File

@@ -0,0 +1,116 @@
---
status: "published"
label: "Temporary Landing Page"
title: "A temporary minimal landing page for your SaaS"
description: "Looking to ship as quickly as possible? Use the Coming Soon component to showcase your product's progress."
order: 9
---
If you're rushing to launch your SaaS, you can use the Coming Soon component to showcase a minimal landing page for your product and generate buzz before you launch.
{% component path="coming-soon" /%}
My suggestions is to replace the whole `(marketing)` layout using the Coming Soon component.
This will save you a lot of time making sure the landing page and the links are filled with the right information.
```tsx {% title="apps/web/app/(marketing)/layout.tsx" %}
import Link from 'next/link';
import {
ComingSoon,
ComingSoonButton,
ComingSoonHeading,
ComingSoonLogo,
ComingSoonText,
} from '@kit/ui/marketing';
import { AppLogo } from '~/components/app-logo';
import appConfig from '~/config/app.config';
export default function SiteLayout() {
return (
<ComingSoon>
<ComingSoonLogo>
<AppLogo />
</ComingSoonLogo>
<ComingSoonHeading>{appConfig.name} is coming soon</ComingSoonHeading>
<ComingSoonText>
We&apos;re building something amazing. Our team is working hard to bring
you a product that will revolutionize how you work.
</ComingSoonText>
<ComingSoonButton asChild>
<Link href="#">Follow Our Progress</Link>
</ComingSoonButton>
{/* Additional custom content */}
<div className="mt-8 flex justify-center gap-4">
{/* Social icons, etc */}
</div>
</ComingSoon>
);
}
```
Even better, you can use an env variable to check if it's a production build or not and displaying the normal layout during development:
```tsx {% title="apps/web/app/(marketing)/layout.tsx" %}
import Link from 'next/link';
import {
ComingSoon,
ComingSoonButton,
ComingSoonHeading,
ComingSoonLogo,
ComingSoonText,
} from '@kit/ui/marketing';
import { SiteFooter } from '~/(marketing)/_components/site-footer';
import { SiteHeader } from '~/(marketing)/_components/site-header';
import { AppLogo } from '~/components/app-logo';
import appConfig from '~/config/app.config';
function SiteLayout(props: React.PropsWithChildren) {
if (!appConfig.production) {
return (
<div className={'flex min-h-[100vh] flex-col'}>
<SiteHeader />
{props.children}
<SiteFooter />
</div>
);
}
return (
<ComingSoon>
<ComingSoonLogo>
<AppLogo />
</ComingSoonLogo>
<ComingSoonHeading>{appConfig.name} is coming soon</ComingSoonHeading>
<ComingSoonText>
We&apos;re building something amazing. Our team is working hard to bring
you a product that will revolutionize how you work.
</ComingSoonText>
<ComingSoonButton asChild>
<Link href="#">Follow Our Progress</Link>
</ComingSoonButton>
{/* Additional custom content */}
<div className="mt-8 flex justify-center gap-4">
{/* Social icons, etc */}
</div>
</ComingSoon>
);
}
export default SiteLayout;
```

View File

@@ -0,0 +1,130 @@
---
status: "published"
label: "Cookie Banner"
title: "Cookie Banner Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Cookie Banner component in the Next.js Supabase SaaS kit"
order: 7
---
This module provides a `CookieBanner` component and a `useCookieConsent` hook for managing cookie consent in React applications.
{% component path="cookie-banner" /%}
## CookieBanner Component
The CookieBanner component displays a consent banner for cookies and tracking technologies.
### Usage
```jsx
import dynamic from 'next/dynamic';
const CookieBanner = dynamic(() => import('@kit/ui/cookie-banner').then(m => m.CookieBanner), {
ssr: false
});
function App() {
return (
<div>
{/* Your app content */}
<CookieBanner />
</div>
);
}
```
### Features
- Displays only when consent status is unknown
- Automatically hides after user interaction
- Responsive design (different layouts for mobile and desktop)
- Internationalization support via the `Trans` component
- Animated entrance using Tailwind CSS
## useCookieConsent Hook
This custom hook manages the cookie consent state and provides methods to update it.
### Usage
```jsx
import { useCookieConsent } from '@kit/ui/cookie-banner';
function MyComponent() {
const { status, accept, reject, clear } = useCookieConsent();
// Use these values and functions as needed
}
```
### API
- `status: ConsentStatus`: Current consent status (Accepted, Rejected, or Unknown)
- `accept(): void`: Function to accept cookies
- `reject(): void`: Function to reject cookies
- `clear(): void`: Function to clear the current consent status
## ConsentStatus Enum
```typescript
enum ConsentStatus {
Accepted = 'accepted',
Rejected = 'rejected',
Unknown = 'unknown'
}
```
## Key Features
1. **Persistent Storage**: Consent status is stored in localStorage for persistence across sessions.
2. **Server-Side Rendering Compatible**: Checks for browser environment before accessing localStorage.
3. **Customizable**: The `COOKIE_CONSENT_STATUS` key can be configured as needed.
4. **Reactive**: The banner automatically updates based on the consent status.
## Styling
The component uses Tailwind CSS for styling, with support for dark mode and responsive design.
## Accessibility
- Uses Base UI's Dialog primitive for improved accessibility
- Autofocus on the "Accept" button for keyboard navigation
## Internationalization
The component uses the `Trans` component for internationalization. Ensure you have the following keys in your i18n configuration:
- `cookieBanner.title`
- `cookieBanner.description`
- `cookieBanner.reject`
- `cookieBanner.accept`
## Best Practices
1. Place the `CookieBanner` component at the root of your application to ensure it's always visible when needed.
2. Use the `useCookieConsent` hook to conditionally render content or initialize tracking scripts based on the user's consent.
3. Provide clear and concise information about your cookie usage in the banner description.
4. Ensure your privacy policy is up-to-date and accessible from the cookie banner or nearby.
## Example: Conditional Script Loading
```jsx
function App() {
const { status } = useCookieConsent();
useEffect(() => {
if (status === ConsentStatus.Accepted) {
// Initialize analytics or other cookie-dependent scripts
}
}, [status]);
return (
<div>
{/* Your app content */}
<CookieBanner />
</div>
);
}
```
This cookie consent management system provides a user-friendly way to comply with cookie laws and regulations while maintaining a good user experience.

View File

@@ -0,0 +1,108 @@
---
status: "published"
label: "Data Table"
title: "Data Table Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Data Table component in the Next.js Supabase SaaS kit"
order: 2
---
The DataTable component is a powerful and flexible table component built on top of TanStack Table (React Table v8). It provides a range of features for displaying and interacting with tabular data, including pagination, sorting, and custom rendering.
## Usage
```tsx
import { DataTable } from '@kit/ui/enhanced-data-table';
function MyComponent() {
const columns = [
// Define your columns here
];
const data = [
// Your data array
];
return (
<DataTable
columns={columns}
data={data}
pageSize={10}
pageIndex={0}
pageCount={5}
/>
);
}
```
## Props
- `data: T[]` (required): An array of objects representing the table data.
- `columns: ColumnDef<T>[]` (required): An array of column definitions.
- `pageIndex?: number`: The current page index (0-based).
- `pageSize?: number`: The number of rows per page.
- `pageCount?: number`: The total number of pages.
- `onPaginationChange?: (pagination: PaginationState) => void`: Callback function for pagination changes.
- `tableProps?: React.ComponentProps<typeof Table>`: Additional props to pass to the underlying Table component.
## Pagination
The DataTable component handles pagination internally but can also be controlled externally. It provides navigation buttons for first page, previous page, next page, and last page.
## Sorting
Sorting is handled internally by the component. Click on column headers to sort by that column.
## Filtering
The component supports column filtering, which can be implemented in the column definitions.
## Example with ServerDataLoader
Here's an example of how to use the DataTable component with ServerDataLoader:
```jsx
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
function AccountsPage({ searchParams }) {
const client = getSupabaseServerAdminClient();
const page = searchParams.page ? parseInt(searchParams.page) : 1;
const filters = getFilters(searchParams);
return (
<ServerDataLoader
table={'accounts'}
client={client}
page={page}
where={filters}
>
{({ data, page, pageSize, pageCount }) => (
<DataTable
columns={[
// Define your columns here
]}
data={data}
page={page}
pageSize={pageSize}
pageCount={pageCount}
/>
)}
</ServerDataLoader>
);
}
```
This example demonstrates how to use ServerDataLoader to fetch data from a Supabase table and pass it to the DataTable component. The ServerDataLoader handles the data fetching and pagination, while the DataTable takes care of rendering and client-side interactions.
## Customization
The DataTable component is built with customization in mind. You can customize the appearance using Tailwind CSS classes and extend its functionality by passing custom props to the underlying Table component.
## Internationalization
The component uses the `Trans` component for internationalization. Ensure you have your i18n setup correctly to leverage this feature.
The DataTable component provides a powerful and flexible solution for displaying tabular data in your React applications, with built-in support for common table features and easy integration with server-side data loading.

View File

@@ -0,0 +1,95 @@
---
status: "published"
label: "Empty State"
title: "Empty State Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Empty State component in the Next.js Supabase SaaS kit"
order: 7
---
The `EmptyState` component is a flexible and reusable UI element designed to display when there's no content to show. It's perfect for scenarios like empty lists, search results with no matches, or initial states of features.
{% component path="empty-state" /%}
## Components
1. `EmptyState`: The main wrapper component
2. `EmptyStateHeading`: For the main heading
3. `EmptyStateText`: For descriptive text
4. `EmptyStateButton`: For a call-to-action button
## Usage
```jsx
import { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton } from '@kit/ui/empty-state';
function MyComponent() {
return (
<EmptyState>
<EmptyStateHeading>No results found</EmptyStateHeading>
<EmptyStateText>Try adjusting your search or filter to find what you're looking for.</EmptyStateText>
<EmptyStateButton>Clear filters</EmptyStateButton>
</EmptyState>
);
}
```
## Component Details
### EmptyState
The main container that wraps all other components.
- **Props**: Accepts all standard `div` props
- **Styling**:
- Flex container with centered content
- Rounded corners with a dashed border
- Light shadow for depth
### EmptyStateHeading
Used for the main heading of the empty state.
- **Props**: Accepts all standard `h3` props
- **Styling**:
- Large text (2xl)
- Bold font
- Tight letter spacing
### EmptyStateText
For descriptive text explaining the empty state or providing guidance.
- **Props**: Accepts all standard `p` props
- **Styling**:
- Small text
- Muted color for less emphasis
### EmptyStateButton
A button component for primary actions.
- **Props**: Accepts all props from the base `Button` component
- **Styling**:
- Margin top for spacing
- Inherits styles from the base `Button` component
## Features
1. **Flexible Structure**: Components can be used in any order, and additional custom elements can be added.
2. **Automatic Layout**: The component automatically arranges its children in a centered, vertical layout.
3. **Customizable**: Each subcomponent accepts className props for custom styling.
4. **Type-Safe**: Utilizes TypeScript for prop type checking.
## Customization
You can customize the appearance of each component by passing a `className` prop:
```tsx
<EmptyState className="bg-gray-100">
<EmptyStateHeading className="text-primary">Custom Heading</EmptyStateHeading>
<EmptyStateText className="text-lg">Larger descriptive text</EmptyStateText>
<EmptyStateButton className="bg-secondary">Custom Button</EmptyStateButton>
</EmptyState>
```
This `EmptyState` component provides a clean, consistent way to handle empty states in your application. Its modular design allows for easy customization while maintaining a cohesive look and feel across different use cases.

95
docs/components/if.mdoc Normal file
View File

@@ -0,0 +1,95 @@
---
status: "published"
label: "Conditional Rendering"
title: "Dynamic Conditional Rendering in the Next.js Supabase SaaS kit"
description: "Learn how to use the If component in the Next.js Supabase SaaS kit"
order: 4
---
The `If` component is a utility component for conditional rendering in React applications. It provides a clean, declarative way to render content based on a condition, with support for fallback content.
## Features
- Conditional rendering based on various types of conditions
- Support for render props pattern
- Optional fallback content
- Memoized for performance optimization
## Usage
```jsx
import { If } from '@kit/ui/if';
function MyComponent({ isLoggedIn, user }) {
return (
<If condition={isLoggedIn} fallback={<LoginPrompt />}>
{(value) => <WelcomeMessage user={user} />}
</If>
);
}
```
## Props
The `If` component accepts the following props:
- `condition: Condition<Value>` (required): The condition to evaluate. Can be any value, where falsy values (`false`, `null`, `undefined`, `0`, `''`) are considered false.
- `children: React.ReactNode | ((value: Value) => React.ReactNode)` (required): The content to render when the condition is truthy. Can be a React node or a function (render prop).
- `fallback?: React.ReactNode` (optional): Content to render when the condition is falsy.
## Types
```typescript
type Condition<Value = unknown> = Value | false | null | undefined | 0 | '';
```
## Examples
### Basic usage
```jsx
<If condition={isLoading}>
<LoadingSpinner />
</If>
```
### With fallback
```jsx
<If condition={hasData} fallback={<NoDataMessage />}>
<DataDisplay data={data} />
</If>
```
### Using render props
```jsx
<If condition={user}>
{(user) => <UserProfile username={user.name} />}
</If>
```
## Performance
The `If` component uses `useMemo` to optimize performance by memoizing the rendered output. This means it will only re-render when the `condition`, `children`, or `fallback` props change.
## Best Practices
1. Use the `If` component for simple conditional rendering to improve readability.
2. Leverage the render props pattern when you need to use the condition's value in the rendered content.
3. Provide a fallback for better user experience when the condition is false.
4. Remember that the condition is re-evaluated on every render, so keep it simple to avoid unnecessary computations.
## Typescript Support
The `If` component is fully typed - This allows for type-safe usage of the render props pattern:
```typescript
<If condition={user}>
{(user) => <UserProfile name={user.name} email={user.email} />}
</If>
```
The `If` component provides a clean and efficient way to handle conditional rendering in React applications, improving code readability and maintainability.

View File

@@ -0,0 +1,96 @@
---
status: "published"
label: "Loading Overlay"
title: "Loading Overlay Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Loading Overlay component in the Next.js Supabase SaaS kit"
order: 3
---
The LoadingOverlay component is a versatile UI element designed to display a loading state with a spinner and optional content. It's perfect for indicating background processes or page loads in your application.
{% component path="overlays/loading-overlay" /%}
## Features
- Customizable appearance through CSS classes
- Option for full-page overlay or inline loading indicator
- Spinner animation with customizable styling
- Ability to include additional content or messages
## Usage
```jsx
import { LoadingOverlay } from '@kit/ui/loading-overlay';
function MyComponent() {
return (
<LoadingOverlay>
Loading your content...
</LoadingOverlay>
);
}
```
## Props
The LoadingOverlay component accepts the following props:
- `children?: React.ReactNode`: Optional content to display below the spinner.
- `className?: string`: Additional CSS classes to apply to the container.
- `spinnerClassName?: string`: CSS classes to apply to the spinner component.
- `fullPage?: boolean`: Whether to display as a full-page overlay. Defaults to `true`.
## Examples
### Full-page overlay
```jsx
<LoadingOverlay>
Please wait while we load your dashboard...
</LoadingOverlay>
```
### Inline loading indicator
```jsx
<LoadingOverlay fullPage={false} className="h-40">
Fetching results...
</LoadingOverlay>
```
### Customized appearance
```jsx
<LoadingOverlay
className="bg-gray-800 text-white"
spinnerClassName="text-blue-500"
>
Processing your request...
</LoadingOverlay>
```
## Styling
The LoadingOverlay uses Tailwind CSS for styling. Key classes include:
- Flex layout with centered content: `flex flex-col items-center justify-center`
- Space between spinner and content: `space-y-4`
- Full-page overlay (when `fullPage` is true):
```
fixed left-0 top-0 z-[100] h-screen w-screen bg-background
```
You can further customize the appearance by passing additional classes through the `className` and `spinnerClassName` props.
## Accessibility
When using the LoadingOverlay, consider adding appropriate ARIA attributes to improve accessibility, such as `aria-busy="true"` on the parent element that's in a loading state.
## Best Practices
1. Use full-page overlays sparingly to avoid disrupting user experience.
2. Provide clear, concise messages to inform users about what's loading.
3. Consider using inline loading indicators for smaller UI elements or partial page updates.
4. Ensure sufficient contrast between the overlay and the spinner for visibility.
The LoadingOverlay component provides a simple yet effective way to indicate loading states in your application, enhancing user experience by providing visual feedback during asynchronous operations or content loads.

View File

@@ -0,0 +1,566 @@
---
status: "published"
label: "Marketing Components"
title: "Marketing Components in the Next.js Supabase SaaS kit"
description: "Learn how to use the Marketing components in the Next.js Supabase SaaS kit"
order: 8
---
Marketing components are designed to help you create beautiful and engaging marketing pages for your SaaS application. These components are built on top of the Shadcn UI library and are designed to work seamlessly with Next.js routing.
## Hero
The Hero component is a versatile and customizable landing page hero section for React applications.
{% component path="marketing/hero" /%}
### Import
```jsx
import { Hero } from '@kit/ui/marketing';
```
### Usage
```jsx
import { Hero, Pill, CtaButton } from '@kit/ui/marketing';
import Image from 'next/image';
function LandingPage() {
return (
<Hero
pill={<Pill>New Feature</Pill>}
title="Welcome to Our App"
subtitle="Discover the power of our innovative solution"
cta={<CtaButton>Get Started</CtaButton>}
image={
<Image
src="/hero-image.jpg"
alt="Hero Image"
width={1200}
height={600}
/>
}
/>
);
}
```
### Styling
The Hero component uses Tailwind CSS for styling. You can customize its appearance by:
1. Modifying the default classes in the component.
2. Passing additional classes via the `className` prop.
3. Overriding styles in your CSS using the appropriate selectors.
### Animations
By default, the Hero component applies entrance animations to its elements. You can disable these animations by setting the `animate` prop to `false`.
### Accessibility
The Hero component uses semantic HTML elements and follows accessibility best practices:
- The main title uses an `<h1>` tag (via the `HeroTitle` component).
- The subtitle uses an `<h3>` tag for proper heading hierarchy.
Ensure that any images passed via the `image` prop include appropriate `alt` text for screen readers.
### Notes
- The Hero component is designed to be flexible and can accommodate various content types through its props.
- For optimal performance, consider lazy-loading large images passed to the `image` prop.
- The component is responsive and adjusts its layout for different screen sizes.
### A Larger example straight from the kit
Below is a larger example of a Hero component with additional elements like a pill, CTA button, and image:
```tsx
import { Hero, Pill, CtaButton, GradientSecondaryText } from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
import { LayoutDashboard } from 'lucide-react';
import Image from 'next/image';
<Hero
pill={
<Pill label={'New'}>
<span>The leading SaaS Starter Kit for ambitious developers</span>
</Pill>
}
title={
<>
<span>The ultimate SaaS Starter</span>
<span>for your next project</span>
</>
}
subtitle={
<span>
Build and Ship a SaaS faster than ever before with the next-gen SaaS
Starter Kit. Ship your SaaS in days, not months.
</span>
}
cta={<MainCallToActionButton />}
image={
<Image
priority
className={
'delay-250 rounded-2xl border border-gray-200 duration-1000 ease-out animate-in fade-in zoom-in-50 fill-mode-both dark:border-primary/10'
}
width={3558}
height={2222}
src={`/images/dashboard.webp`}
alt={`App Image`}
/>
}
/>
function MainCallToActionButton() {
return (
<div className={'flex space-x-4'}>
<CtaButton>
<Link href={'/auth/sign-up'}>
<span className={'flex items-center space-x-0.5'}>
<span>
<Trans i18nKey={'common.getStarted'} />
</span>
<ArrowRightIcon
className={
'h-4 animate-in fade-in slide-in-from-left-8' +
' delay-1000 duration-1000 zoom-in fill-mode-both'
}
/>
</span>
</Link>
</CtaButton>
<CtaButton variant={'link'}>
<Link href={'/contact'}>
<Trans i18nKey={'common.contactUs'} />
</Link>
</CtaButton>
</div>
);
}
```
## HeroTitle
The `HeroTitle` component is a specialized heading component used within the Hero component to display the main title.
{% component path="marketing/hero-title" /%}
### Props
The `HeroTitle` component accepts the following props:
1. `asChild?: boolean`: Whether to render the component as a child of the `Slot` component.
2. `HTMLAttributes<HTMLHeadingElement>`: Additional attributes to apply to the heading element.
### Usage
```tsx
import { HeroTitle } from '@kit/ui/marketing';
function LandingPage() {
return (
<HeroTitle asChild>
Welcome to Our App
</HeroTitle>
);
}
```
## Pill
The `Pill` component is a small, rounded content container often used for highlighting or categorizing information.
{% component path="marketing/pill" /%}
### Usage
Use the `Pill` component to create a small, rounded content container with optional label text.
```tsx
import { Pill } from '@kit/ui/marketing';
function LandingPage() {
return (
<Pill label="New">
Discover the power of our innovative
</Pill>
);
}
```
## Features
The `FeatureShowcase`, `FeatureShowcaseIconContainer`, `FeatureGrid`, and `FeatureCard` components are designed to showcase product features on marketing pages.
### FeatureShowcase
The `FeatureShowcase` component is a layout component that showcases a feature with an icon, heading, and description.
### FeatureShowcaseIconContainer
The `FeatureShowcaseIconContainer` component is a layout component that contains an icon for the `FeatureShowcase` component.
### FeatureGrid
The `FeatureGrid` component is a layout component that arranges `FeatureCard` components in a grid layout.
### FeatureCard
The `FeatureCard` component is a card component that displays a feature with a label, description, and optional image.
### Usage
Use the `FeatureShowcase` component to showcase a feature with an icon, heading, and description.
```tsx
<div className={'container mx-auto'}>
<div
className={'flex flex-col space-y-16 xl:space-y-32 2xl:space-y-36'}
>
<FeatureShowcase
heading={
<>
<b className="font-semibold dark:text-white">
The ultimate SaaS Starter Kit
</b>
.{' '}
<GradientSecondaryText>
Unleash your creativity and build your SaaS faster than ever
with Makerkit.
</GradientSecondaryText>
</>
}
icon={
<FeatureShowcaseIconContainer>
<LayoutDashboard className="h-5" />
<span>All-in-one solution</span>
</FeatureShowcaseIconContainer>
}
>
<FeatureGrid>
<FeatureCard
className={
'relative col-span-2 overflow-hidden bg-violet-500 text-white lg:h-96'
}
label={'Beautiful Dashboard'}
description={`Makerkit provides a beautiful dashboard to manage your SaaS business.`}
>
<Image
className="absolute right-0 top-0 hidden h-full w-full rounded-tl-2xl border border-border lg:top-36 lg:flex lg:h-auto lg:w-10/12"
src={'/images/dashboard-header.webp'}
width={'2061'}
height={'800'}
alt={'Dashboard Header'}
/>
</FeatureCard>
<FeatureCard
className={
'relative col-span-2 w-full overflow-hidden lg:col-span-1'
}
label={'Authentication'}
description={`Makerkit provides a variety of providers to allow your users to sign in.`}
>
<Image
className="absolute left-16 top-32 hidden h-auto w-8/12 rounded-l-2xl lg:flex"
src={'/images/sign-in.webp'}
width={'1760'}
height={'1680'}
alt={'Sign In'}
/>
</FeatureCard>
<FeatureCard
className={
'relative col-span-2 overflow-hidden lg:col-span-1 lg:h-96'
}
label={'Multi Tenancy'}
description={`Multi tenant memberships for your SaaS business.`}
>
<Image
className="absolute right-0 top-0 hidden h-full w-full rounded-tl-2xl border lg:top-28 lg:flex lg:h-auto lg:w-8/12"
src={'/images/multi-tenancy.webp'}
width={'2061'}
height={'800'}
alt={'Multi Tenancy'}
/>
</FeatureCard>
<FeatureCard
className={'relative col-span-2 overflow-hidden lg:h-96'}
label={'Billing'}
description={`Makerkit supports multiple payment gateways to charge your customers.`}
>
<Image
className="absolute right-0 top-0 hidden h-full w-full rounded-tl-2xl border border-border lg:top-36 lg:flex lg:h-auto lg:w-11/12"
src={'/images/billing.webp'}
width={'2061'}
height={'800'}
alt={'Billing'}
/>
</FeatureCard>
</FeatureGrid>
</FeatureShowcase>
</div>
</div>
```
## SecondaryHero
The `SecondaryHero` component is a secondary hero section that can be used to highlight additional features or content on a landing page.
```tsx
<SecondaryHero
pill={<Pill>Get started for free. No credit card required.</Pill>}
heading="Fair pricing for all types of businesses"
subheading="Get started on our free plan and upgrade when you are ready."
/>
```
{% component path="marketing/secondary-hero" /%}
## Header
The `Header` component is a navigation header that can be used to display links to different sections of a marketing page.
```tsx
export function SiteHeader(props: { user?: User | null }) {
return (
<Header
logo={<AppLogo />}
navigation={<SiteNavigation />}
actions={<SiteHeaderAccountSection user={props.user ?? null} />}
/>
);
}
```
## Footer
The `Footer` component is a footer section that can be used to display links, social media icons, and other information on a marketing page.
```tsx
import { Footer } from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
import { AppLogo } from '~/components/app-logo';
import appConfig from '~/config/app.config';
export function SiteFooter() {
return (
<Footer
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
description={<Trans i18nKey="marketing.footerDescription" />}
copyright={
<Trans
i18nKey="marketing.copyright"
values={{
product: appConfig.name,
year: new Date().getFullYear(),
}}
/>
}
sections={[
{
heading: <Trans i18nKey="marketing.about" />,
links: [
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
],
},
{
heading: <Trans i18nKey="marketing.product" />,
links: [
{
href: '/docs',
label: <Trans i18nKey="marketing.documentation" />,
},
],
},
{
heading: <Trans i18nKey="marketing.legal" />,
links: [
{
href: '/terms-of-service',
label: <Trans i18nKey="marketing.termsOfService" />,
},
{
href: '/privacy-policy',
label: <Trans i18nKey="marketing.privacyPolicy" />,
},
{
href: '/cookie-policy',
label: <Trans i18nKey="marketing.cookiePolicy" />,
},
],
},
]}
/>
);
}
```
## CtaButton
The `CtaButton` component is a call-to-action button that can be used to encourage users to take a specific action.
{% component path="marketing/cta-button" /%}
```tsx
function MainCallToActionButton() {
return (
<div className={'flex space-x-4'}>
<CtaButton>
<Link href={'/auth/sign-up'}>
<span className={'flex items-center space-x-0.5'}>
<span>
<Trans i18nKey={'common.getStarted'} />
</span>
<ArrowRightIcon
className={
'h-4 animate-in fade-in slide-in-from-left-8' +
' delay-1000 duration-1000 zoom-in fill-mode-both'
}
/>
</span>
</Link>
</CtaButton>
<CtaButton variant={'link'}>
<Link href={'/contact'}>
<Trans i18nKey={'common.contactUs'} />
</Link>
</CtaButton>
</div>
);
}
```
## GradientSecondaryText
The `GradientSecondaryText` component is a text component that applies a gradient color to the text.
{% component path="marketing/gradient-secondary-text" /%}
```tsx
function GradientSecondaryTextExample() {
return (
<p>
<GradientSecondaryText>
Unleash your creativity and build your SaaS faster than ever with
Makerkit.
</GradientSecondaryText>
</p>
);
}
```
## GradientText
The `GradientText` component is a text component that applies a gradient color to the text.
{% component path="marketing/gradient-text" /%}
```tsx
function GradientTextExample() {
return (
<p>
<GradientText className={'from-primary/60 to-primary'}>
Unleash your creativity and build your SaaS faster than ever with
Makerkit.
</GradientText>
</p>
);
}
```
You can use the Tailwind CSS gradient utility classes to customize the gradient colors.
```tsx
<GradientText className={'from-violet-500 to-purple-700'}>
Unleash your creativity and build your SaaS faster than ever with Makerkit.
</GradientText>
```
## NewsletterSignupContainer
The `NewsletterSignupContainer` is a comprehensive component for handling newsletter signups in a marketing context. It manages the entire signup flow, including form display, loading state, and success/error messages.
{% component path="marketing/newsletter-sign-up" /%}
### Import
```jsx
import { NewsletterSignupContainer } from '@kit/ui/marketing';
```
### Props
The `NewsletterSignupContainer` accepts the following props:
- `onSignup`: the callback function that will notify you of a submission
- `heading`: the heading of the component
- `description`: the description below the heading
- `successMessage`: the text to display on successful submissions
- `errorMessage`: the text to display on errors
The component also accepts all standard HTML div attributes.
### Usage
```tsx
'use client';
import { NewsletterSignupContainer } from '@kit/ui/marketing';
function WrapperNewsletterComponent() {
const handleNewsletterSignup = async (email: string) => {
// Implement your signup logic here
await apiClient.subscribeToNewsletter(email);
};
return (
<NewsletterSignupContainer
onSignup={handleNewsletterSignup}
heading="Join Our Community"
description="Be the first to know about new features and updates."
successMessage="You're all set! Check your inbox for a confirmation email."
errorMessage="Oops! Something went wrong. Please try again later."
className="max-w-md mx-auto"
/>
);
}
```
Wrap the component into a parent `client` component as you'll need to pass the `onSignup` function to the component.
The `onSignup` function should handle the signup process, such as making an API request to subscribe the user to the newsletter, whichever provider you're using.
### Behavior
1. Initially displays the newsletter signup form.
2. When the form is submitted, it shows a loading spinner.
3. On successful signup, displays a success message.
4. If an error occurs during signup, shows an error message.
### Styling
The component uses Tailwind CSS for styling. The container is flexbox-based and centers its content. You can customize the appearance by passing additional classes via the `className` prop.
### Accessibility
- Uses semantic HTML structure with appropriate headings.
- Provides clear feedback for form submission states.
- Error and success messages are displayed using the `Alert` component for consistent styling and accessibility.
### Notes
- It integrates with other Makerkit UI components like `Alert`, `Heading`, and `Spinner`.
- The actual signup logic is decoupled from the component, allowing for flexibility in implementation.

View File

@@ -0,0 +1,345 @@
---
status: "published"
label: "Multi Step Forms"
title: "Multi Step Forms in the Next.js Supabase SaaS kit"
description: "Building multi-step forms in the Next.js Supabase SaaS kit"
order: 0
---
{% callout type="warning" title="Deprecated in v3" %}
`MultiStepForm` was removed in v3. Use `Form` with conditional step rendering instead.
{% /callout %}
The Multi-Step Form Component is a powerful and flexible wrapper around React Hook Form, Zod, and Shadcn UI. It provides a simple API to create multi-step forms with ease, perfect for complex registration processes, surveys, or any scenario where you need to break down a long form into manageable steps.
## Features
- Easy integration with React Hook Form and Zod for form management and validation
- Built-in step management
- Customizable layout and styling
- Progress tracking with optional Stepper component
- TypeScript support for type-safe form schemas
{% component path="multi-step-form" /%}
## Usage
Here's a basic example of how to use the Multi-Step Form Component:
```tsx
import { MultiStepForm, MultiStepFormStep } from '@kit/ui/multi-step-form';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
const FormSchema = createStepSchema({
step1: z.object({ /* ... */ }),
step2: z.object({ /* ... */ }),
});
export function MyForm() {
const form = useForm({
resolver: zodResolver(FormSchema),
// ...
});
const onSubmit = (data) => {
// Handle form submission
};
return (
<MultiStepForm schema={FormSchema} form={form} onSubmit={onSubmit}>
<MultiStepFormStep name="step1">
{/* Step 1 fields */}
</MultiStepFormStep>
<MultiStepFormStep name="step2">
{/* Step 2 fields */}
</MultiStepFormStep>
</MultiStepForm>
);
}
```
## Key Components
### MultiStepForm
The main wrapper component that manages the form state and step progression.
Props:
- `schema`: Zod schema for form validation
- `form`: React Hook Form's `useForm` instance
- `onSubmit`: Function to handle form submission
- `className`: Optional CSS classes
### MultiStepFormStep
Represents an individual step in the form.
Props:
- `name`: Unique identifier for the step (should match a key in your schema)
- `children`: Step content
### MultiStepFormHeader
Optional component for adding a header to your form, often used with the Stepper component.
### MultiStepFormContextProvider
Provides access to form context within child components.
### useMultiStepFormContext
The hook returns an object with the following properties:
- `form: UseFormReturn<z.infer<Schema>>` - The original form object.
- `currentStep: string` - The name of the current step.
- `currentStepIndex: number` - The index of the current step (0-based).
- `totalSteps: number` - The total number of steps in the form.
- `isFirstStep: boolean` - Whether the current step is the first step.
- `isLastStep: boolean` - Whether the current step is the last step.
- `nextStep: (e: React.SyntheticEvent) => void` - Function to move to the next step.
- `prevStep: (e: React.SyntheticEvent) => void` - Function to move to the previous step.
- `goToStep: (index: number) => void` - Function to jump to a specific step by index.
- `direction: 'forward' | 'backward' | undefined` - The direction of the last step change.
- `isStepValid: () => boolean` - Function to check if the current step is valid.
- `isValid: boolean` - Whether the entire form is valid.
- `errors: FieldErrors<z.infer<Schema>>` - Form errors from React Hook Form.
- `mutation: UseMutationResult` - A mutation object for handling form submission.
## Example
Here's a more complete example of a multi-step form with three steps: Account, Profile, and Review. The form uses Zod for schema validation and React Hook Form for form management.
```tsx
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
MultiStepForm,
MultiStepFormContextProvider,
MultiStepFormHeader,
MultiStepFormStep,
createStepSchema,
useMultiStepFormContext,
} from '@kit/ui/multi-step-form';
import { Stepper } from '@kit/ui/stepper';
const FormSchema = createStepSchema({
account: z.object({
username: z.string().min(3),
email: z.string().email(),
}),
profile: z.object({
password: z.string().min(8),
age: z.coerce.number().min(18),
}),
});
type FormValues = z.infer<typeof FormSchema>;
export function MultiStepFormDemo() {
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
account: {
username: '',
email: '',
},
profile: {
password: '',
},
},
reValidateMode: 'onBlur',
mode: 'onBlur',
});
const onSubmit = (data: FormValues) => {
console.log('Form submitted:', data);
};
return (
<MultiStepForm
className={'space-y-10 p-8 rounded-xl border'}
schema={FormSchema}
form={form}
onSubmit={onSubmit}
>
<MultiStepFormHeader
className={'flex w-full flex-col justify-center space-y-6'}
>
<h2 className={'text-xl font-bold'}>Create your account</h2>
<MultiStepFormContextProvider>
{({ currentStepIndex }) => (
<Stepper
variant={'numbers'}
steps={['Account', 'Profile', 'Review']}
currentStep={currentStepIndex}
/>
)}
</MultiStepFormContextProvider>
</MultiStepFormHeader>
<MultiStepFormStep name="account">
<AccountStep />
</MultiStepFormStep>
<MultiStepFormStep name="profile">
<ProfileStep />
</MultiStepFormStep>
<MultiStepFormStep name="review">
<ReviewStep />
</MultiStepFormStep>
</MultiStepForm>
);
}
function AccountStep() {
const { form, nextStep, isStepValid } = useMultiStepFormContext();
return (
<Form {...form}>
<div className={'flex flex-col gap-4'}>
<FormField
name="account.username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="account.email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button onClick={nextStep} disabled={!isStepValid()}>
Next
</Button>
</div>
</div>
</Form>
);
}
function ProfileStep() {
const { form, nextStep, prevStep } = useMultiStepFormContext();
return (
<Form {...form}>
<div className={'flex flex-col gap-4'}>
<FormField
name="profile.password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="profile.age"
render={({ field }) => (
<FormItem>
<FormLabel>Age</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button type={'button'} variant={'outline'} onClick={prevStep}>
Previous
</Button>
<Button onClick={nextStep}>Next</Button>
</div>
</div>
</Form>
);
}
function ReviewStep() {
const { prevStep, form } = useMultiStepFormContext<typeof FormSchema>();
const values = form.getValues();
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-4'}>
<div>Great! Please review the values.</div>
<div className={'flex flex-col space-y-2 text-sm'}>
<div>
<span>Username</span>: <span>{values.account.username}</span>
</div>
<div>
<span>Email</span>: <span>{values.account.email}</span>
</div>
<div>
<span>Age</span>: <span>{values.profile.age}</span>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type={'button'} variant={'outline'} onClick={prevStep}>
Back
</Button>
<Button type={'submit'}>Create Account</Button>
</div>
</div>
);
}
```
The inner components `AccountStep`, `ProfileStep`, and `ReviewStep` represent the individual steps of the form. They use the `useMultiStepFormContext` hook to access form utilities like `nextStep`, `prevStep`, and `isStepValid`.
These are built using ShadcnUI - so please [do refer to the ShadcnUI documentation](https://ui.shadcn.com/docs/components/form) for more information on how to use the components.
## Tips
1. Use the `createStepSchema` helper to easily create Zod schemas for your multi-step form.
2. Leverage the `useMultiStepFormContext` hook in your step components to access form utilities.
3. Combine with the Stepper component for visual progress indication.
4. Customize the look and feel using the provided `className` props and your own CSS.
The Multi-Step Form Component simplifies the creation of complex, multi-step forms while providing a great user experience. It's flexible enough to handle a wide variety of use cases while keeping your code clean and maintainable.

136
docs/components/page.mdoc Normal file
View File

@@ -0,0 +1,136 @@
---
status: "published"
label: "Page"
title: "Page Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Page component in the Next.js Supabase SaaS kit"
order: 5
---
The Page component is a versatile layout component that provides different page structures based on the specified style. It's designed to create consistent layouts across your application with support for sidebar and header-based designs.
## Usage
```jsx
import { Page, PageNavigation, PageBody, PageHeader } from '@kit/ui/page';
function MyPage() {
return (
<Page style="sidebar">
<PageNavigation>
{/* Navigation content */}
</PageNavigation>
<PageHeader title="Dashboard" description="Welcome to your dashboard">
{/* Optional header content */}
</PageHeader>
<PageBody>
{/* Main page content */}
</PageBody>
</Page>
);
}
```
## Page Component Props
- `style?: 'sidebar' | 'header' | 'custom'`: Determines the layout style (default: 'sidebar')
- `contentContainerClassName?: string`: Custom class for the content container
- `className?: string`: Additional classes for the main container
- `sticky?: boolean`: Whether to make the header sticky (for 'header' style)
## Sub-components
### PageNavigation
Wraps the navigation content, typically used within the Page component.
### PageMobileNavigation
Wraps the mobile navigation content, displayed only on smaller screens.
### PageBody
Contains the main content of the page.
Props:
- `className?: string`: Additional classes for the body container
### PageHeader
Displays the page title and description.
Props:
- `title?: string | React.ReactNode`: The page title
- `description?: string | React.ReactNode`: The page description
- `className?: string`: Additional classes for the header container
### PageTitle
Renders the main title of the page.
### PageDescription
Renders the description text below the page title.
## Layout Styles
### Sidebar Layout
The default layout, featuring a sidebar navigation and main content area.
### Header Layout
A layout with a top navigation bar and content below.
### Custom Layout
Allows for complete custom layouts by directly rendering children.
## Examples
### Sidebar Layout
```jsx
<Page style="sidebar">
<PageNavigation>
<SidebarContent />
</PageNavigation>
<PageHeader title="Dashboard" description="Overview of your account">
<UserMenu />
</PageHeader>
<PageBody>
<DashboardContent />
</PageBody>
</Page>
```
### Header Layout
```jsx
<Page style="header" sticky={true}>
<PageNavigation>
<HeaderNavLinks />
</PageNavigation>
<PageMobileNavigation>
<MobileMenu />
</PageMobileNavigation>
<PageBody>
<PageHeader title="Profile" description="Manage your account settings" />
<ProfileSettings />
</PageBody>
</Page>
```
## Customization
The Page component and its sub-components use Tailwind CSS classes for styling. You can further customize the appearance by passing additional classes through the `className` props or by modifying the default classes in the component implementation.
## Best Practices
1. Use consistent layout styles across similar pages for a cohesive user experience.
2. Leverage the PageHeader component to provide clear page titles and descriptions.
3. Utilize the PageNavigation and PageMobileNavigation components to create responsive navigation experiences.
4. When using the 'custom' style, ensure you handle responsive behavior manually.
The Page component and its related components provide a flexible system for creating structured, responsive layouts in your React application, promoting consistency and ease of maintenance across your project.

1621
docs/components/shadcn.mdoc Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
---
status: "published"
label: "Stepper"
title: "Stepper Component in the Next.js Supabase SaaS kit"
description: "Learn how to use the Stepper component in the Next.js Supabase SaaS kit"
order: 1
---
The Stepper component is a versatile UI element designed to display a series of steps in a process or form. It provides visual feedback on the current step and supports different visual styles.
{% component path="steppers/stepper" /%}
## Usage
```jsx
import { Stepper } from '@kit/ui/stepper';
function MyComponent() {
return (
<Stepper
steps={['Step 1', 'Step 2', 'Step 3']}
currentStep={1}
variant="default"
/>
);
}
```
## Props
The Stepper component accepts the following props:
- `steps: string[]` (required): An array of strings representing the labels for each step.
- `currentStep: number` (required): The index of the currently active step (0-based).
- `variant?: 'numbers' | 'default'` (optional): The visual style of the stepper. Defaults to 'default'.
## Variants
The Stepper component supports two visual variants:
1. `default`: Displays steps as a horizontal line with labels underneath.
2. `numbers`: Displays steps as numbered circles with labels between them.
{% component path="steppers/stepper-numbers" /%}
## Features
- Responsive design that adapts to different screen sizes
- Dark mode support
- Customizable appearance through CSS classes and variants
- Accessibility support with proper ARIA attributes
## Component Breakdown
### Main Stepper Component
The main `Stepper` function renders the overall structure of the component. It:
- Handles prop validation and default values
- Renders nothing if there are fewer than two steps
- Uses a callback to render individual steps
- Applies different CSS classes based on the chosen variant
### Steps Rendering
Steps are rendered using a combination of divs and spans, with different styling applied based on:
- Whether the step is currently selected
- The chosen variant (default or numbers)
### StepDivider Component
For the 'numbers' variant, a `StepDivider` component is used to render the labels between numbered steps. It includes:
- Styling for selected and non-selected states
- A divider line between steps (except for the last step)
## Styling
The component uses a combination of:
- Tailwind CSS classes for basic styling
- `cva` (Class Variance Authority) for managing variant-based styling
- `classNames` function for conditional class application
## Accessibility
- The component uses `aria-selected` to indicate the current step
- Labels are associated with their respective steps for screen readers
## Customization
You can further customize the appearance of the Stepper by:
1. Modifying the `classNameBuilder` function to add or change CSS classes
2. Adjusting the Tailwind CSS classes in the component JSX
3. Creating new variants in the `cva` configuration
## Example
```jsx
<Stepper
steps={['Account', 'Personal Info', 'Review']}
currentStep={1}
variant="numbers"
/>
```
This will render a numbered stepper with three steps, where "Personal Info" is the current (selected) step.
The Stepper component provides a flexible and visually appealing way to guide users through multi-step processes in your application. Its support for different variants and easy customization makes it adaptable to various design requirements.

View File

@@ -0,0 +1,133 @@
---
status: "published"
label: "Application Configuration"
title: "Application Configuration in the Next.js Supabase SaaS Kit"
description: "Configure your app name, URL, theme colors, and locale settings in the Next.js Supabase SaaS Kit using environment variables."
order: 1
---
The application configuration at `apps/web/config/app.config.ts` defines your SaaS application's core settings: name, URL, theme, and locale. Configure these using environment variables rather than editing the file directly.
{% alert type="default" title="Configuration Approach" %}
All configuration is driven by environment variables and validated with Zod at build time. Invalid configuration fails the build immediately, preventing deployment of broken settings.
{% /alert %}
## Configuration Options
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_PRODUCT_NAME` | Yes | Your product name (e.g., "Acme SaaS") |
| `NEXT_PUBLIC_SITE_TITLE` | Yes | Browser title tag and SEO title |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | Yes | Meta description for SEO |
| `NEXT_PUBLIC_SITE_URL` | Yes | Full URL with protocol (e.g., `https://myapp.com`) |
| `NEXT_PUBLIC_DEFAULT_LOCALE` | No | Default language code (default: `en`) |
| `NEXT_PUBLIC_DEFAULT_THEME_MODE` | No | Theme: `light`, `dark`, or `system` |
| `NEXT_PUBLIC_THEME_COLOR` | Yes | Light theme color (hex, e.g., `#ffffff`) |
| `NEXT_PUBLIC_THEME_COLOR_DARK` | Yes | Dark theme color (hex, e.g., `#0a0a0a`) |
## Basic Setup
Add these to your `.env` file:
```bash
NEXT_PUBLIC_SITE_URL=https://myapp.com
NEXT_PUBLIC_PRODUCT_NAME="My SaaS App"
NEXT_PUBLIC_SITE_TITLE="My SaaS App - Build Faster"
NEXT_PUBLIC_SITE_DESCRIPTION="The easiest way to build your SaaS application"
NEXT_PUBLIC_DEFAULT_LOCALE=en
NEXT_PUBLIC_DEFAULT_THEME_MODE=light
NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
```
## How It Works
The configuration file parses environment variables through a Zod schema:
```typescript
const appConfig = AppConfigSchema.parse({
name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
title: process.env.NEXT_PUBLIC_SITE_TITLE,
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION,
url: process.env.NEXT_PUBLIC_SITE_URL,
locale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE,
theme: process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE,
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR,
themeColorDark: process.env.NEXT_PUBLIC_THEME_COLOR_DARK,
production,
});
```
## Environment-Specific Configuration
Structure your environment files for different deployment stages:
| File | Purpose |
|------|---------|
| `.env` | Shared settings across all environments |
| `.env.development` | Local development overrides |
| `.env.production` | Production-specific settings |
| `.env.local` | Local secrets (git-ignored) |
**Example for development:**
```bash
# .env.development
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
**Example for production:**
```bash
# .env.production (or CI/CD environment)
NEXT_PUBLIC_SITE_URL=https://myapp.com
```
## Common Configuration Scenarios
### B2C SaaS Application
```bash
NEXT_PUBLIC_PRODUCT_NAME="PhotoEdit Pro"
NEXT_PUBLIC_SITE_TITLE="PhotoEdit Pro - Edit Photos Online"
NEXT_PUBLIC_SITE_DESCRIPTION="Professional photo editing in your browser. No download required."
NEXT_PUBLIC_SITE_URL=https://photoedit.pro
NEXT_PUBLIC_DEFAULT_THEME_MODE=system
```
### B2B SaaS Application
```bash
NEXT_PUBLIC_PRODUCT_NAME="TeamFlow"
NEXT_PUBLIC_SITE_TITLE="TeamFlow - Project Management for Teams"
NEXT_PUBLIC_SITE_DESCRIPTION="Streamline your team's workflow with powerful project management tools."
NEXT_PUBLIC_SITE_URL=https://teamflow.io
NEXT_PUBLIC_DEFAULT_THEME_MODE=light
```
## Common Pitfalls
1. **HTTP in production**: The build fails if `NEXT_PUBLIC_SITE_URL` uses `http://` in production. Always use `https://` for production deployments.
2. **Same theme colors**: The build fails if `NEXT_PUBLIC_THEME_COLOR` equals `NEXT_PUBLIC_THEME_COLOR_DARK`. They must be different values.
3. **Missing trailing slash**: Don't include a trailing slash in `NEXT_PUBLIC_SITE_URL`. Use `https://myapp.com` not `https://myapp.com/`.
4. **Forgetting to rebuild**: Environment variable changes require a rebuild. Run `pnpm build` after changing production values.
## Accessing Configuration in Code
Import the configuration anywhere in your application:
```typescript
import appConfig from '~/config/app.config';
// Access values
console.log(appConfig.name); // "My SaaS App"
console.log(appConfig.url); // "https://myapp.com"
console.log(appConfig.theme); // "light"
console.log(appConfig.production); // true/false
```
## Related Topics
- [Environment Variables](/docs/next-supabase-turbo/configuration/environment-variables) - Complete environment variable reference
- [Feature Flags](/docs/next-supabase-turbo/configuration/feature-flags-configuration) - Enable or disable features
- [Going to Production](/docs/next-supabase-turbo/going-to-production/checklist) - Production deployment checklist

View File

@@ -0,0 +1,206 @@
---
status: "published"
label: "Authentication Configuration"
title: "Authentication Configuration: Password, Magic Link, OAuth, MFA"
description: "Configure email/password, magic link, OTP, and OAuth authentication in the Next.js Supabase SaaS Kit. Set up password requirements, identity linking, and CAPTCHA protection."
order: 2
---
The authentication configuration at `apps/web/config/auth.config.ts` controls which sign-in methods are available and how they behave. Configure using environment variables to enable password, magic link, OTP, or OAuth authentication.
{% alert type="default" title="Quick Setup" %}
Password authentication is enabled by default. To switch to magic link or add OAuth providers, set the corresponding environment variables and configure the providers in your Supabase Dashboard.
{% /alert %}
## Authentication Methods
| Method | Environment Variable | Default | Description |
|--------|---------------------|---------|-------------|
| Password | `NEXT_PUBLIC_AUTH_PASSWORD` | `true` | Traditional email/password |
| Magic Link | `NEXT_PUBLIC_AUTH_MAGIC_LINK` | `false` | Passwordless email links |
| OTP | `NEXT_PUBLIC_AUTH_OTP` | `false` | One-time password codes |
| OAuth | Configure in code | `['google']` | Third-party providers |
## Basic Configuration
```bash
# Enable password authentication (default)
NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_AUTH_OTP=false
```
## Switching to Magic Link
```bash
NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_MAGIC_LINK=true
```
## Switching to OTP
```bash
NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_OTP=true
```
When using OTP, update your Supabase email templates in `apps/web/supabase/config.toml`:
```toml
[auth.email.template.confirmation]
subject = "Confirm your email"
content_path = "./supabase/templates/otp.html"
[auth.email.template.magic_link]
subject = "Sign in to Makerkit"
content_path = "./supabase/templates/otp.html"
```
Also update the templates in your Supabase Dashboard under **Authentication > Templates** for production.
## OAuth Providers
### Supported Providers
The kit supports all Supabase OAuth providers:
| Provider | ID | Provider | ID |
|----------|-----|----------|-----|
| Apple | `apple` | Kakao | `kakao` |
| Azure | `azure` | Keycloak | `keycloak` |
| Bitbucket | `bitbucket` | LinkedIn | `linkedin` |
| Discord | `discord` | LinkedIn OIDC | `linkedin_oidc` |
| Facebook | `facebook` | Notion | `notion` |
| Figma | `figma` | Slack | `slack` |
| GitHub | `github` | Spotify | `spotify` |
| GitLab | `gitlab` | Twitch | `twitch` |
| Google | `google` | Twitter | `twitter` |
| Fly | `fly` | WorkOS | `workos` |
| | | Zoom | `zoom` |
### Configuring OAuth Providers
OAuth providers are configured in two places:
1. **Supabase Dashboard**: Enable and configure credentials (Client ID, Client Secret)
2. **Code**: Display in the sign-in UI
Edit `apps/web/config/auth.config.ts` to change which providers appear:
```typescript
providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true',
oAuth: ['google', 'github'], // Add providers here
}
```
{% alert type="warning" title="Provider Configuration" %}
Adding a provider to the array only displays it in the UI. You must also configure the provider in your Supabase Dashboard with valid credentials. See [Supabase's OAuth documentation](https://supabase.com/docs/guides/auth/social-login).
{% /alert %}
### OAuth Scopes
Some providers require specific scopes. Configure them in `packages/features/auth/src/components/oauth-providers.tsx`:
```tsx
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
azure: 'email',
keycloak: 'openid',
// add your OAuth providers here
};
```
The kit ships with Azure and Keycloak scopes configured. Add additional providers as needed based on their OAuth requirements.
### Local Development OAuth
For local OAuth testing, configure your providers in `apps/web/supabase/config.toml`. See [Supabase's local development OAuth guide](https://supabase.com/docs/guides/local-development/managing-config).
## Identity Linking
Allow users to link multiple authentication methods (e.g., link Google to an existing email account):
```bash
NEXT_PUBLIC_AUTH_IDENTITY_LINKING=true
```
This must also be enabled in your Supabase Dashboard under **Authentication > Settings**.
## Password Requirements
Enforce password strength rules:
```bash
NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE=true
NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS=true
NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS=true
```
These rules validate:
1. At least one uppercase letter
2. At least one number
3. At least one special character
## CAPTCHA Protection
Protect authentication forms with Cloudflare Turnstile:
```bash
NEXT_PUBLIC_CAPTCHA_SITE_KEY=your-site-key
CAPTCHA_SECRET_TOKEN=your-secret-token
```
Get your keys from the [Cloudflare Turnstile dashboard](https://dash.cloudflare.com/?to=/:account/turnstile).
## Terms and Conditions
Display a terms checkbox during sign-up:
```bash
NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX=true
```
## MFA (Multi-Factor Authentication)
MFA is built into Supabase Auth. To enforce MFA for specific operations:
1. Enable MFA in your Supabase Dashboard
2. Customize RLS policies per [Supabase's MFA documentation](https://supabase.com/blog/mfa-auth-via-rls)
The super admin dashboard already requires MFA for access.
## How It Works
The configuration file parses environment variables through a Zod schema:
```typescript
const authConfig = AuthConfigSchema.parse({
captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,
displayTermsCheckbox:
process.env.NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX === 'true',
enableIdentityLinking:
process.env.NEXT_PUBLIC_AUTH_IDENTITY_LINKING === 'true',
providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true',
oAuth: ['google'],
},
});
```
## Common Pitfalls
1. **OAuth not working**: Ensure the provider is configured in both the code and Supabase Dashboard with matching credentials.
2. **Magic link emails not arriving**: Check your email configuration and Supabase email templates. For local development, emails go to Mailpit at `localhost:54324`.
3. **OTP using wrong template**: Both OTP and magic link use the same Supabase email template type. Use `otp.html` for OTP or `magic-link.html` for magic links, but not both simultaneously.
4. **Identity linking fails**: Must be enabled in both environment variables and Supabase Dashboard.
## Related Topics
- [Authentication API](/docs/next-supabase-turbo/api/authentication-api) - Check user authentication status in code
- [Production Authentication](/docs/next-supabase-turbo/going-to-production/authentication) - Configure authentication for production
- [Authentication Emails](/docs/next-supabase-turbo/emails/authentication-emails) - Customize email templates
- [Environment Variables](/docs/next-supabase-turbo/configuration/environment-variables) - Complete variable reference

View File

@@ -0,0 +1,305 @@
---
status: "published"
title: "Environment Variables Reference for the Next.js Supabase SaaS Kit"
label: "Environment Variables"
order: 0
description: "Complete reference for all environment variables in the Next.js Supabase SaaS Kit, including Supabase, Stripe, email, and feature flag configuration."
---
This page documents all environment variables used by the Next.js Supabase SaaS Kit. Variables are organized by category and include their purpose, required status, and default values.
## Environment File Structure
| File | Purpose | Git Status |
|------|---------|------------|
| `.env` | Shared settings across all environments | Committed |
| `.env.development` | Development-specific overrides | Committed |
| `.env.production` | Production-specific settings | Committed |
| `.env.local` | Local secrets and overrides | Git-ignored |
**Priority order**: `.env.local` > `.env.development`/`.env.production` > `.env`
## Required Variables
These variables must be set for the application to start:
```bash
# Supabase (required)
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=your-public-key
SUPABASE_SECRET_KEY=your-service-role-key
# App identity (required)
NEXT_PUBLIC_SITE_URL=https://yourapp.com
NEXT_PUBLIC_PRODUCT_NAME=Your Product
NEXT_PUBLIC_SITE_TITLE="Your Product - Tagline"
NEXT_PUBLIC_SITE_DESCRIPTION="Your product description"
```
## Core Configuration
### Site Identity
```bash
NEXT_PUBLIC_SITE_URL=https://example.com
NEXT_PUBLIC_PRODUCT_NAME=Makerkit
NEXT_PUBLIC_SITE_TITLE="Makerkit - Build SaaS Faster"
NEXT_PUBLIC_SITE_DESCRIPTION="Production-ready SaaS starter kit"
NEXT_PUBLIC_DEFAULT_LOCALE=en
```
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_SITE_URL` | Yes | Full URL with protocol |
| `NEXT_PUBLIC_PRODUCT_NAME` | Yes | Product name shown in UI |
| `NEXT_PUBLIC_SITE_TITLE` | Yes | Browser title and SEO |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | Yes | Meta description |
| `NEXT_PUBLIC_DEFAULT_LOCALE` | No | Default language (default: `en`) |
### Theme
```bash
NEXT_PUBLIC_DEFAULT_THEME_MODE=light
NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
```
| Variable | Options | Default | Description |
|----------|---------|---------|-------------|
| `NEXT_PUBLIC_DEFAULT_THEME_MODE` | `light`, `dark`, `system` | `light` | Initial theme |
| `NEXT_PUBLIC_THEME_COLOR` | Hex color | Required | Light theme color |
| `NEXT_PUBLIC_THEME_COLOR_DARK` | Hex color | Required | Dark theme color |
| `NEXT_PUBLIC_ENABLE_THEME_TOGGLE` | `true`, `false` | `true` | Allow theme switching |
## Supabase Configuration
```bash
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=your-public-key
SUPABASE_SECRET_KEY=your-service-role-key
SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret
```
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL |
| `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Yes | Public anon key |
| `SUPABASE_SECRET_KEY` | Yes | Service role key (keep secret) |
| `SUPABASE_DB_WEBHOOK_SECRET` | No | Webhook verification secret |
{% alert type="warning" title="Legacy Key Names" %}
If you're using a version prior to 2.12.0, use `NEXT_PUBLIC_SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` instead.
{% /alert %}
## Authentication
```bash
NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_AUTH_OTP=false
NEXT_PUBLIC_AUTH_IDENTITY_LINKING=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
CAPTCHA_SECRET_TOKEN=
NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX=false
```
| Variable | Default | Description |
|----------|---------|-------------|
| `NEXT_PUBLIC_AUTH_PASSWORD` | `true` | Enable password auth |
| `NEXT_PUBLIC_AUTH_MAGIC_LINK` | `false` | Enable magic link auth |
| `NEXT_PUBLIC_AUTH_OTP` | `false` | Enable OTP auth |
| `NEXT_PUBLIC_AUTH_IDENTITY_LINKING` | `false` | Allow identity linking |
| `NEXT_PUBLIC_CAPTCHA_SITE_KEY` | - | Cloudflare Turnstile site key |
| `CAPTCHA_SECRET_TOKEN` | - | Cloudflare Turnstile secret |
| `NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX` | `false` | Show terms checkbox |
### Password Requirements
```bash
NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE=false
NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS=false
NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS=false
```
## Navigation and Layout
```bash
NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar
NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED=false
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar
NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED=false
NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=icon
NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER=true
```
| Variable | Options | Default | Description |
|----------|---------|---------|-------------|
| `NEXT_PUBLIC_USER_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Personal nav layout |
| `NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start collapsed |
| `NEXT_PUBLIC_TEAM_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Team nav layout |
| `NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start collapsed |
| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `offcanvas`, `icon`, `none` | `icon` | Collapse behavior |
| `NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER` | `true`, `false` | `true` | Show collapse button |
## Feature Flags
```bash
NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=false
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=false
NEXT_PUBLIC_ENABLE_VERSION_UPDATER=false
NEXT_PUBLIC_LANGUAGE_PRIORITY=application
```
| Variable | Default | Description |
|----------|---------|-------------|
| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION` | `false` | Users can delete accounts |
| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING` | `false` | Personal subscription billing |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS` | `true` | Enable team features |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` | `true` | Users can create teams |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` | `false` | Users can delete teams |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING` | `false` | Team subscription billing |
| `NEXT_PUBLIC_ENABLE_NOTIFICATIONS` | `true` | In-app notifications |
| `NEXT_PUBLIC_REALTIME_NOTIFICATIONS` | `false` | Live notification updates |
| `NEXT_PUBLIC_ENABLE_VERSION_UPDATER` | `false` | Check for updates |
| `NEXT_PUBLIC_LANGUAGE_PRIORITY` | `application` | `user` or `application` |
## Billing Configuration
### Provider Selection
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe
```
Options: `stripe` or `lemon-squeezy`
### Stripe
```bash
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
```
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Yes (Stripe) | Publishable key |
| `STRIPE_SECRET_KEY` | Yes (Stripe) | Secret key |
| `STRIPE_WEBHOOK_SECRET` | Yes (Stripe) | Webhook signing secret |
### Lemon Squeezy
```bash
LEMON_SQUEEZY_SECRET_KEY=your-secret-key
LEMON_SQUEEZY_STORE_ID=your-store-id
LEMON_SQUEEZY_SIGNING_SECRET=your-signing-secret
```
| Variable | Required | Description |
|----------|----------|-------------|
| `LEMON_SQUEEZY_SECRET_KEY` | Yes (LS) | API secret key |
| `LEMON_SQUEEZY_STORE_ID` | Yes (LS) | Store identifier |
| `LEMON_SQUEEZY_SIGNING_SECRET` | Yes (LS) | Webhook signing secret |
## Email Configuration
### Provider Selection
```bash
MAILER_PROVIDER=nodemailer
```
Options: `nodemailer` or `resend`
### Common Settings
```bash
EMAIL_SENDER="Your App <noreply@yourapp.com>"
CONTACT_EMAIL=contact@yourapp.com
```
### Resend
```bash
RESEND_API_KEY=re_...
```
### Nodemailer (SMTP)
```bash
EMAIL_HOST=smtp.provider.com
EMAIL_PORT=587
EMAIL_USER=your-username
EMAIL_PASSWORD=your-password
EMAIL_TLS=true
```
## CMS Configuration
### Provider Selection
```bash
CMS_CLIENT=keystatic
```
Options: `keystatic` or `wordpress`
### Keystatic
```bash
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=local
NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content
KEYSTATIC_PATH_PREFIX=apps/web
```
For GitHub storage:
```bash
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github
NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO=owner/repo
KEYSTATIC_GITHUB_TOKEN=github_pat_...
```
| Variable | Options | Description |
|----------|---------|-------------|
| `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND` | `local`, `cloud`, `github` | Storage backend |
| `NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH` | Path | Content directory |
| `KEYSTATIC_PATH_PREFIX` | Path | Monorepo prefix |
| `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` | `owner/repo` | GitHub repository |
| `KEYSTATIC_GITHUB_TOKEN` | Token | GitHub access token |
### WordPress
```bash
WORDPRESS_API_URL=https://your-site.com/wp-json
```
## Security Best Practices
1. **Never commit secrets**: Use `.env.local` for sensitive values
2. **Use CI/CD variables**: Store production secrets in your deployment platform
3. **Rotate keys regularly**: Especially after team member changes
4. **Validate in production**: The kit validates configuration at build time
## Common Pitfalls
1. **HTTP in production**: `NEXT_PUBLIC_SITE_URL` must use `https://` in production builds.
2. **Same theme colors**: `NEXT_PUBLIC_THEME_COLOR` and `NEXT_PUBLIC_THEME_COLOR_DARK` must be different.
3. **Missing Supabase keys**: The app won't start without valid Supabase credentials.
4. **Forgetting to restart**: After changing environment variables, you may need to restart the development server.
5. **Wrong file for secrets**: Put secrets in `.env.local` (git-ignored), not `.env` (committed).
## Related Topics
- [Application Configuration](/docs/next-supabase-turbo/configuration/application-configuration) - Core app settings
- [Authentication Configuration](/docs/next-supabase-turbo/configuration/authentication-configuration) - Auth setup
- [Feature Flags](/docs/next-supabase-turbo/configuration/feature-flags-configuration) - Toggle features
- [Going to Production](/docs/next-supabase-turbo/going-to-production/checklist) - Deployment checklist

View File

@@ -0,0 +1,260 @@
---
status: "published"
label: "Feature Flags"
title: "Feature Flags Configuration in the Next.js Supabase SaaS Kit"
description: "Enable or disable team accounts, billing, notifications, and theme toggling in the Next.js Supabase SaaS Kit using feature flags."
order: 4
---
The feature flags configuration at `apps/web/config/feature-flags.config.ts` controls which features are enabled in your application. Toggle team accounts, billing, notifications, and more using environment variables.
{% alert type="default" title="Feature Flags vs Configuration" %}
Feature flags control whether functionality is available to users. Use them to ship different product tiers, run A/B tests, or disable features during maintenance. Unlike configuration, feature flags are meant to change at runtime or between deployments.
{% /alert %}
{% alert type="warning" title="Defaults Note" %}
The "Default" column shows what the code uses if the environment variable is not set. The kit's `.env` file ships with different values to demonstrate features. Check your `.env` file for the actual starting values.
{% /alert %}
## Available Feature Flags
| Flag | Environment Variable | Default | Description |
|------|---------------------|---------|-------------|
| Theme Toggle | `NEXT_PUBLIC_ENABLE_THEME_TOGGLE` | `true` | Allow users to switch themes |
| Account Deletion | `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION` | `false` | Users can delete their accounts |
| Team Accounts | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS` | `true` | Enable team/organization features |
| Team Creation | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` | `true` | Users can create new teams |
| Team Deletion | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` | `false` | Users can delete their teams |
| Personal Billing | `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING` | `false` | Billing for personal accounts |
| Team Billing | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING` | `false` | Billing for team accounts |
| Notifications | `NEXT_PUBLIC_ENABLE_NOTIFICATIONS` | `true` | In-app notification system |
| Realtime Notifications | `NEXT_PUBLIC_REALTIME_NOTIFICATIONS` | `false` | Live notification updates |
| Version Updater | `NEXT_PUBLIC_ENABLE_VERSION_UPDATER` | `false` | Check for app updates |
| Teams Only | `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY` | `false` | Skip personal accounts, use teams only |
| Language Priority | `NEXT_PUBLIC_LANGUAGE_PRIORITY` | `application` | User vs app language preference |
## Common Configurations
### B2C SaaS (Personal Accounts Only)
For consumer applications where each user has their own account and subscription:
```bash
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=false
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
```
### B2B SaaS (Team Accounts Only)
For business applications where organizations subscribe and manage team members. Enable `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY` to skip personal accounts entirely:
```bash
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
```
When `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true`:
- Users are automatically redirected away from personal account routes to their team workspace
- The personal account section in the sidebar/workspace switcher is hidden
- After sign-in, users land on their team dashboard instead of a personal home page
- The last selected team is remembered in a cookie so returning users go straight to their team
This is the recommended approach for B2B apps. It removes the personal account layer entirely so users only interact with team workspaces.
### Hybrid Model (Both Personal and Team)
For applications supporting both individual users and teams (uncommon):
```bash
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
```
### Managed Onboarding (No Self-Service Team Creation)
For applications where you create teams on behalf of customers:
```bash
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
```
## Decision Matrix
Use this matrix to decide which flags to enable:
| Use Case | Theme | Teams | Team Creation | Personal Billing | Team Billing | Deletion |
|----------|-------|-------|---------------|------------------|--------------|----------|
| B2C Consumer App | Yes | No | - | Yes | - | Yes |
| B2B Team SaaS | Optional | Yes | Yes | No | Yes | Optional |
| Enterprise SaaS | Optional | Yes | No | No | Yes | No |
| Freemium Personal | Yes | No | - | Yes | - | Yes |
| Marketplace | Optional | Yes | Yes | Yes | No | Yes |
## How It Works
The configuration file parses environment variables with sensible defaults:
```typescript
const featuresFlagConfig = FeatureFlagsSchema.parse({
enableThemeToggle: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_THEME_TOGGLE,
true,
),
enableAccountDeletion: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION,
false,
),
enableTeamDeletion: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION,
false,
),
enableTeamAccounts: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS,
true,
),
enableTeamCreation: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION,
true,
),
enablePersonalAccountBilling: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING,
false,
),
enableTeamAccountBilling: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING,
false,
),
languagePriority: process.env.NEXT_PUBLIC_LANGUAGE_PRIORITY,
enableNotifications: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS,
true,
),
realtimeNotifications: getBoolean(
process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS,
false,
),
enableVersionUpdater: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER,
false,
),
});
```
## Using Feature Flags in Code
### In Server Components
```typescript
import featureFlagsConfig from '~/config/feature-flags.config';
export default function SettingsPage() {
return (
<div>
{featureFlagsConfig.enableTeamAccounts && (
<TeamAccountsSection />
)}
{featureFlagsConfig.enableAccountDeletion && (
<DeleteAccountButton />
)}
</div>
);
}
```
### In Client Components
```tsx
'use client';
import featureFlagsConfig from '~/config/feature-flags.config';
export function ThemeToggle() {
if (!featureFlagsConfig.enableThemeToggle) {
return null;
}
return <ThemeSwitch />;
}
```
### Conditional Navigation
The navigation configuration files already use feature flags:
```typescript
// From personal-account-navigation.config.tsx
featureFlagsConfig.enablePersonalAccountBilling
? {
label: 'common.routes.billing',
path: pathsConfig.app.personalAccountBilling,
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
```
## Feature Flag Details
### Theme Toggle
Controls whether users can switch between light and dark themes. When disabled, the app uses `NEXT_PUBLIC_DEFAULT_THEME_MODE` exclusively.
### Account Deletion
Allows users to permanently delete their personal accounts. Disabled by default to prevent accidental data loss. Consider enabling for GDPR compliance.
### Team Accounts
Master switch for all team functionality. When disabled, the application operates in personal-account-only mode. Disabling this also hides team-related navigation and features.
### Team Creation
Controls whether users can create new teams. Set to `false` for enterprise scenarios where you provision teams manually.
### Team Deletion
Allows team owners to delete their teams. Disabled by default to prevent accidental data loss.
### Personal vs Team Billing
Choose one based on your business model:
- **Personal billing**: Each user subscribes individually (B2C)
- **Team billing**: Organizations subscribe and add team members (B2B)
Enabling both is possible but uncommon. Most SaaS applications use one model.
### Notifications
Enables the in-app notification system. When combined with `realtimeNotifications`, notifications appear instantly via Supabase Realtime.
### Language Priority
Controls language selection behavior:
- `application`: Use the app's default locale
- `user`: Respect the user's browser language preference
### Version Updater
When enabled, the app checks for updates and notifies users. Useful for deployed applications that receive frequent updates.
## Common Pitfalls
1. **Enabling both billing modes**: While technically possible, enabling both personal and team billing may create confusing user experience (unless your business model is a hybrid of both). Choose one model.
2. **Disabling teams after launch**: If you've collected team data and then disable teams, users lose access. Plan your model before launch.
3. **Forgetting deletion flows**: If you enable deletion, ensure you also handle cascading data deletion and GDPR compliance.
4. **Realtime without base notifications**: `realtimeNotifications` requires `enableNotifications` to be true. The realtime flag adds live updates, not the notification system itself.
## Related Topics
- [Application Configuration](/docs/next-supabase-turbo/configuration/application-configuration) - Core app settings
- [Environment Variables](/docs/next-supabase-turbo/configuration/environment-variables) - Complete variable reference
- [Navigation Configuration](/docs/next-supabase-turbo/configuration/personal-account-sidebar-configuration) - Sidebar customization

View File

@@ -0,0 +1,214 @@
---
status: "published"
label: "Paths Configuration"
title: "Paths Configuration in the Next.js Supabase SaaS Kit"
description: "Configure route paths for authentication, personal accounts, and team accounts in the Next.js Supabase SaaS Kit. Centralized path management for consistent navigation."
order: 3
---
The paths configuration at `apps/web/config/paths.config.ts` centralizes all route definitions. Instead of scattering magic strings throughout your codebase, reference paths from this single configuration file.
{% alert type="default" title="Why Centralize Paths" %}
Centralizing paths prevents typos, makes refactoring easier, and ensures consistency across navigation, redirects, and links throughout your application.
{% /alert %}
## Default Paths
### Authentication Paths
| Path Key | Default Value | Description |
|----------|---------------|-------------|
| `auth.signIn` | `/auth/sign-in` | Sign in page |
| `auth.signUp` | `/auth/sign-up` | Sign up page |
| `auth.verifyMfa` | `/auth/verify` | MFA verification |
| `auth.callback` | `/auth/callback` | OAuth callback handler |
| `auth.passwordReset` | `/auth/password-reset` | Password reset request |
| `auth.passwordUpdate` | `/update-password` | Password update completion |
### Personal Account Paths
| Path Key | Default Value | Description |
|----------|---------------|-------------|
| `app.home` | `/home` | Personal dashboard |
| `app.personalAccountSettings` | `/home/settings` | Profile settings |
| `app.personalAccountBilling` | `/home/billing` | Billing management |
| `app.personalAccountBillingReturn` | `/home/billing/return` | Billing portal return |
### Team Account Paths
| Path Key | Default Value | Description |
|----------|---------------|-------------|
| `app.accountHome` | `/home/[account]` | Team dashboard |
| `app.accountSettings` | `/home/[account]/settings` | Team settings |
| `app.accountBilling` | `/home/[account]/billing` | Team billing |
| `app.accountMembers` | `/home/[account]/members` | Team members |
| `app.accountBillingReturn` | `/home/[account]/billing/return` | Team billing return |
| `app.joinTeam` | `/join` | Team invitation acceptance |
## Configuration File
```typescript
import * as z from 'zod';
const PathsSchema = z.object({
auth: z.object({
signIn: z.string().min(1),
signUp: z.string().min(1),
verifyMfa: z.string().min(1),
callback: z.string().min(1),
passwordReset: z.string().min(1),
passwordUpdate: z.string().min(1),
}),
app: z.object({
home: z.string().min(1),
personalAccountSettings: z.string().min(1),
personalAccountBilling: z.string().min(1),
personalAccountBillingReturn: z.string().min(1),
accountHome: z.string().min(1),
accountSettings: z.string().min(1),
accountBilling: z.string().min(1),
accountMembers: z.string().min(1),
accountBillingReturn: z.string().min(1),
joinTeam: z.string().min(1),
}),
});
const pathsConfig = PathsSchema.parse({
auth: {
signIn: '/auth/sign-in',
signUp: '/auth/sign-up',
verifyMfa: '/auth/verify',
callback: '/auth/callback',
passwordReset: '/auth/password-reset',
passwordUpdate: '/update-password',
},
app: {
home: '/home',
personalAccountSettings: '/home/settings',
personalAccountBilling: '/home/billing',
personalAccountBillingReturn: '/home/billing/return',
accountHome: '/home/[account]',
accountSettings: `/home/[account]/settings`,
accountBilling: `/home/[account]/billing`,
accountMembers: `/home/[account]/members`,
accountBillingReturn: `/home/[account]/billing/return`,
joinTeam: '/join',
},
});
export default pathsConfig;
```
## Using Paths in Code
### In Server Components and Actions
```typescript
import pathsConfig from '~/config/paths.config';
import { redirect } from 'next/navigation';
// Redirect to sign in
redirect(pathsConfig.auth.signIn);
// Redirect to team dashboard
const teamSlug = 'acme-corp';
redirect(pathsConfig.app.accountHome.replace('[account]', teamSlug));
```
### In Client Components
```tsx
import pathsConfig from '~/config/paths.config';
import Link from 'next/link';
function Navigation() {
return (
<nav>
<Link href={pathsConfig.app.home}>Dashboard</Link>
<Link href={pathsConfig.app.personalAccountSettings}>Settings</Link>
</nav>
);
}
```
### Dynamic Team Paths
Team account paths contain `[account]` as a placeholder. Replace it with the actual team slug:
```typescript
import pathsConfig from '~/config/paths.config';
function getTeamPaths(teamSlug: string) {
return {
dashboard: pathsConfig.app.accountHome.replace('[account]', teamSlug),
settings: pathsConfig.app.accountSettings.replace('[account]', teamSlug),
billing: pathsConfig.app.accountBilling.replace('[account]', teamSlug),
members: pathsConfig.app.accountMembers.replace('[account]', teamSlug),
};
}
// Usage
const paths = getTeamPaths('acme-corp');
// paths.dashboard = '/home/acme-corp'
// paths.settings = '/home/acme-corp/settings'
```
## Adding Custom Paths
Extend the schema when adding new routes to your application:
```typescript
const PathsSchema = z.object({
auth: z.object({
// ... existing auth paths
}),
app: z.object({
// ... existing app paths
// Add your custom paths
projects: z.string().min(1),
projectDetail: z.string().min(1),
}),
});
const pathsConfig = PathsSchema.parse({
auth: {
// ... existing values
},
app: {
// ... existing values
projects: '/home/[account]/projects',
projectDetail: '/home/[account]/projects/[projectId]',
},
});
```
Then use the new paths:
```typescript
const projectsPath = pathsConfig.app.projects
.replace('[account]', teamSlug);
const projectDetailPath = pathsConfig.app.projectDetail
.replace('[account]', teamSlug)
.replace('[projectId]', projectId);
```
## Path Conventions
Follow these conventions when adding paths:
1. **Use lowercase with hyphens**: `/home/my-projects` not `/home/myProjects`
2. **Use brackets for dynamic segments**: `[account]`, `[projectId]`
3. **Keep paths shallow**: Avoid deeply nested routes when possible
4. **Group related paths**: Put team-related paths under `app.account*`
## Common Pitfalls
1. **Forgetting to replace dynamic segments**: Always replace `[account]` with the actual slug before using team paths in redirects or links.
2. **Hardcoding paths**: Don't use string literals like `'/home/settings'`. Always import from `pathsConfig` for consistency.
3. **Missing trailing slash consistency**: The kit doesn't use trailing slashes. Keep this consistent in your custom paths.
## Related Topics
- [Navigation Configuration](/docs/next-supabase-turbo/configuration/personal-account-sidebar-configuration) - Configure sidebar navigation
- [App Router Structure](/docs/next-supabase-turbo/installation/navigating-codebase) - Understand the route organization

View File

@@ -0,0 +1,291 @@
---
status: "published"
label: "Personal Account Navigation"
title: "Personal Account Navigation in Next.js Supabase"
description: "Configure the personal account sidebar navigation, layout style, and menu structure in the Next.js Supabase SaaS Kit."
order: 5
---
The personal account navigation at `apps/web/config/personal-account-navigation.config.tsx` defines the sidebar menu for personal workspaces. Add your own routes here to extend the dashboard navigation.
{% alert type="default" title="Where to Add Routes" %}
This is the file you'll edit most often when building your product. Add dashboard pages, settings sections, and feature-specific navigation items here.
{% /alert %}
## Layout Options
| Variable | Options | Default | Description |
|----------|---------|---------|-------------|
| `NEXT_PUBLIC_USER_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Navigation layout style |
| `NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start with collapsed sidebar |
| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `offcanvas`, `icon`, `none` | `icon` | How sidebar collapses |
### Sidebar Style (Default)
```bash
NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar
```
Shows a vertical sidebar on the left with expandable sections.
### Header Style
```bash
NEXT_PUBLIC_USER_NAVIGATION_STYLE=header
```
Shows navigation in a horizontal header bar.
### Collapse Behavior
Control how the sidebar behaves when collapsed:
```bash
# Icon mode: Shows icons only when collapsed
NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=icon
# Offcanvas mode: Slides in/out as an overlay
NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=offcanvas
# None: Sidebar cannot be collapsed
NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=none
```
## Default Configuration
The kit ships with these routes:
```tsx
import { CreditCard, Home, User } from 'lucide-react';
import * as z from 'zod';
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 routes = [
{
label: 'common.routes.application',
children: [
{
label: 'common.routes.home',
path: pathsConfig.app.home,
Icon: <Home className={iconClasses} />,
highlightMatch: `${pathsConfig.app.home}$`,
},
],
},
{
label: 'common.routes.settings',
children: [
{
label: 'common.routes.profile',
path: pathsConfig.app.personalAccountSettings,
Icon: <User className={iconClasses} />,
},
featureFlagsConfig.enablePersonalAccountBilling
? {
label: 'common.routes.billing',
path: pathsConfig.app.personalAccountBilling,
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
].filter((route) => !!route),
},
] satisfies z.output<typeof NavigationConfigSchema>['routes'];
export const personalAccountNavigationConfig = NavigationConfigSchema.parse({
routes,
style: process.env.NEXT_PUBLIC_USER_NAVIGATION_STYLE,
sidebarCollapsed: process.env.NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED,
sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE,
});
```
## Adding Custom Routes
### Simple Route
Add a route to an existing section:
```tsx
const routes = [
{
label: 'common.routes.application',
children: [
{
label: 'common.routes.home',
path: pathsConfig.app.home,
Icon: <Home className={iconClasses} />,
highlightMatch: `${pathsConfig.app.home}$`,
},
{
label: 'Projects',
path: '/home/projects',
Icon: <Folder className={iconClasses} />,
},
],
},
// ... rest of routes
];
```
### New Section
Add a new collapsible section:
```tsx
const routes = [
// ... existing sections
{
label: 'Your Store',
children: [
{
label: 'Products',
path: '/home/products',
Icon: <Package className={iconClasses} />,
},
{
label: 'Orders',
path: '/home/orders',
Icon: <ShoppingCart className={iconClasses} />,
},
{
label: 'Analytics',
path: '/home/analytics',
Icon: <BarChart className={iconClasses} />,
},
],
},
];
```
### Nested Routes
Create sub-menus within a section:
```tsx
const routes = [
{
label: 'Dashboard',
children: [
{
label: 'Overview',
path: '/home',
Icon: <LayoutDashboard className={iconClasses} />,
highlightMatch: '^/home$',
},
{
label: 'Projects',
path: '/home/projects',
Icon: <Folder className={iconClasses} />,
children: [
{
label: 'Active',
path: '/home/projects/active',
Icon: <Play className={iconClasses} />,
},
{
label: 'Archived',
path: '/home/projects/archived',
Icon: <Archive className={iconClasses} />,
},
],
},
],
},
];
```
### Conditional Routes
Show routes based on feature flags or user state:
```tsx
const routes = [
{
label: 'common.routes.settings',
children: [
{
label: 'common.routes.profile',
path: pathsConfig.app.personalAccountSettings,
Icon: <User className={iconClasses} />,
},
// Only show billing if enabled
featureFlagsConfig.enablePersonalAccountBilling
? {
label: 'common.routes.billing',
path: pathsConfig.app.personalAccountBilling,
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
].filter((route) => !!route),
},
];
```
## Route Properties
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `label` | `string` | Yes | Display text (supports i18n keys) |
| `path` | `string` | Yes | Route path |
| `Icon` | `ReactNode` | No | Lucide icon component |
| `highlightMatch` | `string` | No | Regex pattern for active route highlighting |
| `children` | `Route[]` | No | Nested routes for sub-menus |
## Internationalization
Route labels support i18n keys:
```tsx
{
label: 'common.routes.dashboard', // Uses translation
path: '/home',
Icon: <Home className={iconClasses} />,
}
```
Translation files are in `apps/web/i18n/messages/[locale]/common.json`:
```json
{
"routes": {
"dashboard": "Dashboard",
"settings": "Settings",
"profile": "Profile"
}
}
```
For quick prototyping, use plain strings:
```tsx
{
label: 'My Projects', // Plain string
path: '/home/projects',
Icon: <Folder className={iconClasses} />,
}
```
## Best Practices
1. **Use `pathsConfig`**: Import paths from the configuration instead of hardcoding strings.
2. **Group logically**: Put related routes in the same section.
3. **Use feature flags**: Conditionally show routes based on billing plans or user permissions.
4. **Consistent icons**: Use icons from `lucide-react` for visual consistency.
## Common Pitfalls
1. **Missing `highlightMatch` property**: The home route needs `highlightMatch` with a regex pattern to prevent it from matching all paths starting with `/home`.
2. **Forgetting to filter undefined**: When using conditional routes, always filter out `undefined` values with `.filter((route) => !!route)`.
3. **Wrong icon size**: Use `w-4` class for icons to match the navigation style.
## Related Topics
- [Team Account Navigation](/docs/next-supabase-turbo/configuration/team-account-sidebar-configuration) - Configure team navigation
- [Paths Configuration](/docs/next-supabase-turbo/configuration/paths-configuration) - Centralized path management
- [Adding Pages](/docs/next-supabase-turbo/development/marketing-pages) - Create new dashboard pages

View File

@@ -0,0 +1,265 @@
---
status: "published"
label: "Team Account Navigation"
title: "Team Account Navigation Configuration in the Next.js Supabase SaaS Kit"
description: "Configure the team account sidebar navigation, layout style, and menu structure in the Next.js Supabase SaaS Kit for B2B team workspaces."
order: 6
---
The team account navigation at `apps/web/config/team-account-navigation.config.tsx` defines the sidebar menu for team workspaces. This configuration differs from personal navigation because routes include the team slug as a dynamic segment.
{% alert type="default" title="Team Context" %}
Team navigation routes use `[account]` as a placeholder that gets replaced with the actual team slug at runtime (e.g., `/home/acme-corp/settings`).
{% /alert %}
## Layout Options
| Variable | Options | Default | Description |
|----------|---------|---------|-------------|
| `NEXT_PUBLIC_TEAM_NAVIGATION_STYLE` | `sidebar`, `header` | `sidebar` | Navigation layout style |
| `NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED` | `true`, `false` | `false` | Start with collapsed sidebar |
| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `offcanvas`, `icon`, `none` | `icon` | How sidebar collapses |
### Sidebar Style (Default)
```bash
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar
```
### Header Style
```bash
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=header
```
## Default Configuration
The kit ships with these team routes:
```tsx
import { CreditCard, LayoutDashboard, Settings, 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',
children: [
{
label: 'common.routes.dashboard',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />,
highlightMatch: `${pathsConfig.app.accountHome.replace('[account]', account)}$`,
},
],
},
{
label: 'common.routes.settings',
collapsible: false,
children: [
{
label: 'common.routes.settings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings 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,
sidebarCollapsed: process.env.NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED,
sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE,
});
}
function createPath(path: string, account: string) {
return path.replace('[account]', account);
}
```
## Key Differences from Personal Navigation
| Aspect | Personal Navigation | Team Navigation |
|--------|--------------------|-----------------:|
| Export | `personalAccountNavigationConfig` (object) | `getTeamAccountSidebarConfig(account)` (function) |
| Paths | Static (e.g., `/home/settings`) | Dynamic (e.g., `/home/acme-corp/settings`) |
| Context | Single user workspace | Team-specific workspace |
## Adding Custom Routes
### Simple Route
Add a route to an existing section. Use the `createPath` helper to inject the team slug:
```tsx
const getRoutes = (account: string) => [
{
label: 'common.routes.application',
children: [
{
label: 'common.routes.dashboard',
path: createPath(pathsConfig.app.accountHome, account),
Icon: <LayoutDashboard className={iconClasses} />,
highlightMatch: `${createPath(pathsConfig.app.accountHome, account)}$`,
},
{
label: 'Projects',
path: createPath('/home/[account]/projects', account),
Icon: <Folder className={iconClasses} />,
},
],
},
// ... rest of routes
];
```
### New Section
Add a new section for team-specific features:
```tsx
const getRoutes = (account: string) => [
// ... existing sections
{
label: 'Workspace',
children: [
{
label: 'Projects',
path: createPath('/home/[account]/projects', account),
Icon: <Folder className={iconClasses} />,
},
{
label: 'Documents',
path: createPath('/home/[account]/documents', account),
Icon: <FileText className={iconClasses} />,
},
{
label: 'Integrations',
path: createPath('/home/[account]/integrations', account),
Icon: <Plug className={iconClasses} />,
},
],
},
];
```
### Conditional Routes
Show routes based on feature flags or team permissions:
```tsx
const getRoutes = (account: string) => [
{
label: 'common.routes.settings',
collapsible: false,
children: [
{
label: 'common.routes.settings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />,
},
{
label: 'common.routes.members',
path: createPath(pathsConfig.app.accountMembers, account),
Icon: <Users className={iconClasses} />,
},
// Only show billing if enabled
featureFlagsConfig.enableTeamAccountBilling
? {
label: 'common.routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
].filter(Boolean),
},
];
```
## Route Properties
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `label` | `string` | Yes | Display text (supports i18n keys) |
| `path` | `string` | Yes | Route path with team slug |
| `Icon` | `ReactNode` | No | Lucide icon component |
| `highlightMatch` | `string` | No | Regex pattern for active route highlighting |
| `children` | `Route[]` | No | Nested routes for sub-menus |
| `collapsible` | `boolean` | No | Whether section can collapse |
## Using the createPath Helper
Always use the `createPath` helper to replace `[account]` with the team slug:
```tsx
function createPath(path: string, account: string) {
return path.replace('[account]', account);
}
// Usage
const settingsPath = createPath('/home/[account]/settings', 'acme-corp');
// Result: '/home/acme-corp/settings'
```
For paths defined in `pathsConfig`, use the same pattern:
```tsx
createPath(pathsConfig.app.accountSettings, account)
// Converts '/home/[account]/settings' to '/home/acme-corp/settings'
```
## Non-Collapsible Sections
Set `collapsible: false` to keep a section always expanded:
```tsx
{
label: 'common.routes.settings',
collapsible: false, // Always expanded
children: [
// ... routes
],
}
```
## Best Practices
1. **Always use `createPath`**: Never hardcode team slugs. Always use the helper function.
2. **Keep paths in `pathsConfig`**: When adding new routes, add them to `paths.config.ts` first.
3. **Filter undefined routes**: When using conditional routes, always add `.filter(Boolean)`.
4. **Use the `highlightMatch` property**: Add `highlightMatch` with a regex pattern to index routes to prevent matching nested paths.
5. **Consider mobile**: Test navigation on mobile devices. Complex nested menus can be hard to navigate.
## Common Pitfalls
1. **Forgetting to replace `[account]`**: If paths show `[account]` literally in the URL, you forgot to use `createPath`.
2. **Not exporting as function**: Team navigation must be a function that accepts the account slug, not a static object.
3. **Mixing personal and team routes**: Team routes should use `/home/[account]/...`, personal routes use `/home/...`.
4. **Hardcoding team slugs**: Never hardcode a specific team slug. Always use the `account` parameter.
## Related Topics
- [Personal Account Navigation](/docs/next-supabase-turbo/configuration/personal-account-sidebar-configuration) - Configure personal navigation
- [Paths Configuration](/docs/next-supabase-turbo/configuration/paths-configuration) - Centralized path management
- [Team Accounts](/docs/next-supabase-turbo/api/team-account-api) - Understanding team workspaces
- [Feature Flags](/docs/next-supabase-turbo/configuration/feature-flags-configuration) - Toggle team features

467
docs/content/cms-api.mdoc Normal file
View File

@@ -0,0 +1,467 @@
---
status: "published"
label: "CMS API"
title: "CMS API Reference for the Next.js Supabase SaaS Kit"
description: "Complete API reference for fetching, filtering, and rendering content from any CMS provider in Makerkit."
order: 1
---
The CMS API provides a unified interface for fetching content regardless of your storage backend. The same code works with Keystatic, WordPress, Supabase, or any custom CMS client you create.
## Creating a CMS Client
The `createCmsClient` function returns a client configured for your chosen provider:
```tsx
import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
```
The provider is determined by the `CMS_CLIENT` environment variable:
```bash
CMS_CLIENT=keystatic # Default
CMS_CLIENT=wordpress
CMS_CLIENT=supabase # Requires plugin
```
You can also override the provider at runtime:
```tsx
import { createCmsClient } from '@kit/cms';
// Force WordPress regardless of env var
const wpClient = await createCmsClient('wordpress');
```
## Fetching Multiple Content Items
Use `getContentItems()` to retrieve lists of content with filtering and pagination:
```tsx
import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
const { items, total } = await client.getContentItems({
collection: 'posts',
limit: 10,
offset: 0,
sortBy: 'publishedAt',
sortDirection: 'desc',
status: 'published',
});
```
### Options Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `collection` | `string` | Required | The collection to query (`posts`, `documentation`, `changelog`) |
| `limit` | `number` | `10` | Maximum items to return |
| `offset` | `number` | `0` | Number of items to skip (for pagination) |
| `sortBy` | `'publishedAt' \| 'order' \| 'title'` | `'publishedAt'` | Field to sort by |
| `sortDirection` | `'asc' \| 'desc'` | `'asc'` | Sort direction |
| `status` | `'published' \| 'draft' \| 'review' \| 'pending'` | `'published'` | Filter by content status |
| `categories` | `string[]` | - | Filter by category slugs |
| `tags` | `string[]` | - | Filter by tag slugs |
| `language` | `string` | - | Filter by language code |
| `content` | `boolean` | `true` | Whether to fetch full content (set `false` for list views) |
| `parentIds` | `string[]` | - | Filter by parent content IDs (for hierarchical content) |
### Pagination Example
```tsx
import { createCmsClient } from '@kit/cms';
import { cache } from 'react';
const getPostsPage = cache(async (page: number, perPage = 10) => {
const client = await createCmsClient();
return client.getContentItems({
collection: 'posts',
limit: perPage,
offset: (page - 1) * perPage,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
});
// Usage in a Server Component
async function BlogList({ page }: { page: number }) {
const { items, total } = await getPostsPage(page);
const totalPages = Math.ceil(total / 10);
return (
<div>
{items.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.description}</p>
</article>
))}
<nav>
Page {page} of {totalPages}
</nav>
</div>
);
}
```
### Filtering by Category
```tsx
const { items } = await client.getContentItems({
collection: 'posts',
categories: ['tutorials', 'guides'],
limit: 5,
});
```
### List View Optimization
For list views where you only need titles and descriptions, skip content fetching:
```tsx
const { items } = await client.getContentItems({
collection: 'posts',
content: false, // Don't fetch full content
limit: 20,
});
```
## Fetching a Single Content Item
Use `getContentItemBySlug()` to retrieve a specific piece of content:
```tsx
import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug: 'getting-started',
collection: 'posts',
});
if (!post) {
// Handle not found
}
```
### Options Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `slug` | `string` | Required | The URL slug of the content item |
| `collection` | `string` | Required | The collection to search |
| `status` | `'published' \| 'draft' \| 'review' \| 'pending'` | `'published'` | Required status for the item |
### Draft Preview
To preview unpublished content (e.g., for admin users):
```tsx
const draft = await client.getContentItemBySlug({
slug: 'upcoming-feature',
collection: 'posts',
status: 'draft',
});
```
## Content Item Shape
All CMS providers return items matching this TypeScript interface:
```tsx
interface ContentItem {
id: string;
title: string;
label: string | undefined;
slug: string;
url: string;
description: string | undefined;
content: unknown; // Provider-specific format
publishedAt: string; // ISO date string
image: string | undefined;
status: 'draft' | 'published' | 'review' | 'pending';
categories: Category[];
tags: Tag[];
order: number;
parentId: string | undefined;
children: ContentItem[];
collapsible?: boolean;
collapsed?: boolean;
}
interface Category {
id: string;
name: string;
slug: string;
}
interface Tag {
id: string;
name: string;
slug: string;
}
```
## Rendering Content
Content format varies by provider (Markdoc nodes, HTML, React nodes). Use the `ContentRenderer` component for provider-agnostic rendering:
```tsx
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
async function ArticlePage({ slug }: { slug: string }) {
const client = await createCmsClient();
const article = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!article) {
notFound();
}
return (
<article>
<header>
<h1>{article.title}</h1>
{article.description && <p>{article.description}</p>}
<time dateTime={article.publishedAt}>
{new Date(article.publishedAt).toLocaleDateString()}
</time>
</header>
<ContentRenderer content={article.content} />
<footer>
{article.categories.map((cat) => (
<span key={cat.id}>{cat.name}</span>
))}
</footer>
</article>
);
}
```
## Working with Categories and Tags
### Fetch All Categories
```tsx
const categories = await client.getCategories({
limit: 50,
offset: 0,
});
```
### Fetch a Category by Slug
```tsx
const category = await client.getCategoryBySlug('tutorials');
if (category) {
// Fetch posts in this category
const { items } = await client.getContentItems({
collection: 'posts',
categories: [category.slug],
});
}
```
### Fetch All Tags
```tsx
const tags = await client.getTags({
limit: 100,
});
```
### Fetch a Tag by Slug
```tsx
const tag = await client.getTagBySlug('react');
```
## Building Dynamic Pages
### Blog Post Page
```tsx {% title="app/[locale]/(marketing)/blog/[slug]/page.tsx" %}
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const client = await createCmsClient();
const { items } = await client.getContentItems({
collection: 'posts',
content: false,
limit: 1000,
});
return items.map((post) => ({
slug: post.slug,
}));
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
return {};
}
return {
title: post.title,
description: post.description,
openGraph: {
images: post.image ? [post.image] : [],
},
};
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<ContentRenderer content={post.content} />
</article>
);
}
```
### CMS-Powered Static Pages
Store pages like Terms of Service or Privacy Policy in your CMS:
```tsx {% title="app/[slug]/page.tsx" %}
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
interface Props {
params: Promise<{ slug: string }>;
}
export default async function StaticPage({ params }: Props) {
const { slug } = await params;
const client = await createCmsClient();
const page = await client.getContentItemBySlug({
slug,
collection: 'pages', // Create this collection in your CMS
});
if (!page) {
notFound();
}
return (
<div>
<h1>{page.title}</h1>
<ContentRenderer content={page.content} />
</div>
);
}
```
{% alert type="default" title="Create the pages collection" %}
This example assumes you've added a `pages` collection to your CMS configuration. By default, Makerkit includes `posts`, `documentation`, and `changelog` collections.
{% /alert %}
## Caching Strategies
### React Cache
Wrap CMS calls with React's `cache()` for request deduplication:
```tsx
import { createCmsClient } from '@kit/cms';
import { cache } from 'react';
export const getPost = cache(async (slug: string) => {
const client = await createCmsClient();
return client.getContentItemBySlug({
slug,
collection: 'posts',
});
});
```
### Next.js Data Cache
The CMS client respects Next.js caching. For static content, pages are cached at build time with `generateStaticParams()`.
For dynamic content that should revalidate:
```tsx
import { unstable_cache } from 'next/cache';
import { createCmsClient } from '@kit/cms';
const getCachedPosts = unstable_cache(
async () => {
const client = await createCmsClient();
return client.getContentItems({ collection: 'posts', limit: 10 });
},
['posts-list'],
{ revalidate: 3600 } // Revalidate every hour
);
```
## Provider-Specific Notes
### Keystatic
- Collections: `posts`, `documentation`, `changelog` (configurable in `keystatic.config.ts`)
- Categories and tags are stored as arrays of strings
- Content is Markdoc, rendered via `@kit/keystatic/renderer`
### WordPress
- Collections map to WordPress content types: use `posts` for posts, `pages` for pages
- Categories and tags use WordPress's native taxonomy system
- Language filtering uses tags (add `en`, `de`, etc. tags to posts)
- Content is HTML, rendered via `@kit/wordpress/renderer`
### Supabase
- Uses the `content_items`, `categories`, and `tags` tables
- Requires the Supabase CMS plugin installation
- Content can be HTML or any format you store
- Works with [Supamode](/supabase-cms) for admin UI
## Next Steps
- [Keystatic Setup](/docs/next-supabase-turbo/content/keystatic): Configure local or GitHub storage
- [WordPress Setup](/docs/next-supabase-turbo/content/wordpress): Connect to WordPress REST API
- [Supabase CMS Plugin](/docs/next-supabase-turbo/content/supabase): Store content in your database
- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build integrations for Sanity, Contentful, etc.

128
docs/content/cms.mdoc Normal file
View File

@@ -0,0 +1,128 @@
---
status: "published"
title: "CMS Integration in the Next.js Supabase SaaS Kit"
label: "CMS"
description: "Makerkit's CMS interface abstracts content storage, letting you swap between Keystatic, WordPress, or Supabase without changing your application code."
order: 0
---
Makerkit provides a unified CMS interface that decouples your application from the underlying content storage. Write your content queries once, then swap between Keystatic, WordPress, or Supabase without touching your React components.
This abstraction means you can start with local Markdown files during development, then switch to WordPress for a content team, or Supabase for a database-driven approach, all without rewriting your data fetching logic.
## Supported CMS Providers
Makerkit ships with two built-in CMS implementations and one plugin:
| Provider | Storage | Best For | Edge Compatible |
|----------|---------|----------|-----------------|
| [Keystatic](/docs/next-supabase-turbo/content/keystatic) | Local files or GitHub | Solo developers, Git-based workflows | GitHub mode only |
| [WordPress](/docs/next-supabase-turbo/content/wordpress) | WordPress REST API | Content teams, existing WordPress sites | Yes |
| [Supabase](/docs/next-supabase-turbo/content/supabase) | PostgreSQL via Supabase | Database-driven content, custom admin | Yes |
You can also [create your own CMS client](/docs/next-supabase-turbo/content/creating-your-own-cms-client) for providers like Sanity, Contentful, or Strapi.
## How It Works
The CMS interface consists of three layers:
1. **CMS Client**: An abstract class that defines methods like `getContentItems()` and `getContentItemBySlug()`. Each provider implements this interface.
2. **Content Renderer**: A React component that knows how to render content from each provider (Markdoc for Keystatic, HTML for WordPress, etc.).
3. **Registry**: A dynamic import system that loads the correct client based on the `CMS_CLIENT` environment variable.
```tsx
// This code works with any CMS provider
import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
const { items } = await client.getContentItems({
collection: 'posts',
limit: 10,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
```
The `CMS_CLIENT` environment variable determines which implementation gets loaded:
```bash
CMS_CLIENT=keystatic # Default - file-based content
CMS_CLIENT=wordpress # WordPress REST API
CMS_CLIENT=supabase # Supabase database (requires plugin)
```
## Default Collections
Keystatic ships with three pre-configured collections:
- **posts**: Blog posts with title, description, categories, tags, and Markdoc content
- **documentation**: Hierarchical docs with parent-child relationships and ordering
- **changelog**: Release notes and updates
WordPress maps to its native content types (posts and pages). Supabase uses the `content_items` table with flexible metadata.
## Choosing a Provider
**Choose Keystatic if:**
- You're a solo developer or small team
- You want version-controlled content in your repo
- You prefer Markdown/Markdoc for writing
- You don't need real-time collaborative editing
**Choose WordPress if:**
- You have an existing WordPress site
- Your content team knows WordPress
- You need its plugin ecosystem (SEO, forms, etc.)
- You want a battle-tested admin interface
**Choose Supabase if:**
- You want content in your existing database
- You need row-level security on content
- You're building a user-generated content feature
- You want to use [Supamode](/supabase-cms) as your admin
## Quick Start
By default, Makerkit uses Keystatic with local storage. No configuration needed.
To switch providers, set the environment variable and follow the provider-specific setup:
```bash
# .env
CMS_CLIENT=keystatic
```
Then use the [CMS API](/docs/next-supabase-turbo/content/cms-api) to fetch content in your components:
```tsx
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
async function BlogPost({ slug }: { slug: string }) {
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<ContentRenderer content={post.content} />
</article>
);
}
```
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation for fetching and filtering content
- [Keystatic Setup](/docs/next-supabase-turbo/content/keystatic): Configure local or GitHub storage
- [WordPress Setup](/docs/next-supabase-turbo/content/wordpress): Connect to WordPress REST API
- [Supabase CMS Plugin](/docs/next-supabase-turbo/content/supabase): Store content in your database
- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build your own integration

View File

@@ -0,0 +1,629 @@
---
status: "published"
label: "Custom CMS"
title: "Building a Custom CMS Client for the Next.js Supabase SaaS Kit"
description: "Implement the CMS interface to integrate Sanity, Contentful, Strapi, Payload, or any headless CMS with Makerkit."
order: 5
---
Makerkit's CMS interface is designed to be extensible. If you're using a CMS that isn't supported out of the box, you can create your own client by implementing the `CmsClient` abstract class.
This guide walks through building a custom CMS client using a fictional HTTP API as an example. The same pattern works for Sanity, Contentful, Strapi, Payload, or any headless CMS.
## The CMS Interface
Your client must implement these methods:
```tsx
import { Cms, CmsClient } from '@kit/cms-types';
export abstract class CmsClient {
// Fetch multiple content items with filtering and pagination
abstract getContentItems(
options?: Cms.GetContentItemsOptions
): Promise<{
total: number;
items: Cms.ContentItem[];
}>;
// Fetch a single content item by slug
abstract getContentItemBySlug(params: {
slug: string;
collection: string;
status?: Cms.ContentItemStatus;
}): Promise<Cms.ContentItem | undefined>;
// Fetch categories
abstract getCategories(
options?: Cms.GetCategoriesOptions
): Promise<Cms.Category[]>;
// Fetch a single category by slug
abstract getCategoryBySlug(
slug: string
): Promise<Cms.Category | undefined>;
// Fetch tags
abstract getTags(
options?: Cms.GetTagsOptions
): Promise<Cms.Tag[]>;
// Fetch a single tag by slug
abstract getTagBySlug(
slug: string
): Promise<Cms.Tag | undefined>;
}
```
## Type Definitions
The CMS types are defined in `packages/cms/types/src/cms-client.ts`:
```tsx
export namespace Cms {
export interface ContentItem {
id: string;
title: string;
label: string | undefined;
url: string;
description: string | undefined;
content: unknown;
publishedAt: string;
image: string | undefined;
status: ContentItemStatus;
slug: string;
categories: Category[];
tags: Tag[];
order: number;
children: ContentItem[];
parentId: string | undefined;
collapsible?: boolean;
collapsed?: boolean;
}
export type ContentItemStatus = 'draft' | 'published' | 'review' | 'pending';
export interface Category {
id: string;
name: string;
slug: string;
}
export interface Tag {
id: string;
name: string;
slug: string;
}
export interface GetContentItemsOptions {
collection: string;
limit?: number;
offset?: number;
categories?: string[];
tags?: string[];
content?: boolean;
parentIds?: string[];
language?: string | undefined;
sortDirection?: 'asc' | 'desc';
sortBy?: 'publishedAt' | 'order' | 'title';
status?: ContentItemStatus;
}
export interface GetCategoriesOptions {
slugs?: string[];
limit?: number;
offset?: number;
}
export interface GetTagsOptions {
slugs?: string[];
limit?: number;
offset?: number;
}
}
```
## Example Implementation
Here's a complete example for a fictional HTTP API:
```tsx {% title="packages/cms/my-cms/src/my-cms-client.ts" %}
import { Cms, CmsClient } from '@kit/cms-types';
const API_URL = process.env.MY_CMS_API_URL;
const API_KEY = process.env.MY_CMS_API_KEY;
export function createMyCmsClient() {
return new MyCmsClient();
}
class MyCmsClient extends CmsClient {
private async fetch<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`CMS API error: ${response.status}`);
}
return response.json();
}
async getContentItems(
options: Cms.GetContentItemsOptions
): Promise<{ total: number; items: Cms.ContentItem[] }> {
const params = new URLSearchParams();
params.set('collection', options.collection);
if (options.limit) {
params.set('limit', options.limit.toString());
}
if (options.offset) {
params.set('offset', options.offset.toString());
}
if (options.status) {
params.set('status', options.status);
}
if (options.sortBy) {
params.set('sort_by', options.sortBy);
}
if (options.sortDirection) {
params.set('sort_direction', options.sortDirection);
}
if (options.categories?.length) {
params.set('categories', options.categories.join(','));
}
if (options.tags?.length) {
params.set('tags', options.tags.join(','));
}
if (options.language) {
params.set('language', options.language);
}
const data = await this.fetch<{
total: number;
items: ApiContentItem[];
}>(`/content?${params.toString()}`);
return {
total: data.total,
items: data.items.map(this.mapContentItem),
};
}
async getContentItemBySlug(params: {
slug: string;
collection: string;
status?: Cms.ContentItemStatus;
}): Promise<Cms.ContentItem | undefined> {
try {
const queryParams = new URLSearchParams({
collection: params.collection,
});
if (params.status) {
queryParams.set('status', params.status);
}
const data = await this.fetch<ApiContentItem>(
`/content/${params.slug}?${queryParams.toString()}`
);
return this.mapContentItem(data);
} catch (error) {
// Return undefined for 404s
return undefined;
}
}
async getCategories(
options?: Cms.GetCategoriesOptions
): Promise<Cms.Category[]> {
const params = new URLSearchParams();
if (options?.limit) {
params.set('limit', options.limit.toString());
}
if (options?.offset) {
params.set('offset', options.offset.toString());
}
if (options?.slugs?.length) {
params.set('slugs', options.slugs.join(','));
}
const data = await this.fetch<ApiCategory[]>(
`/categories?${params.toString()}`
);
return data.map(this.mapCategory);
}
async getCategoryBySlug(slug: string): Promise<Cms.Category | undefined> {
try {
const data = await this.fetch<ApiCategory>(`/categories/${slug}`);
return this.mapCategory(data);
} catch {
return undefined;
}
}
async getTags(options?: Cms.GetTagsOptions): Promise<Cms.Tag[]> {
const params = new URLSearchParams();
if (options?.limit) {
params.set('limit', options.limit.toString());
}
if (options?.offset) {
params.set('offset', options.offset.toString());
}
if (options?.slugs?.length) {
params.set('slugs', options.slugs.join(','));
}
const data = await this.fetch<ApiTag[]>(`/tags?${params.toString()}`);
return data.map(this.mapTag);
}
async getTagBySlug(slug: string): Promise<Cms.Tag | undefined> {
try {
const data = await this.fetch<ApiTag>(`/tags/${slug}`);
return this.mapTag(data);
} catch {
return undefined;
}
}
// Map API response to Makerkit's ContentItem interface
private mapContentItem(item: ApiContentItem): Cms.ContentItem {
return {
id: item.id,
title: item.title,
label: item.label ?? undefined,
slug: item.slug,
url: `/${item.collection}/${item.slug}`,
description: item.excerpt ?? undefined,
content: item.body,
publishedAt: item.published_at,
image: item.featured_image ?? undefined,
status: this.mapStatus(item.status),
categories: (item.categories ?? []).map(this.mapCategory),
tags: (item.tags ?? []).map(this.mapTag),
order: item.sort_order ?? 0,
parentId: item.parent_id ?? undefined,
children: [],
collapsible: item.collapsible ?? false,
collapsed: item.collapsed ?? false,
};
}
private mapCategory(cat: ApiCategory): Cms.Category {
return {
id: cat.id,
name: cat.name,
slug: cat.slug,
};
}
private mapTag(tag: ApiTag): Cms.Tag {
return {
id: tag.id,
name: tag.name,
slug: tag.slug,
};
}
private mapStatus(status: string): Cms.ContentItemStatus {
switch (status) {
case 'live':
case 'active':
return 'published';
case 'draft':
return 'draft';
case 'pending':
case 'scheduled':
return 'pending';
case 'review':
return 'review';
default:
return 'draft';
}
}
}
// API response types (adjust to match your CMS)
interface ApiContentItem {
id: string;
title: string;
label?: string;
slug: string;
collection: string;
excerpt?: string;
body: unknown;
published_at: string;
featured_image?: string;
status: string;
categories?: ApiCategory[];
tags?: ApiTag[];
sort_order?: number;
parent_id?: string;
collapsible?: boolean;
collapsed?: boolean;
}
interface ApiCategory {
id: string;
name: string;
slug: string;
}
interface ApiTag {
id: string;
name: string;
slug: string;
}
```
## Registering Your Client
### 1. Add the CMS Type
Update the type definition:
```tsx {% title="packages/cms/types/src/cms.type.ts" %}
export type CmsType = 'wordpress' | 'keystatic' | 'my-cms';
```
### 2. Register the Client
Add your client to the registry:
```tsx {% title="packages/cms/core/src/create-cms-client.ts" %}
import { CmsClient, CmsType } from '@kit/cms-types';
import { createRegistry } from '@kit/shared/registry';
const CMS_CLIENT = process.env.CMS_CLIENT as CmsType;
const cmsRegistry = createRegistry<CmsClient, CmsType>();
// Existing registrations...
cmsRegistry.register('wordpress', async () => {
const { createWordpressClient } = await import('@kit/wordpress');
return createWordpressClient();
});
cmsRegistry.register('keystatic', async () => {
const { createKeystaticClient } = await import('@kit/keystatic');
return createKeystaticClient();
});
// Register your client
cmsRegistry.register('my-cms', async () => {
const { createMyCmsClient } = await import('@kit/my-cms');
return createMyCmsClient();
});
export async function createCmsClient(type: CmsType = CMS_CLIENT) {
return cmsRegistry.get(type);
}
```
### 3. Create a Content Renderer (Optional)
If your CMS returns content in a specific format, create a renderer:
```tsx {% title="packages/cms/core/src/content-renderer.tsx" %}
cmsContentRendererRegistry.register('my-cms', async () => {
const { MyCmsContentRenderer } = await import('@kit/my-cms/renderer');
return MyCmsContentRenderer;
});
```
Example renderer for HTML content:
```tsx {% title="packages/cms/my-cms/src/renderer.tsx" %}
interface Props {
content: unknown;
}
export function MyCmsContentRenderer({ content }: Props) {
if (typeof content !== 'string') {
return null;
}
return (
<div
className="prose prose-lg"
dangerouslySetInnerHTML={{ __html: content }}
/>
);
}
```
For Markdown content:
```tsx {% title="packages/cms/my-cms/src/renderer.tsx" %}
import { marked } from 'marked';
interface Props {
content: unknown;
}
export function MyCmsContentRenderer({ content }: Props) {
if (typeof content !== 'string') {
return null;
}
const html = marked(content);
return (
<div
className="prose prose-lg"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
```
### 4. Set the Environment Variable
```bash
# .env
CMS_CLIENT=my-cms
MY_CMS_API_URL=https://api.my-cms.com
MY_CMS_API_KEY=your-api-key
```
## Real-World Examples
### Sanity
```tsx
import { createClient } from '@sanity/client';
import { Cms, CmsClient } from '@kit/cms-types';
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
useCdn: true,
apiVersion: '2024-01-01',
});
class SanityClient extends CmsClient {
async getContentItems(options: Cms.GetContentItemsOptions) {
const query = `*[_type == $collection && status == $status] | order(publishedAt desc) [$start...$end] {
_id,
title,
slug,
excerpt,
body,
publishedAt,
mainImage,
categories[]->{ _id, title, slug },
tags[]->{ _id, title, slug }
}`;
const params = {
collection: options.collection,
status: options.status ?? 'published',
start: options.offset ?? 0,
end: (options.offset ?? 0) + (options.limit ?? 10),
};
const items = await client.fetch(query, params);
const total = await client.fetch(
`count(*[_type == $collection && status == $status])`,
params
);
return {
total,
items: items.map(this.mapContentItem),
};
}
// ... implement other methods
}
```
### Contentful
```tsx
import { createClient } from 'contentful';
import { Cms, CmsClient } from '@kit/cms-types';
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});
class ContentfulClient extends CmsClient {
async getContentItems(options: Cms.GetContentItemsOptions) {
const response = await client.getEntries({
content_type: options.collection,
limit: options.limit ?? 10,
skip: options.offset ?? 0,
order: ['-fields.publishedAt'],
});
return {
total: response.total,
items: response.items.map(this.mapContentItem),
};
}
// ... implement other methods
}
```
## Testing Your Client
Create tests to verify your implementation:
```tsx {% title="packages/cms/my-cms/src/__tests__/my-cms-client.test.ts" %}
import { describe, it, expect, beforeAll } from 'vitest';
import { createMyCmsClient } from '../my-cms-client';
describe('MyCmsClient', () => {
const client = createMyCmsClient();
it('fetches content items', async () => {
const { items, total } = await client.getContentItems({
collection: 'posts',
limit: 5,
});
expect(items).toBeInstanceOf(Array);
expect(typeof total).toBe('number');
if (items.length > 0) {
expect(items[0]).toHaveProperty('id');
expect(items[0]).toHaveProperty('title');
expect(items[0]).toHaveProperty('slug');
}
});
it('fetches a single item by slug', async () => {
const item = await client.getContentItemBySlug({
slug: 'test-post',
collection: 'posts',
});
if (item) {
expect(item.slug).toBe('test-post');
}
});
it('returns undefined for non-existent slugs', async () => {
const item = await client.getContentItemBySlug({
slug: 'non-existent-slug-12345',
collection: 'posts',
});
expect(item).toBeUndefined();
});
});
```
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers
- Check the [Keystatic implementation](https://github.com/makerkit/next-supabase-saas-kit-turbo/tree/main/packages/cms/keystatic) for a complete example

321
docs/content/keystatic.mdoc Normal file
View File

@@ -0,0 +1,321 @@
---
status: "published"
title: "Keystatic CMS Setup for the Next.js Supabase SaaS Kit"
label: "Keystatic"
description: "Configure Keystatic as your CMS with local file storage for development or GitHub integration for production and team collaboration."
order: 2
---
Keystatic is a file-based CMS that stores content as Markdown/Markdoc files. It's the default CMS in Makerkit because it requires zero setup for local development and integrates with Git for version-controlled content.
## Storage Modes
Keystatic supports three storage modes:
| Mode | Storage | Best For | Edge Compatible |
|------|---------|----------|-----------------|
| `local` | Local filesystem | Development, solo projects | No |
| `github` | GitHub repository | Production, team collaboration | Yes |
| `cloud` | Keystatic Cloud | Managed hosting | Yes |
Local mode reads files directly from disk. GitHub mode fetches content via the GitHub API, making it compatible with edge runtimes like Cloudflare Workers.
## Local Storage (Default)
Local mode works out of the box. Content lives in your repository's `content/` directory:
```bash
# .env (optional - these are the defaults)
CMS_CLIENT=keystatic
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=local
KEYSTATIC_PATH_PREFIX=apps/web
NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content
```
Content structure:
```
apps/web/content/
├── posts/ # Blog posts
├── documentation/ # Docs (supports nesting)
└── changelog/ # Release notes
```
**Limitations**: Local mode doesn't work with edge runtimes (Cloudflare Workers, Vercel Edge) because it requires filesystem access. Use GitHub mode for edge deployments.
## GitHub Storage
GitHub mode fetches content from your repository via the GitHub API. This enables edge deployment and team collaboration through Git.
### 1. Set Environment Variables
```bash
# .env
CMS_CLIENT=keystatic
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github
NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO=your-org/your-repo
KEYSTATIC_GITHUB_TOKEN=github_pat_xxxxxxxxxxxx
KEYSTATIC_PATH_PREFIX=apps/web
NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content
```
### 2. Create a GitHub Token
1. Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
2. Create a new token with:
- **Repository access**: Select your content repository
- **Permissions**: Contents (Read-only for production, Read and write for admin UI)
3. Copy the token to `KEYSTATIC_GITHUB_TOKEN`
For read-only access (recommended for production):
```bash
KEYSTATIC_GITHUB_TOKEN=github_pat_xxxxxxxxxxxx
```
### 3. Configure Path Prefix
If your content isn't at the repository root, set the path prefix:
```bash
# For monorepos where content is in apps/web/content/
KEYSTATIC_PATH_PREFIX=apps/web
NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH=./content
```
## Keystatic Cloud
Keystatic Cloud is a managed service that handles GitHub authentication and provides a hosted admin UI.
```bash
# .env
CMS_CLIENT=keystatic
NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=cloud
KEYSTATIC_STORAGE_PROJECT=your-project-id
```
Get your project ID from the [Keystatic Cloud dashboard](https://keystatic.cloud).
## Adding the Admin UI
Keystatic includes a visual editor for managing content. To add it:
```bash
turbo gen keystatic
```
This creates a route at `/keystatic` where you can create and edit content.
{% alert type="warning" title="Protect the admin in production" %}
By default, the Keystatic admin is only available in development. For production, add authentication:
```tsx {% title="app/keystatic/layout.tsx" %}
import { redirect } from 'next/navigation';
import { isSuperAdmin } from '@kit/admin';
export default async function KeystaticLayout({
children,
}: {
children: React.ReactNode;
}) {
const isAdmin = await isSuperAdmin();
if (!isAdmin) {
redirect('/');
}
return children;
}
```
{% /alert %}
### GitHub Mode Admin Setup
GitHub mode requires a GitHub App for the admin UI to authenticate and commit changes.
1. Install the Keystatic GitHub App on your repository
2. Follow the [Keystatic GitHub mode documentation](https://keystatic.com/docs/github-mode) for setup
The admin UI commits content changes directly to your repository, triggering your CI/CD pipeline.
## Default Collections
Makerkit configures three collections in `packages/cms/keystatic/src/keystatic.config.ts`:
### Posts
Blog posts with frontmatter:
```yaml
---
title: "Getting Started with Makerkit"
description: "A guide to building your SaaS"
publishedAt: 2025-01-15
status: published
categories:
- tutorials
tags:
- getting-started
image: /images/posts/getting-started.webp
---
Content here...
```
### Documentation
Hierarchical docs with ordering and collapsible sections:
```yaml
---
title: "Authentication"
label: "Auth" # Short label for navigation
description: "How authentication works"
order: 1
status: published
collapsible: true
collapsed: false
---
Content here...
```
Documentation supports nested directories. A file at `documentation/auth/sessions/sessions.mdoc` automatically becomes a child of `documentation/auth/auth.mdoc`.
### Changelog
Release notes:
```yaml
---
title: "v2.0.0 Release"
description: "Major update with new features"
publishedAt: 2025-01-10
status: published
---
Content here...
```
## Adding Custom Collections
Edit `packages/cms/keystatic/src/keystatic.config.ts` to add collections:
```tsx {% title="packages/cms/keystatic/src/keystatic.config.ts" %}
// In getKeystaticCollections()
return {
// ... existing collections
pages: collection({
label: 'Pages',
slugField: 'title',
path: `${path}pages/*`,
format: { contentField: 'content' },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
description: fields.text({ label: 'Description' }),
content: getContentField(),
status: fields.select({
defaultValue: 'draft',
label: 'Status',
options: statusOptions,
}),
},
}),
};
```
## Content Format
Keystatic uses [Markdoc](https://markdoc.dev), a Markdown superset with custom components.
### Basic Markdown
Standard Markdown syntax works:
```markdown
# Heading
Paragraph with **bold** and *italic*.
- List item
- Another item
```code
Code block
```
```
### Images
Images are stored in `public/site/images/` and referenced with the public path:
```markdown
![Alt text](/site/images/screenshot.webp)
```
### Custom Components
Makerkit extends Markdoc with custom nodes. Check `packages/cms/keystatic/src/markdoc-nodes.ts` for available components.
## Cloudflare Workers Compatibility
Cloudflare Workers don't send the `User-Agent` header, which the GitHub API requires. Add this workaround to `packages/cms/keystatic/src/keystatic-client.ts`:
```tsx {% title="packages/cms/keystatic/src/keystatic-client.ts" %}
// Add at the top of the file
const self = global || globalThis || this;
const originalFetch = self.fetch;
self.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const requestInit: RequestInit = {
...(init ?? {}),
headers: {
...(init?.headers ?? {}),
'User-Agent': 'Cloudflare-Workers',
}
};
return originalFetch(input, requestInit);
};
```
## Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CMS_CLIENT` | No | `keystatic` | CMS provider |
| `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND` | No | `local` | Storage mode: `local`, `github`, `cloud` |
| `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` | GitHub only | - | Repository in `owner/repo` format |
| `KEYSTATIC_GITHUB_TOKEN` | GitHub only | - | GitHub personal access token |
| `KEYSTATIC_STORAGE_PROJECT` | Cloud only | - | Keystatic Cloud project ID |
| `KEYSTATIC_PATH_PREFIX` | No | - | Path to content in monorepos |
| `NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH` | No | `./content` | Content directory path |
| `KEYSTATIC_STORAGE_BRANCH_PREFIX` | No | - | Branch prefix for GitHub mode |
## Troubleshooting
### Content not loading in production
Verify GitHub mode is configured:
- `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github`
- `KEYSTATIC_GITHUB_TOKEN` has read access to the repository
- `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` matches your repository
### Admin UI shows authentication error
For GitHub mode, ensure:
- The Keystatic GitHub App is installed on your repository
- Your GitHub token has write permissions (for the admin)
### Edge runtime errors
Local mode doesn't work on edge. Switch to GitHub or Cloud mode:
- Set `NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND=github`
- Configure GitHub token with read access
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Learn the full API for fetching content
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers
- [Keystatic Documentation](https://keystatic.com/docs): Official Keystatic docs

423
docs/content/supabase.mdoc Normal file
View File

@@ -0,0 +1,423 @@
---
status: "published"
title: "Supabase CMS Plugin for the Next.js Supabase SaaS Kit"
label: "Supabase"
description: "Store content in your Supabase database with optional Supamode integration for a visual admin interface."
order: 4
---
The Supabase CMS plugin stores content directly in your Supabase database. This gives you full control over your content schema, row-level security policies, and the ability to query content alongside your application data.
This approach works well when you want content in the same database as your app, need RLS policies on content, or want to use [Supamode](/supabase-cms) as your admin interface.
## Installation
### 1. Install the Plugin
Run the Makerkit CLI from your app directory:
```bash
npx @makerkit/cli plugins install
```
Select **Supabase CMS** when prompted.
### 2. Add the Package Dependency
Add the plugin to your CMS package:
```bash
pnpm --filter "@kit/cms" add "@kit/supabase-cms@workspace:*"
```
### 3. Register the CMS Type
Update the CMS type definition to include Supabase:
```tsx {% title="packages/cms/types/src/cms.type.ts" %}
export type CmsType = 'wordpress' | 'keystatic' | 'supabase';
```
### 4. Register the Client
Add the Supabase client to the CMS registry:
```tsx {% title="packages/cms/core/src/create-cms-client.ts" %}
import { CmsClient, CmsType } from '@kit/cms-types';
import { createRegistry } from '@kit/shared/registry';
const CMS_CLIENT = process.env.CMS_CLIENT as CmsType;
const cmsRegistry = createRegistry<CmsClient, CmsType>();
// Existing registrations...
cmsRegistry.register('wordpress', async () => {
const { createWordpressClient } = await import('@kit/wordpress');
return createWordpressClient();
});
cmsRegistry.register('keystatic', async () => {
const { createKeystaticClient } = await import('@kit/keystatic');
return createKeystaticClient();
});
// Add Supabase registration
cmsRegistry.register('supabase', async () => {
const { createSupabaseCmsClient } = await import('@kit/supabase-cms');
return createSupabaseCmsClient();
});
export async function createCmsClient(type: CmsType = CMS_CLIENT) {
return cmsRegistry.get(type);
}
```
### 5. Register the Content Renderer
Add the Supabase content renderer:
```tsx {% title="packages/cms/core/src/content-renderer.tsx" %}
cmsContentRendererRegistry.register('supabase', async () => {
return function SupabaseContentRenderer({ content }: { content: unknown }) {
return content as React.ReactNode;
};
});
```
The default renderer returns content as-is. If you store HTML, it renders as HTML. For Markdown, add a Markdown renderer.
### 6. Run the Migration
Create a new migration file:
```bash
pnpm --filter web supabase migration new cms
```
Copy the contents of `packages/plugins/supabase-cms/migration.sql` to the new migration file, then apply it:
```bash
pnpm --filter web supabase migration up
```
### 7. Generate Types
Regenerate TypeScript types to include the new tables:
```bash
pnpm run supabase:web:typegen
```
### 8. Set the Environment Variable
Switch to the Supabase CMS:
```bash
# apps/web/.env
CMS_CLIENT=supabase
```
## Database Schema
The plugin creates three tables:
### content_items
Stores all content (posts, pages, docs):
```sql
create table public.content_items (
id uuid primary key default gen_random_uuid(),
title text not null,
slug text not null unique,
description text,
content text,
image text,
status text not null default 'draft',
collection text not null default 'posts',
published_at timestamp with time zone,
created_at timestamp with time zone default now(),
updated_at timestamp with time zone default now(),
parent_id uuid references public.content_items(id),
"order" integer default 0,
language text,
metadata jsonb default '{}'::jsonb
);
```
### categories
Content categories:
```sql
create table public.categories (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
created_at timestamp with time zone default now()
);
```
### tags
Content tags:
```sql
create table public.tags (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
created_at timestamp with time zone default now()
);
```
### Junction Tables
Many-to-many relationships:
```sql
create table public.content_items_categories (
content_item_id uuid references public.content_items(id) on delete cascade,
category_id uuid references public.categories(id) on delete cascade,
primary key (content_item_id, category_id)
);
create table public.content_items_tags (
content_item_id uuid references public.content_items(id) on delete cascade,
tag_id uuid references public.tags(id) on delete cascade,
primary key (content_item_id, tag_id)
);
```
## Using Supamode as Admin
{% img src="/assets/images/supamode-cms-plugin-posts.webp" width="2970" height="2028" alt="Supamode CMS Posts Interface" /%}
[Supamode](/supabase-cms) provides a visual interface for managing content in Supabase tables. It's built specifically for Supabase and integrates with RLS policies.
{% alert type="default" title="Supamode is optional" %}
Supamode is a separate product. You can use any Postgres admin tool, build your own admin, or manage content via SQL.
{% /alert %}
### Setting Up Supamode
1. Install Supamode following the [installation guide](/docs/supamode/installation)
2. Sync the CMS tables to Supamode:
- Run the following SQL commands in Supabase Studio's SQL Editor:
```sql
-- Run in Supabase Studio's SQL Editor
select supamode.sync_managed_tables('public', 'content_items');
select supamode.sync_managed_tables('public', 'categories');
select supamode.sync_managed_tables('public', 'tags');
```
3. Configure table views in the Supamode UI under **Resources**
### Content Editing
With Supamode, you can:
- Create and edit content with a form-based UI
- Upload images to Supabase Storage
- Manage categories and tags
- Preview content before publishing
- Filter and search content
## Querying Content
The Supabase CMS client implements the standard CMS interface:
```tsx
import { createCmsClient } from '@kit/cms';
const client = await createCmsClient();
// Get all published posts
const { items, total } = await client.getContentItems({
collection: 'posts',
status: 'published',
limit: 10,
sortBy: 'publishedAt',
sortDirection: 'desc',
});
// Get a specific post
const post = await client.getContentItemBySlug({
slug: 'getting-started',
collection: 'posts',
});
```
### Direct Supabase Queries
For complex queries, use the Supabase client directly:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function getPostsWithCustomQuery() {
const client = getSupabaseServerClient();
const { data, error } = await client
.from('content_items')
.select(`
*,
categories:content_items_categories(
category:categories(*)
),
tags:content_items_tags(
tag:tags(*)
)
`)
.eq('collection', 'posts')
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(10);
return data;
}
```
## Row-Level Security
Add RLS policies to control content access:
```sql
-- Allow public read access to published content
create policy "Public can read published content"
on public.content_items
for select
using (status = 'published');
-- Allow authenticated users to read all content
create policy "Authenticated users can read all content"
on public.content_items
for select
to authenticated
using (true);
-- Allow admins to manage content
create policy "Admins can manage content"
on public.content_items
for all
to authenticated
using (
exists (
select 1 from public.accounts
where accounts.id = auth.uid()
and accounts.is_admin = true
)
);
```
## Content Format
The `content` field stores text. Common formats:
### HTML
Store rendered HTML directly:
```tsx
const post = {
title: 'Hello World',
content: '<p>This is <strong>HTML</strong> content.</p>',
};
```
Render with `dangerouslySetInnerHTML` or a sanitizing library.
### Markdown
Store Markdown and render at runtime:
```tsx
import { marked } from 'marked';
import type { Cms } from '@kit/cms-types';
function renderContent(markdown: string) {
return { __html: marked(markdown) };
}
function Post({ post }: { post: Cms.ContentItem }) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={renderContent(post.content as string)} />
</article>
);
}
```
### JSON
Store structured content as JSON in the `metadata` field:
```tsx
const post = {
title: 'Product Comparison',
content: '', // Optional summary
metadata: {
products: [
{ name: 'Basic', price: 9 },
{ name: 'Pro', price: 29 },
],
},
};
```
## Customizing the Schema
Extend the schema by modifying the migration:
```sql
-- Add custom fields
alter table public.content_items
add column author_id uuid references auth.users(id),
add column reading_time integer,
add column featured boolean default false;
-- Add indexes
create index content_items_featured_idx
on public.content_items(featured)
where status = 'published';
```
Update the Supabase client to handle custom fields.
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `CMS_CLIENT` | Yes | Set to `supabase` |
The plugin uses your existing Supabase connection (no additional configuration needed).
## Troubleshooting
### Migration fails
Check that you have the latest Supabase CLI and your local database is running:
```bash
pnpm --filter web supabase start
pnpm --filter web supabase migration up
```
### TypeScript errors after migration
Regenerate types:
```bash
pnpm run supabase:web:typegen
```
### Content not appearing
Verify:
- The `status` field is set to `published`
- The `collection` field matches your query
- RLS policies allow access
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation
- [Supamode Documentation](/docs/supamode/installation): Set up the admin interface
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers

293
docs/content/wordpress.mdoc Normal file
View File

@@ -0,0 +1,293 @@
---
status: "published"
title: "WordPress CMS Integration for the Next.js Supabase SaaS Kit"
label: "WordPress"
description: "Connect your WordPress site to Makerkit using the REST API for blog posts, documentation, and dynamic pages."
order: 3
---
WordPress integration lets you use an existing WordPress site as your content backend. Makerkit fetches content through the WordPress REST API, so you get the familiar WordPress admin while serving content from your Next.js app.
This approach works well when you have a content team that knows WordPress, or you want to leverage WordPress plugins for SEO, forms, or other features.
## Quick Setup
### 1. Set Environment Variables
```bash
# .env
CMS_CLIENT=wordpress
WORDPRESS_API_URL=https://your-wordpress-site.com
```
### 2. Configure WordPress Permalinks
WordPress REST API requires pretty permalinks. In your WordPress admin:
1. Go to Settings → Permalinks
2. Select "Post name" (`/%postname%/`) or any option except "Plain"
3. Save changes
Without this, the REST API won't resolve slugs correctly.
## Content Mapping
WordPress content types map to Makerkit collections:
| WordPress Type | Makerkit Collection | Notes |
|----------------|---------------------|-------|
| Posts | `posts` | Standard WordPress posts |
| Pages | `pages` | WordPress pages |
### Blog Posts
Create posts in WordPress with:
- **Category**: Add a category named `blog` for blog posts
- **Tags**: Use tags for filtering (including language codes like `en`, `de`)
- **Featured Image**: Automatically used as the post image
```tsx
const { items } = await client.getContentItems({
collection: 'posts',
categories: ['blog'],
limit: 10,
});
```
### Documentation Pages
WordPress doesn't natively support hierarchical documentation. To build docs:
1. Create pages (not posts) for documentation
2. Enable categories for pages (see below)
3. Add a category named `documentation`
#### Enabling Categories for Pages
Add this to your theme's `functions.php`:
```php {% title="wp-content/themes/your-theme/functions.php" %}
function add_categories_to_pages() {
register_taxonomy_for_object_type('category', 'page');
}
add_action('init', 'add_categories_to_pages');
```
Then fetch documentation:
```tsx
const { items } = await client.getContentItems({
collection: 'pages',
categories: ['documentation'],
});
```
## Multi-Language Content
WordPress doesn't have built-in multi-language support. Makerkit uses tags for language filtering:
1. Create tags for each language: `en`, `de`, `fr`, etc.
2. Add the appropriate language tag to each post
3. Filter by language in your queries:
```tsx
const { items } = await client.getContentItems({
collection: 'posts',
language: 'en', // Filters by tag
});
```
For full multi-language support, consider plugins like WPML or Polylang, then adapt the Makerkit WordPress client to use their APIs.
## Local Development
Makerkit includes a Docker Compose setup for local WordPress development:
```bash
# From packages/cms/wordpress/
docker-compose up
```
Or from the root:
```bash
pnpm --filter @kit/wordpress run start
```
This starts WordPress at `http://localhost:8080`.
### Default Credentials
```
Database Host: db
Database Name: wordpress
Database User: wordpress
Database Password: wordpress
```
On first visit, WordPress prompts you to complete the installation.
## Production Configuration
### WordPress Hosting
Host WordPress anywhere that exposes the REST API:
- WordPress.com (Business plan or higher)
- Self-hosted WordPress
- Managed WordPress hosting (WP Engine, Kinsta, etc.)
### CORS Configuration
If your Next.js app and WordPress are on different domains, configure CORS in WordPress.
Add to `wp-config.php`:
```php {% title="wp-config.php" %}
header("Access-Control-Allow-Origin: https://your-nextjs-app.com");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
```
Or use a plugin like "WP CORS" for more control.
### Caching
The WordPress REST API can be slow. Consider:
1. **WordPress caching plugins**: WP Super Cache, W3 Total Cache
2. **CDN for the API**: Cloudflare, Fastly
3. **Next.js caching**: Use `unstable_cache` or ISR
```tsx
import { unstable_cache } from 'next/cache';
import { createCmsClient } from '@kit/cms';
const getCachedPosts = unstable_cache(
async () => {
const client = await createCmsClient();
return client.getContentItems({ collection: 'posts', limit: 10 });
},
['wordpress-posts'],
{ revalidate: 300 } // 5 minutes
);
```
## Content Structure
### Post Fields
WordPress posts return these fields through the Makerkit CMS interface:
| Field | Source | Notes |
|-------|--------|-------|
| `title` | `title.rendered` | HTML-decoded |
| `content` | `content.rendered` | Full HTML content |
| `description` | `excerpt.rendered` | Post excerpt |
| `image` | Featured media | Full URL |
| `slug` | `slug` | URL slug |
| `publishedAt` | `date` | ISO 8601 format |
| `status` | `status` | Mapped to Makerkit statuses |
| `categories` | Category taxonomy | Array of category objects |
| `tags` | Tag taxonomy | Array of tag objects |
| `order` | `menu_order` | For page ordering |
| `parentId` | `parent` | For hierarchical pages |
### Status Mapping
| WordPress Status | Makerkit Status |
|------------------|-----------------|
| `publish` | `published` |
| `draft` | `draft` |
| `pending` | `pending` |
| Other | `draft` |
## Rendering WordPress Content
WordPress content is HTML. Use the `ContentRenderer` component:
```tsx
import { createCmsClient, ContentRenderer } from '@kit/cms';
import { notFound } from 'next/navigation';
async function BlogPost({ slug }: { slug: string }) {
const client = await createCmsClient();
const post = await client.getContentItemBySlug({
slug,
collection: 'posts',
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
{/* ContentRenderer handles HTML safely */}
<ContentRenderer content={post.content} />
</article>
);
}
```
The WordPress renderer sanitizes HTML and applies appropriate styling.
### Custom Styling
WordPress content includes CSS classes. Add styles to your global CSS:
```css {% title="apps/web/styles/globals.css" %}
/* WordPress block styles */
.wp-block-image {
margin: 2rem 0;
}
.wp-block-quote {
border-left: 4px solid var(--primary);
padding-left: 1rem;
font-style: italic;
}
/* Gutenberg alignment */
.alignwide {
max-width: 100vw;
margin-left: calc(-50vw + 50%);
margin-right: calc(-50vw + 50%);
}
```
## Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `CMS_CLIENT` | Yes | Set to `wordpress` |
| `WORDPRESS_API_URL` | Yes | WordPress site URL (no trailing slash) |
## Troubleshooting
### REST API returns 404
- Verify permalinks are set to something other than "Plain"
- Check that the REST API is accessible: `curl https://your-site.com/wp-json/wp/v2/posts`
- Some security plugins disable the REST API; check your plugins
### Categories not working for pages
Ensure you've added the `add_categories_to_pages()` function to your theme's `functions.php`.
### Images not loading
- Check that `WORDPRESS_API_URL` matches the site URL in WordPress settings
- Verify featured images are set on posts
- Check for mixed content issues (HTTP vs HTTPS)
### CORS errors
Add CORS headers to WordPress (see Production Configuration above) or use a proxy.
## Next Steps
- [CMS API Reference](/docs/next-supabase-turbo/content/cms-api): Full API documentation
- [CMS Overview](/docs/next-supabase-turbo/content/cms): Compare CMS providers
- [Custom CMS Client](/docs/next-supabase-turbo/content/creating-your-own-cms-client): Build custom integrations

View File

@@ -0,0 +1,278 @@
---
status: "published"
label: "Updating Fonts"
title: "Customize Application Fonts | Next.js Supabase SaaS Kit"
order: 3
description: "Configure custom fonts using Google Fonts, local fonts, or system fonts in your Makerkit application with Next.js font optimization."
---
Customize your application's typography by editing `apps/web/lib/fonts.ts`. This file defines the font families used throughout your app, with Next.js automatically handling font optimization, subsetting, and self-hosting for privacy and performance.
By default, Makerkit uses Apple's system font on Apple devices (San Francisco) and falls back to Inter on other platforms.
## Quick Font Change
Replace the default Inter font with any Google Font:
```tsx title="apps/web/lib/fonts.ts"
import { Poppins as SansFont } from 'next/font/google';
import { cn } from '@kit/ui/utils';
const sans = SansFont({
subsets: ['latin'],
variable: '--font-sans-fallback',
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial'],
preload: true,
weight: ['300', '400', '500', '600', '700'],
});
const heading = sans;
export { sans, heading };
export function getFontsClassName(theme?: string) {
const dark = theme === 'dark';
const light = !dark;
const font = [sans.variable, heading.variable].reduce<string[]>(
(acc, curr) => {
if (acc.includes(curr)) return acc;
return [...acc, curr];
},
[],
);
return cn(...font, { dark, light });
}
```
## Using Different Fonts for Headings and Body
Create visual hierarchy by using different fonts for headings and body text:
```tsx title="apps/web/lib/fonts.ts"
import { Inter as SansFont, Playfair_Display as HeadingFont } from 'next/font/google';
import { cn } from '@kit/ui/utils';
const sans = SansFont({
subsets: ['latin'],
variable: '--font-sans-fallback',
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial'],
preload: true,
weight: ['300', '400', '500', '600', '700'],
});
const heading = HeadingFont({
subsets: ['latin'],
variable: '--font-heading',
fallback: ['Georgia', 'Times New Roman', 'serif'],
preload: true,
weight: ['400', '500', '600', '700'],
});
export { sans, heading };
export function getFontsClassName(theme?: string) {
const dark = theme === 'dark';
const light = !dark;
const font = [sans.variable, heading.variable].reduce<string[]>(
(acc, curr) => {
if (acc.includes(curr)) return acc;
return [...acc, curr];
},
[],
);
return cn(...font, { dark, light });
}
```
Then update `apps/web/styles/shadcn-ui.css` to use the heading font in the `@theme inline` block:
```css title="apps/web/styles/shadcn-ui.css"
@theme inline {
--font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback);
--font-heading: var(--font-heading), Georgia, serif;
}
```
## Using Local Fonts
For fonts not available on Google Fonts, or for complete control over font files:
```tsx title="apps/web/lib/fonts.ts"
import localFont from 'next/font/local';
import { cn } from '@kit/ui/utils';
const sans = localFont({
src: [
{
path: '../fonts/CustomFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../fonts/CustomFont-Medium.woff2',
weight: '500',
style: 'normal',
},
{
path: '../fonts/CustomFont-Bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-sans-fallback',
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial'],
preload: true,
});
const heading = sans;
export { sans, heading };
```
Place font files in `apps/web/fonts/` directory. Supported formats: `.woff2` (recommended), `.woff`, `.ttf`, `.otf`.
## Removing Apple System Font Default
By default, Makerkit prioritizes Apple's system font on macOS and iOS for a native feel. To use your chosen font consistently across all platforms:
Edit the `@theme inline` block in `apps/web/styles/shadcn-ui.css`:
```css title="apps/web/styles/shadcn-ui.css"
@theme inline {
/* Remove -apple-system and BlinkMacSystemFont to use your font everywhere */
--font-sans: var(--font-sans-fallback);
--font-heading: var(--font-sans);
}
```
This ensures your Google Font or local font displays on Apple devices instead of San Francisco.
## Popular Font Combinations
### Modern SaaS (Clean and Professional)
```tsx
import { Inter as SansFont } from 'next/font/google';
// Headings and body: Inter
```
### Editorial (Content-Heavy Apps)
```tsx
import { Source_Sans_3 as SansFont, Source_Serif_4 as HeadingFont } from 'next/font/google';
// Body: Source Sans 3
// Headings: Source Serif 4
```
### Startup (Friendly and Approachable)
```tsx
import { DM_Sans as SansFont } from 'next/font/google';
// Headings and body: DM Sans
```
### Technical (Developer Tools)
```tsx
import { IBM_Plex_Sans as SansFont, IBM_Plex_Mono as MonoFont } from 'next/font/google';
// Body: IBM Plex Sans
// Code: IBM Plex Mono
```
### Premium (Luxury/Finance)
```tsx
import { Outfit as SansFont } from 'next/font/google';
// Headings and body: Outfit
```
## Font Variable Reference
The font system uses CSS variables defined in two places:
| Variable | Defined In | Purpose |
|----------|------------|---------|
| `--font-sans-fallback` | `fonts.ts` | Next.js optimized font |
| `--font-heading` | `fonts.ts` | Heading font (if different) |
| `--font-sans` | `shadcn-ui.css` | Final font stack with system fallbacks |
Tailwind uses these through `theme.css`:
```css title="apps/web/styles/shadcn-ui.css"
@theme inline {
--font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback);
--font-heading: var(--font-sans);
}
```
## Optimizing Font Loading
### Preload Critical Fonts
```tsx
const sans = SansFont({
// ...
preload: true, // Preload for faster initial render
display: 'swap', // Show fallback immediately, swap when loaded
});
```
### Subset for Faster Loading
```tsx
const sans = SansFont({
// ...
subsets: ['latin'], // Only load Latin characters
// Or load multiple subsets if needed:
// subsets: ['latin', 'latin-ext', 'cyrillic'],
});
```
### Specify Only Needed Weights
```tsx
const sans = SansFont({
// ...
weight: ['400', '500', '700'], // Only weights you actually use
// Avoid: weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900']
});
```
## Common Mistakes
**Loading too many font weights**: Each weight adds to bundle size. Only include weights you actually use (typically 400, 500, 600, 700).
**Forgetting to update CSS variables**: After changing fonts in `fonts.ts`, you may need to update `shadcn-ui.css` if you want to remove the Apple system font priority or configure the heading font.
**Using display: 'block'**: This causes invisible text until fonts load (FOIT). Use `display: 'swap'` for better perceived performance.
**Not testing on Windows**: Apple system fonts don't exist on Windows. Always test your fallback fonts on non-Apple devices.
## Verification
After updating fonts:
1. Check the Network tab in DevTools for font files loading
2. Verify fonts render on both Mac and Windows
3. Test with slow network throttling to see fallback behavior
4. Run Lighthouse to check for font-related performance issues
```bash
# Quick check for font loading
pnpm dev
# Open DevTools > Network > Filter: Font
# Verify your custom font files are loading
```
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Why does my custom font not appear on Mac?", "answer": "By default, Makerkit prioritizes Apple system fonts. Edit shadcn-ui.css and remove -apple-system and BlinkMacSystemFont from the --font-sans variable to use your custom font on Apple devices."},
{"question": "How do I add a monospace font for code blocks?", "answer": "Import a monospace font in fonts.ts, export it, and add a --font-mono CSS variable. Then configure it in theme.css. Consider IBM Plex Mono, JetBrains Mono, or Fira Code."},
{"question": "Can I use variable fonts?", "answer": "Yes. Next.js supports variable fonts. Specify weight as a range: weight: '100 900'. This loads a single file that supports all weights, often smaller than multiple static font files."},
{"question": "How do I improve font loading performance?", "answer": "Limit font weights to those you use, enable preload: true, use display: 'swap', and only load needed subsets. Variable fonts can also reduce total download size."}
]
/%}
## Next Steps
- Back to [Customization Overview](/docs/next-supabase-turbo/customization)
- Configure your [theme colors](/docs/next-supabase-turbo/customization/theme) to complement your typography
- Set up your [layout style](/docs/next-supabase-turbo/customization/layout-style) for navigation
- Update your [application logo](/docs/next-supabase-turbo/customization/logo)

View File

@@ -0,0 +1,285 @@
---
status: "published"
label: "Layout Style"
title: "Configure Navigation Layout | Next.js Supabase SaaS Kit"
order: 4
description: "Choose between sidebar and header navigation layouts, configure collapsed states, and customize the navigation experience for your SaaS application."
---
Makerkit offers two navigation layouts: **sidebar** (default) and **header**. You can configure each workspace independently, with separate settings for personal accounts and team accounts. All layout options are controlled through environment variables.
## Quick Configuration
Set your preferred layout in `.env.local`:
```bash title=".env.local"
# Personal account workspace layout
NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar
# Team account workspace layout
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar
```
Available values: `sidebar`, `header`, or `custom`.
## Layout Options Compared
### Sidebar Layout (Default)
The sidebar layout places navigation on the left side of the screen, providing a persistent, vertically-oriented menu.
{% img src="/assets/images/docs/turbo-sidebar-layout.webp" width="2522" height="1910" /%}
**Best for:**
- Apps with many navigation items (5+)
- Complex feature sets needing categorized menus
- Desktop-first applications
- Enterprise or admin-heavy dashboards
**Configuration:**
```bash title=".env.local"
NEXT_PUBLIC_USER_NAVIGATION_STYLE=sidebar
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar
```
### Header Layout
The header layout places navigation horizontally at the top of the screen, with a more compact, traditional web app feel.
{% img src="/assets/images/docs/turbo-header-layout.webp" width="3282" height="1918" /%}
**Best for:**
- Apps with fewer navigation items (3-5)
- Consumer-facing products
- Mobile-first designs
- Simple, focused applications
**Configuration:**
```bash title=".env.local"
NEXT_PUBLIC_USER_NAVIGATION_STYLE=header
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=header
```
## Sidebar Behavior Options
### Default Collapsed State
Control whether the sidebar starts expanded or collapsed:
```bash title=".env.local"
# Personal account sidebar (home workspace)
NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED=false
# Team account sidebar
NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED=false
```
Set to `true` to start with a collapsed icon-only sidebar. Users can still expand it manually.
### Collapsible Style
Choose how the sidebar collapses and expands:
```bash title=".env.local"
# Options: offcanvas, icon, none
NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=offcanvas
```
| Style | Behavior |
|-------|----------|
| `offcanvas` | Sidebar slides in/out as an overlay (mobile-friendly) |
| `icon` | Sidebar collapses to icons only, expanding on hover |
| `none` | Sidebar cannot be collapsed |
### Sidebar Trigger Visibility
Show or hide the sidebar toggle button:
```bash title=".env.local"
NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER=true
```
Set to `false` if you want the sidebar to remain in its configured state without user control.
## Complete Configuration Example
Here's a full example for a team-focused SaaS with different layouts per workspace:
```bash title=".env.local"
# Personal account: simple header navigation
NEXT_PUBLIC_USER_NAVIGATION_STYLE=header
# Team workspace: full sidebar with collapsed default
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=sidebar
NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED=true
NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE=icon
NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER=true
# Theme settings
NEXT_PUBLIC_DEFAULT_THEME_MODE=system
NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
```
## Customizing Navigation Items
Navigation items are defined in configuration files, not environment variables:
| Workspace | Configuration File |
|-----------|-------------------|
| Personal Account | `apps/web/config/personal-account-navigation.config.tsx` |
| Team Account | `apps/web/config/team-account-navigation.config.tsx` |
### Personal Account Navigation
```tsx title="apps/web/config/personal-account-navigation.config.tsx"
import { CreditCard, Home, User, Settings } from 'lucide-react';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import pathsConfig from '~/config/paths.config';
const iconClasses = 'w-4';
const routes = [
{
label: 'common.routes.application',
children: [
{
label: 'common.routes.home',
path: pathsConfig.app.home,
Icon: <Home className={iconClasses} />,
highlightMatch: `${pathsConfig.app.home}$`,
},
],
},
{
label: 'common.routes.settings',
children: [
{
label: 'common.routes.profile',
path: pathsConfig.app.personalAccountSettings,
Icon: <User className={iconClasses} />,
},
{
label: 'common.routes.billing',
path: pathsConfig.app.personalAccountBilling,
Icon: <CreditCard className={iconClasses} />,
},
],
},
];
export const personalAccountNavigationConfig = NavigationConfigSchema.parse({
routes,
style: process.env.NEXT_PUBLIC_USER_NAVIGATION_STYLE,
sidebarCollapsed: process.env.NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED,
sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE,
});
```
### Adding a New Navigation Item
To add a custom page to the navigation:
```tsx title="apps/web/config/personal-account-navigation.config.tsx"
import { BarChart3 } from 'lucide-react';
const routes = [
{
label: 'common.routes.application',
children: [
{
label: 'common.routes.home',
path: pathsConfig.app.home,
Icon: <Home className={iconClasses} />,
highlightMatch: `${pathsConfig.app.home}$`,
},
// Add your custom navigation item
{
label: 'common.routes.analytics',
path: '/home/analytics',
Icon: <BarChart3 className={iconClasses} />,
},
],
},
// ... rest of routes
];
```
Remember to add the translation key to your locale files:
```json title="apps/web/i18n/messages/en/common.json"
{
"routes": {
"analytics": "Analytics"
}
}
```
## Navigation Schema Reference
The `NavigationConfigSchema` supports these properties:
```typescript
interface NavigationConfig {
routes: {
label: string; // Translation key for section header
collapsible?: boolean; // Allow section to collapse (default: false)
children: {
label: string; // Translation key for item
path: string; // Route path
Icon?: ReactNode; // Lucide icon component
highlightMatch?: string; // Regex pattern for active route highlighting
}[];
}[];
style?: 'sidebar' | 'header' | 'custom';
sidebarCollapsed?: boolean | string;
sidebarCollapsedStyle?: 'offcanvas' | 'icon' | 'none';
}
```
## Environment Variables Reference
| Variable | Default | Options | Description |
|----------|---------|---------|-------------|
| `NEXT_PUBLIC_USER_NAVIGATION_STYLE` | `sidebar` | `sidebar`, `header`, `custom` | Personal account layout |
| `NEXT_PUBLIC_TEAM_NAVIGATION_STYLE` | `sidebar` | `sidebar`, `header`, `custom` | Team account layout |
| `NEXT_PUBLIC_HOME_SIDEBAR_COLLAPSED` | `false` | `true`, `false` | Personal sidebar default state |
| `NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED` | `false` | `true`, `false` | Team sidebar default state |
| `NEXT_PUBLIC_SIDEBAR_COLLAPSIBLE_STYLE` | `icon` | `offcanvas`, `icon`, `none` | Collapse behavior |
| `NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER` | `true` | `true`, `false` | Show collapse toggle |
## Common Mistakes
**Mixing layout styles inconsistently**: If personal accounts use header layout but teams use sidebar, the experience can feel disjointed. Consider the transition between workspaces.
**Too many items in header layout**: Header navigation works best with 3-5 top-level items. More than that causes horizontal overflow or cramped spacing. Use sidebar layout for complex navigation.
**Forgetting mobile behavior**: Sidebar layout automatically converts to a slide-out drawer on mobile. Test both layouts on narrow viewports.
**Not updating translations**: Navigation labels use translation keys. Adding items without corresponding translations shows raw keys like `common.routes.analytics`.
## Verification
After changing layout configuration:
1. Clear your browser's local storage (layout preferences are cached)
2. Restart the dev server for environment variable changes
3. Test both personal account and team account workspaces
4. Verify mobile responsiveness at 375px viewport width
5. Check that sidebar collapse/expand works correctly
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Can I use different layouts for personal and team accounts?", "answer": "Yes. Set NEXT_PUBLIC_USER_NAVIGATION_STYLE and NEXT_PUBLIC_TEAM_NAVIGATION_STYLE to different values. This is useful when team workspaces need more navigation complexity than personal accounts."},
{"question": "How do I create a completely custom layout?", "answer": "Set the navigation style to 'custom' and implement your own layout component. You'll need to modify the layout files in apps/web/app/home/ to use your custom navigation component."},
{"question": "Why isn't my sidebar staying collapsed?", "answer": "User preferences are stored in local storage and override environment defaults. Clear local storage or use the browser's incognito mode to test default behavior."},
{"question": "How do I add icons to navigation items?", "answer": "Import icons from lucide-react and pass them as the Icon prop. Use className='w-4' to maintain consistent sizing with other navigation icons."}
]
/%}
## Next Steps
- Back to [Customization Overview](/docs/next-supabase-turbo/customization)
- Configure your [theme colors](/docs/next-supabase-turbo/customization/theme) to match your navigation style
- Set up [custom fonts](/docs/next-supabase-turbo/customization/fonts) for navigation typography
- Update your [application logo](/docs/next-supabase-turbo/customization/logo) for the sidebar/header

View File

@@ -0,0 +1,194 @@
---
status: "published"
label: "Updating the Logo"
title: "Customize Your Application Logo | Next.js Supabase SaaS Kit"
order: 1
description: "Replace the default Makerkit logo with your own brand logo using SVG, image files, or custom React components."
---
Replace the default Makerkit logo by editing the `AppLogo` component at `apps/web/components/app-logo.tsx`. This single component controls the logo across your entire application: authentication pages, site header, footer, sidebar, and email templates.
## Quick Start
Open `apps/web/components/app-logo.tsx` and replace the existing SVG with your logo:
```tsx title="apps/web/components/app-logo.tsx"
import Link from 'next/link';
import { cn } from '@kit/ui/utils';
function LogoImage({ className }: { className?: string }) {
return (
<img
src="/images/logo.svg"
alt="Your Company Name"
className={cn('w-[80px] lg:w-[95px]', className)}
/>
);
}
export function AppLogo({
href,
label,
className,
}: {
href?: string | null;
className?: string;
label?: string;
}) {
if (href === null) {
return <LogoImage className={className} />;
}
return (
<Link aria-label={label ?? 'Home Page'} href={href ?? '/'}>
<LogoImage className={className} />
</Link>
);
}
```
Place your logo file in `apps/web/public/images/` and update the `src` path accordingly.
## Logo Implementation Options
### Option 1: SVG Component (Recommended)
Inline SVGs provide the best performance and allow dynamic styling with Tailwind classes:
```tsx title="apps/web/components/app-logo.tsx"
function LogoImage({ className }: { className?: string }) {
return (
<svg
className={cn('w-[95px] h-auto', className)}
viewBox="0 0 100 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="fill-primary dark:fill-white"
d="M10 5h80v22H10z"
/>
{/* Your SVG paths */}
</svg>
);
}
```
**Benefits:**
- Supports `fill-primary` for automatic theme color adaptation
- Responds to dark mode with `dark:fill-white`
- Scales without quality loss
- No additional HTTP requests
### Option 2: Next.js Image Component
For PNG, JPG, or WebP logos, use `next/image` for automatic optimization:
```tsx title="apps/web/components/app-logo.tsx"
import Image from 'next/image';
function LogoImage({ className }: { className?: string }) {
return (
<Image
src="/images/logo.png"
alt="Your Company Name"
width={95}
height={32}
className={cn('w-[80px] lg:w-[95px] h-auto', className)}
priority
/>
);
}
```
### Option 3: Dark Mode Variants
When your logo needs different versions for light and dark modes:
```tsx title="apps/web/components/app-logo.tsx"
import Image from 'next/image';
import { cn } from '@kit/ui/utils';
function LogoImage({ className }: { className?: string }) {
return (
<>
<Image
src="/images/logo-dark.svg"
alt="Your Company Name"
width={95}
height={32}
className={cn('hidden dark:block w-[80px] lg:w-[95px]', className)}
priority
/>
<Image
src="/images/logo-light.svg"
alt="Your Company Name"
width={95}
height={32}
className={cn('block dark:hidden w-[80px] lg:w-[95px]', className)}
priority
/>
</>
);
}
```
## Where the Logo Appears
The `AppLogo` component renders in these locations:
| Location | File Path | Notes |
|----------|-----------|-------|
| Site Header | `packages/ui/src/makerkit/marketing/header.tsx` | Marketing pages |
| Site Footer | `packages/ui/src/makerkit/marketing/footer.tsx` | All pages |
| Auth Pages | `apps/web/app/[locale]/auth/layout.tsx` | Sign in, sign up |
| App Sidebar | `packages/ui/src/makerkit/sidebar-navigation.tsx` | Dashboard (when team accounts disabled) |
| Email Templates | `packages/email-templates/src/` | Transactional emails |
## Favicon and Social Images
Update these additional brand assets in `apps/web/app/`:
```
apps/web/app/
├── favicon.ico # Browser tab icon (32x32)
├── icon.png # PWA icon (512x512)
├── apple-icon.png # iOS home screen (180x180)
└── opengraph-image.png # Social sharing (1200x630)
```
Generate these from your logo using tools like [RealFaviconGenerator](https://realfavicongenerator.net/) or [Favicon.io](https://favicon.io/).
## Common Mistakes
**Using low-resolution images**: Logos appear blurry on high-DPI displays. Always use SVG when possible, or provide 2x/3x image assets.
**Forgetting alt text**: Screen readers need descriptive alt text. Use your company name, not "logo".
**Hard-coded dimensions**: Use responsive classes like `w-[80px] lg:w-[95px]` instead of fixed pixel widths to ensure the logo scales appropriately on mobile.
**Missing priority attribute**: Add `priority` to Next.js Image components for above-the-fold logos to prevent layout shift.
## Verification
After updating your logo:
1. Check the marketing header at `http://localhost:3000`
2. Verify the auth pages at `http://localhost:3000/auth/sign-in`
3. Test dark mode toggle to confirm logo visibility
4. Inspect mobile viewport (375px width) for proper sizing
{% faq
title="Frequently Asked Questions"
items=[
{"question": "How do I make my SVG logo change color with the theme?", "answer": "Use Tailwind's fill classes on your SVG paths: fill-primary for the default theme color, or dark:fill-white to change in dark mode. Remove any hardcoded fill attributes from the SVG."},
{"question": "What size should my logo be?", "answer": "Design for 95px width on desktop and 80px on mobile. SVGs scale automatically. For raster images, export at 2x resolution (190x64 pixels minimum) to support high-DPI displays."},
{"question": "Can I use different logos in different parts of the app?", "answer": "Yes. You can modify the AppLogo component to accept a variant prop or create separate components. However, maintaining brand consistency is recommended."},
{"question": "How do I update the logo in email templates?", "answer": "Email templates use the same AppLogo component where possible, but some email clients require inline images. Check packages/email-templates/src/components/ for email-specific logo handling."}
]
/%}
## Next Steps
- Back to [Customization Overview](/docs/next-supabase-turbo/customization)
- Configure your [brand colors and theme](/docs/next-supabase-turbo/customization/theme)
- Customize your [application fonts](/docs/next-supabase-turbo/customization/fonts)

View File

@@ -0,0 +1,236 @@
---
status: "published"
label: "Tailwind CSS"
title: "Tailwind CSS Configuration | Next.js Supabase SaaS Kit"
order: -1
description: "Configure Tailwind CSS 4, extend the design system, and customize styles across your Makerkit monorepo application."
---
Makerkit uses Tailwind CSS 4 with Shadcn UI for styling. All style configuration lives in `apps/web/styles/`, with the main entry point at `globals.css`. This guide covers how to customize Tailwind, add new packages to the content paths, and extend the design system.
## Style File Structure
The styling system uses these files in `apps/web/styles/`:
```
apps/web/styles/
├── globals.css # Main entry point, imports everything
├── theme.css # Theme color variables (light/dark mode, :root/.dark)
├── shadcn-ui.css # Maps CSS variables to Tailwind's @theme inline
├── makerkit.css # Makerkit-specific component styles
└── markdoc.css # Content/documentation styles
```
## Tailwind CSS 4 Configuration
Tailwind CSS 4 uses CSS-based configuration instead of JavaScript. The `@theme inline` directive in `shadcn-ui.css` maps your CSS variables to Tailwind design tokens:
```css title="apps/web/styles/shadcn-ui.css"
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* Border radius */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
/* Font families */
--font-sans: -apple-system, BlinkMacSystemFont, var(--font-sans-fallback);
--font-heading: var(--font-sans);
}
```
The actual color values are defined in `theme.css` using oklch format (see [Theme Colors](/docs/next-supabase-turbo/customization/theme)).
These tokens become available as Tailwind utilities: `bg-primary`, `text-foreground`, `rounded-lg`, etc.
## Adding Content Paths for New Packages
When you create a new package in the monorepo, Tailwind needs to know where to scan for class names. Add a `@source` directive in `apps/web/styles/globals.css`:
```css title="apps/web/styles/globals.css"
@import 'tailwindcss';
@import 'tw-animate-css';
/* local styles */
@import './theme.css';
@import './shadcn-ui.css';
@import './markdoc.css';
@import './makerkit.css';
/* content sources - update the below if you add a new path */
@source '../../../packages/*/src/**/*.{ts,tsx}';
@source '../../../packages/features/*/src/**/*.{ts,tsx}';
@source '../../../packages/billing/*/src/**/*.{ts,tsx}';
@source '../../../packages/plugins/*/src/**/*.{ts,tsx}';
@source '../../../packages/cms/*/src/**/*.{ts,tsx}';
@source '../{app,components,config,lib}/**/*.{ts,tsx}';
/* Add your new package here */
@source '../../../packages/your-package/src/**/*.{ts,tsx}';
```
The `@source` directive is the Tailwind CSS 4 replacement for the `content` array in the old `tailwind.config.ts`.
## Custom Utility Classes
Add custom utilities using the `@utility` directive in `makerkit.css` or a new CSS file:
```css title="apps/web/styles/makerkit.css"
@utility container {
@apply mx-auto px-4 lg:px-8 xl:max-w-[80rem];
}
```
Or add utilities in a `@layer`:
```css
@layer utilities {
.text-balance {
text-wrap: balance;
}
.scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
}
```
## Extending the Theme
Add custom design tokens in `shadcn-ui.css` inside the `@theme inline` block:
```css title="apps/web/styles/shadcn-ui.css"
@theme inline {
/* ... existing tokens ... */
/* Custom colors */
--color-brand: var(--brand);
--color-brand-light: var(--brand-light);
}
```
Then define the values in `theme.css`:
```css title="apps/web/styles/theme.css"
:root {
--brand: oklch(65% 0.2 250);
--brand-light: oklch(85% 0.1 250);
}
```
Use these in your components:
```tsx
<div className="bg-brand text-brand-light">
Content
</div>
```
## Component-Level Styles
For complex component styles, use `@layer components` in `makerkit.css`:
```css title="apps/web/styles/makerkit.css"
@layer components {
.card-hover {
@apply transition-all duration-200;
@apply hover:shadow-lg hover:-translate-y-0.5;
}
.btn-gradient {
@apply bg-gradient-to-r from-primary to-accent;
@apply text-primary-foreground;
@apply hover:opacity-90 transition-opacity;
}
}
```
## Shadcn UI Component Customization
Override Shadcn component styles by targeting their classes:
```css title="apps/web/styles/shadcn-ui.css"
@layer components {
/* Custom button variants */
.btn-primary-gradient {
@apply bg-gradient-to-r from-blue-600 to-indigo-600;
@apply hover:from-blue-700 hover:to-indigo-700;
}
}
```
## Dark Mode Utilities
Create dark-mode-aware utilities using the `dark:` variant:
```css
@layer utilities {
.glass {
@apply bg-white/80 backdrop-blur-sm;
@apply dark:bg-neutral-900/80;
}
.surface-elevated {
@apply bg-white shadow-sm;
@apply dark:bg-neutral-800 dark:shadow-none;
}
}
```
The dark variant is configured in `theme.css` as:
```css
@custom-variant dark (&:is(.dark *));
```
## Common Mistakes
**Missing content paths**: New packages won't have their Tailwind classes compiled if you forget to add a `@source` directive. Styles will appear missing in production even if they work in development.
**Using `@apply` excessively**: Reserve `@apply` for reusable component patterns. For one-off styles, use utility classes directly in JSX. Excessive `@apply` increases CSS bundle size.
**Forgetting `@layer` directives**: Custom styles without `@layer` can have specificity issues. Always wrap custom styles in `@layer base`, `@layer components`, or `@layer utilities`.
**Hardcoding colors**: Use theme variables (`bg-primary`, `text-foreground`) instead of hardcoded colors (`bg-blue-500`). This ensures consistency and makes theme changes easier.
## Verification
After modifying Tailwind configuration:
1. Restart the dev server (Tailwind config changes require a restart)
2. Run `pnpm build` to verify all classes compile correctly
3. Check production build for missing styles: `pnpm build && pnpm start`
4. Verify dark mode works for any new utilities
```bash
# Quick verification commands
pnpm dev # Development server
pnpm build # Production build (catches missing content paths)
pnpm typecheck # Type checking
```
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Why are my Tailwind classes not working in production?", "answer": "The most common cause is missing @source directives in globals.css. Add your package path as a @source directive and rebuild. Classes must exist in scanned files to be included in the production CSS bundle."},
{"question": "How do I add a completely custom color?", "answer": "Define the CSS variable in theme.css (:root block), e.g. --brand: oklch(65% 0.2 250). Then map it in shadcn-ui.css inside @theme inline: --color-brand: var(--brand). Use it as bg-brand or text-brand in your components."},
{"question": "Should I use @apply or inline utilities?", "answer": "Prefer inline utilities for most cases. Use @apply only for frequently repeated patterns that need to stay in sync. Inline utilities are more explicit and easier to maintain."},
{"question": "How do I override Shadcn component styles?", "answer": "Add overrides in shadcn-ui.css within @layer components. Target the specific component classes or create variant classes. You can also pass className props to override individual instances."}
]
/%}
## Next Steps
- Back to [Customization Overview](/docs/next-supabase-turbo/customization)
- Configure your [theme colors](/docs/next-supabase-turbo/customization/theme) for brand consistency
- Set up [custom fonts](/docs/next-supabase-turbo/customization/fonts) for typography
- Choose your [layout style](/docs/next-supabase-turbo/customization/layout-style) for navigation

View File

@@ -0,0 +1,252 @@
---
status: "published"
label: "Updating the Theme"
title: "Customize Your Shadcn UI Theme Colors | Next.js Supabase SaaS Kit"
order: 0
description: "Configure brand colors, dark mode, and Shadcn UI theme variables in your Makerkit application using Tailwind CSS 4."
---
Customize your application's color scheme by editing `apps/web/styles/theme.css`. This file defines all theme variables (`:root` and `.dark`) that Shadcn UI components use, giving you complete control over your brand colors in both light and dark modes.
## Quick Theme Change
The fastest way to update your theme is to use the [Shadcn UI Themes page](https://ui.shadcn.com/themes):
1. Choose a color scheme on the Shadcn theme builder
2. Copy the generated CSS variables
3. Paste them into `apps/web/styles/theme.css`
4. Wrap color values with `hsl()` or `oklch()` functions (Tailwind CSS 4 requirement)
## Theme File Structure
Makerkit's theming uses three CSS files in `apps/web/styles/`:
| File | Purpose |
|------|---------|
| `theme.css` | Your theme colors - `:root` and `.dark` variables (edit this file) |
| `shadcn-ui.css` | Maps CSS variables to Tailwind's `@theme inline` system |
| `globals.css` | Imports all styles and base Tailwind directives |
## Core Theme Variables
Edit `apps/web/styles/theme.css` to customize these color groups. Colors use oklch format:
```css title="apps/web/styles/theme.css"
:root {
/* Background and text */
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* Primary brand color (buttons, links, focus rings) */
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
/* Secondary actions and elements */
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
/* Muted backgrounds and text */
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
/* Hover states and accents */
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
/* Destructive actions (delete, error) */
--destructive: oklch(0.58 0.22 27);
/* Cards and popovers */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* Borders and inputs */
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* Border radius */
--radius: 0.625rem;
/* Sidebar-specific colors */
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Chart colors */
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
}
```
## Dark Mode Configuration
Define dark mode colors in the `.dark` class within the same file:
```css title="apps/web/styles/theme.css"
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.16 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--card: oklch(0.16 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.16 0 0);
--popover-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.16 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
```
## Converting Shadcn Theme Colors to Tailwind CSS 4
Shadcn's theme builder outputs HSL values without the function wrapper. Tailwind CSS 4 requires explicit color functions.
**Shadcn output:**
```css
--primary: 222.2 47.4% 11.2%;
```
**Tailwind CSS 4 format:**
```css
--primary: hsl(222.2 47.4% 11.2%);
```
You can also use `oklch()` for better color perception:
```css
--primary: oklch(21.03% 0.0318 264.65);
```
Use any AI tool or color converter to transform the values. The key is ensuring every color value is wrapped in a color function.
## Using Tailwind Color Palette
Reference Tailwind's built-in colors using CSS variables:
```css
--primary: var(--color-blue-600);
--destructive: var(--color-red-500);
--accent: var(--color-indigo-100);
```
Available color scales: `slate`, `gray`, `zinc`, `neutral`, `stone`, `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`.
Each scale includes shades from `50` (lightest) to `950` (darkest).
## Theme Mode Configuration
Control how theme switching works with these environment variables:
```bash title=".env.local"
# Default theme: light, dark, or system
NEXT_PUBLIC_DEFAULT_THEME_MODE=system
# Show/hide the theme toggle in the UI
NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
```
## Custom Brand Color Example
Here's a complete example using a custom indigo brand color:
```css title="apps/web/styles/theme.css"
:root {
--primary: oklch(0.457 0.24 277.023); /* indigo-600 */
--primary-foreground: oklch(1 0 0); /* white */
--secondary: oklch(0.943 0.029 282.832); /* indigo-100 */
--secondary-foreground: oklch(0.272 0.174 282.572); /* indigo-900 */
--accent: oklch(0.969 0.014 282.832); /* indigo-50 */
--accent-foreground: oklch(0.272 0.174 282.572);
--ring: oklch(0.539 0.233 277.117); /* indigo-500 */
}
.dark {
--primary: oklch(0.673 0.208 277.568); /* indigo-400 */
--primary-foreground: oklch(0.208 0.153 283.264); /* indigo-950 */
--secondary: oklch(0.272 0.174 282.572); /* indigo-900 */
--secondary-foreground: oklch(0.943 0.029 282.832);
--accent: oklch(0.351 0.209 281.288); /* indigo-800 */
--accent-foreground: oklch(0.943 0.029 282.832);
--ring: oklch(0.673 0.208 277.568); /* indigo-400 */
}
```
## Common Mistakes
**Forgetting color function wrappers**: Tailwind CSS 4 requires `hsl()`, `oklch()`, or `rgb()` around color values. Raw space-separated values like `222 47% 11%` won't work.
**Low contrast ratios**: Ensure sufficient contrast between foreground and background colors. Use tools like [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify WCAG compliance.
**Inconsistent dark mode**: Always define dark mode variants for every color you customize. Missing dark mode variables cause jarring visual inconsistencies.
**Not testing all components**: Theme changes affect every Shadcn component. After updating colors, click through your app to verify buttons, inputs, cards, and dialogs all look correct.
## Verification
After updating your theme:
1. Start the dev server: `pnpm dev`
2. Toggle between light and dark modes
3. Check these component types:
- Primary buttons (`--primary`)
- Form inputs (`--input`, `--border`, `--ring`)
- Cards and dialogs (`--card`, `--popover`)
- Destructive actions (`--destructive`)
- Sidebar navigation (`--sidebar-*` variables)
{% faq
title="Frequently Asked Questions"
items=[
{"question": "How do I use a custom color not in Tailwind's palette?", "answer": "Define your color using hsl() or oklch() functions directly. For example: --primary: hsl(250 60% 45%). You can use any valid CSS color value wrapped in a color function."},
{"question": "Why do my colors look different than the Shadcn theme preview?", "answer": "Tailwind CSS 4 requires explicit color functions (hsl, oklch, rgb). Convert space-separated HSL values to hsl() function calls. Also ensure you're using the same color space."},
{"question": "Can I have different themes for different pages?", "answer": "The theme applies globally. For page-specific styling, use CSS classes or component-level overrides rather than modifying theme variables. You could also implement a theme context for programmatic switching."},
{"question": "How do I disable dark mode entirely?", "answer": "Set NEXT_PUBLIC_DEFAULT_THEME_MODE=light and NEXT_PUBLIC_ENABLE_THEME_TOGGLE=false in your environment variables. This forces light mode and hides the toggle."}
]
/%}
## Next Steps
- Back to [Customization Overview](/docs/next-supabase-turbo/customization)
- Set up your [Tailwind CSS configuration](/docs/next-supabase-turbo/customization/tailwind-css) for additional customizations
- Configure [custom fonts](/docs/next-supabase-turbo/customization/fonts) for your brand typography
- Update your [application logo](/docs/next-supabase-turbo/customization/logo) to match your theme

View File

@@ -0,0 +1,209 @@
---
status: "published"
description: "Learn how to set up captcha protection for your API routes."
title: "Captcha Protection for your API Routes"
label: "Captcha Protection"
order: 7
---
For captcha protection, we use [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile).
{% sequence title="How to set up captcha protection for your API routes" description="Learn how to set up captcha protection for your API routes" %}
[Setting up the environment variables](#setting-up-the-environment-variables)
[Enabling the captcha protection](#enabling-the-captcha-protection)
[Using captcha in your components](#using-captcha-in-your-components)
[Verifying the token](#verifying-the-token)
{% /sequence %}
## Setting up the environment variables
To enable it, you need to set the following environment variables:
```bash
CAPTCHA_SECRET_TOKEN=
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
```
You can find the `CAPTCHA_SECRET_TOKEN` in the Turnstile configuration. The `NEXT_PUBLIC_CAPTCHA_SITE_KEY` is public and safe to share. Instead, the `CAPTCHA_SECRET_TOKEN` should be kept secret.
This guide assumes you have correctly set up your Turnstile configuration. If you haven't, please refer to the https://developers.cloudflare.com/turnstile.
## Enabling the captcha protection
When you set the token in the environment variables, the kit will automatically protect your API routes with captcha.
**Important:** You also need to set the token in the Supabase Dashboard!
## Using Captcha in Your Components
The kit provides two clean APIs for captcha integration depending on your use case.
### Option 1: Using the useCaptcha Hook
For auth containers and simple forms, use the useCaptcha hook for zero-boilerplate captcha integration:
```tsx
import { useCaptcha } from '@kit/auth/captcha/client';
function MyComponent({ captchaSiteKey }) {
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const handleSubmit = async (data) => {
try {
await myServerAction({
...data,
captchaToken: captcha.token,
});
} finally {
// Always reset after submission
captcha.reset();
}
};
return (
<form onSubmit={handleSubmit}>
{captcha.field}
<button type="submit">Submit</button>
</form>
);
}
```
The useCaptcha hook returns:
- `token` - The current captcha token
- `reset()` - Function to reset the captcha widget
- `field` - The captcha component to render
### Option 2: React Hook Form Integration
For forms using react-hook-form, use the CaptchaField component with automatic form integration:
```tsx
import { useForm } from 'react-hook-form';
import { CaptchaField } from '@kit/auth/captcha/client';
function MyForm() {
const form = useForm({
defaultValues: {
message: '',
captchaToken: '',
},
});
const handleSubmit = async (data) => {
try {
await myServerAction(data);
form.reset(); // Automatically resets captcha too
} catch (error) {
// Handle error
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
{/* Your form fields */}
<CaptchaField
siteKey={config.captchaSiteKey}
control={form.control}
name="captchaToken"
/>
<button type="submit">Submit</button>
</form>
</Form>
);
}
```
When using React Hook Form integration:
- The captcha token is automatically set in the form state
- Calling form.reset() automatically resets the captcha
- No manual state management needed
## Using with Server Actions
Define your server action schema to include the captchaToken:
```tsx
import * as z from 'zod';
import { captchaActionClient } from '@kit/next/safe-action';
const MySchema = z.object({
message: z.string(),
captchaToken: z.string(),
});
export const myServerAction = captchaActionClient
.inputSchema(MySchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// Your action code - captcha is automatically verified
console.log(data.message);
});
```
The `captchaActionClient` automatically:
1. Extracts the `captchaToken` from the data
2. Verifies it with Cloudflare Turnstile
3. Throws an error if verification fails
### Important Notes
- **Token Validity**: A captcha token is valid for one request only
- **Always Reset:** Always call captcha.reset() (or form.reset() with RHF) after submission, whether successful or not
- **Automatic Renewal**: The library automatically renews tokens when needed, but you must reset after consumption
## Verifying the Token Manually
If you need to verify the captcha token manually server-side (e.g., in API routes), use:
```tsx
import { verifyCaptchaToken } from '@kit/auth/captcha/server';
async function myApiHandler(request: Request) {
const token = request.headers.get('x-captcha-token');
// Throws an error if invalid
await verifyCaptchaToken(token);
// Your API logic
}
```
Note: If you use `captchaActionClient` or `enhanceRouteHandler` with captcha: true, verification is automatic and you don't need to call verifyCaptchaToken manually.
## Upgrading from v2
{% callout title="Differences with v2" %}
In v2, captcha-protected actions used `enhanceAction` with `{ captcha: true }`. In v3, use `captchaActionClient` from `@kit/next/safe-action` which handles captcha verification automatically. Zod imports also changed from `import { z } from 'zod'` to `import * as z from 'zod'`.
For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration).
{% /callout %}
## Migration from old API (prior to v2.18.3)
If you're migrating from the old `useCaptchaToken` hook:
Before:
```tsx
import { useCaptchaToken } from '@kit/auth/captcha/client';
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
// Manual state management required
```
After:
```tsx
import { useCaptcha } from '@kit/auth/captcha/client';
const captcha = useCaptcha({ siteKey: captchaSiteKey });
```

View File

@@ -0,0 +1,32 @@
---
status: "published"
title: "CSRF Protection"
description: "How CSRF protection works in Makerkit."
label: "CSRF Protection"
order: 6
---
## CSRF Protection
CSRF protection is handled automatically by Next.js when using Server Actions. You do not need to manage CSRF tokens manually.
### Server Actions
Server Actions are inherently protected against CSRF attacks by Next.js. The framework validates the origin of all Server Action requests, ensuring they come from the same origin as your application.
No additional configuration or token passing is needed.
### API Route Handlers
API Route Handlers under `/api/*` do not have CSRF protection, as they are typically used for webhooks, external services, and third-party integrations. If you need to protect an API route from unauthorized access, use authentication checks via `enhanceRouteHandler` with `auth: true`.
### Recommendations
- **Prefer Server Actions** for all mutations from client components. They provide built-in CSRF protection and type safety.
- **Use Route Handlers** only for webhooks, streaming responses, or integrations that require standard HTTP endpoints.
---
## V2 Legacy
In v2, Makerkit used `@edge-csrf/nextjs` middleware to protect non-API routes against CSRF attacks. A `useCsrfToken` hook from `@kit/shared/hooks` was used to retrieve the CSRF token and pass it as an `X-CSRF-Token` header on fetch requests. Both have been removed in v3 since Server Actions handle CSRF protection natively.

View File

@@ -0,0 +1,713 @@
---
status: "published"
title: "Client-Side Data Fetching with React Query"
label: "React Query"
description: "Use React Query (TanStack Query) for client-side data fetching in MakerKit. Covers queries, mutations, caching, optimistic updates, and combining with Server Components."
order: 5
---
React Query (TanStack Query v5) manages client-side data fetching with automatic caching, background refetching, and optimistic updates. MakerKit includes it pre-configured. Use React Query when you need real-time dashboards, infinite scroll, optimistic UI updates, or data shared across multiple components. For initial page loads, prefer Server Components. Tested with TanStack Query v5 (uses `gcTime` instead of `cacheTime`).
### When to use React Query and Server Components?
**Use React Query** for real-time updates, optimistic mutations, pagination, and shared client-side state.
**Use Server Components** for initial page loads and SEO content. Combine both: load data server-side, then hydrate React Query for client interactivity.
## When to Use React Query
**Use React Query for:**
- Real-time dashboards that need background refresh
- Infinite scroll and pagination
- Data that multiple components share
- Optimistic updates for instant feedback
- Client-side filtering and sorting with server data
**Use Server Components instead for:**
- Initial page loads
- SEO-critical content
- Data that doesn't need real-time updates
## Basic Query
Fetch data with `useQuery`. The query automatically caches results and handles loading/error states:
```tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function TasksList({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const { data: tasks, isLoading, error } = useQuery({
queryKey: ['tasks', accountId],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
});
if (isLoading) {
return <div>Loading tasks...</div>;
}
if (error) {
return <div>Failed to load tasks</div>;
}
return (
<ul>
{tasks?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
```
## Query Keys
Query keys identify cached data. Structure them hierarchically for easy invalidation:
```tsx
// Specific task
queryKey: ['tasks', taskId]
// All tasks for an account
queryKey: ['tasks', { accountId }]
// All tasks for an account with filters
queryKey: ['tasks', { accountId, status: 'pending', page: 1 }]
// Invalidate all task queries
queryClient.invalidateQueries({ queryKey: ['tasks'] });
// Invalidate tasks for specific account
queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] });
```
### Query Key Factory
For larger apps, create a query key factory:
```tsx
// lib/query-keys.ts
export const queryKeys = {
tasks: {
all: ['tasks'] as const,
list: (accountId: string) => ['tasks', { accountId }] as const,
detail: (taskId: string) => ['tasks', taskId] as const,
filtered: (accountId: string, filters: TaskFilters) =>
['tasks', { accountId, ...filters }] as const,
},
members: {
all: ['members'] as const,
list: (accountId: string) => ['members', { accountId }] as const,
},
};
// Usage
const { data } = useQuery({
queryKey: queryKeys.tasks.list(accountId),
queryFn: () => fetchTasks(accountId),
});
// Invalidate all tasks
queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all });
```
## Mutations
Use `useMutation` for create, update, and delete operations:
```tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function CreateTaskForm({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newTask: { title: string }) => {
const { data, error } = await supabase
.from('tasks')
.insert({
title: newTask.title,
account_id: accountId,
})
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: () => {
// Invalidate and refetch tasks list
queryClient.invalidateQueries({ queryKey: ['tasks', { accountId }] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({ title: formData.get('title') as string });
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Task title" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Task'}
</button>
{mutation.error && (
<p className="text-destructive">Failed to create task</p>
)}
</form>
);
}
```
## Optimistic Updates
Update the UI immediately before the server responds for a snappier feel:
```tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function useUpdateTask(accountId: string) {
const supabase = useSupabase();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (task: { id: string; completed: boolean }) => {
const { data, error } = await supabase
.from('tasks')
.update({ completed: task.completed })
.eq('id', task.id)
.select()
.single();
if (error) throw error;
return data;
},
// Optimistically update the cache
onMutate: async (updatedTask) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: ['tasks', { accountId }],
});
// Snapshot previous value
const previousTasks = queryClient.getQueryData<Task[]>([
'tasks',
{ accountId },
]);
// Optimistically update
queryClient.setQueryData<Task[]>(
['tasks', { accountId }],
(old) =>
old?.map((task) =>
task.id === updatedTask.id
? { ...task, completed: updatedTask.completed }
: task
)
);
// Return context with snapshot
return { previousTasks };
},
// Rollback on error
onError: (err, updatedTask, context) => {
queryClient.setQueryData(
['tasks', { accountId }],
context?.previousTasks
);
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['tasks', { accountId }],
});
},
});
}
```
Usage:
```tsx
function TaskItem({ task, accountId }: { task: Task; accountId: string }) {
const updateTask = useUpdateTask(accountId);
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={task.completed}
onChange={(e) =>
updateTask.mutate({ id: task.id, completed: e.target.checked })
}
/>
<span className={task.completed ? 'line-through' : ''}>
{task.title}
</span>
</label>
);
}
```
## Combining with Server Components
Load initial data in Server Components, then hydrate React Query for client-side updates:
```tsx
// app/tasks/page.tsx (Server Component)
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { TasksManager } from './tasks-manager';
export default async function TasksPage({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account } = await params;
const supabase = getSupabaseServerClient();
const { data: tasks } = await supabase
.from('tasks')
.select('*')
.eq('account_slug', account)
.order('created_at', { ascending: false });
return (
<TasksManager
accountSlug={account}
initialTasks={tasks ?? []}
/>
);
}
```
```tsx
// tasks-manager.tsx (Client Component)
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
interface Props {
accountSlug: string;
initialTasks: Task[];
}
export function TasksManager({ accountSlug, initialTasks }: Props) {
const supabase = useSupabase();
const { data: tasks } = useQuery({
queryKey: ['tasks', { accountSlug }],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_slug', accountSlug)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
},
// Use server data as initial value
initialData: initialTasks,
// Consider fresh for 30 seconds (skip immediate refetch)
staleTime: 30_000,
});
return (
<div>
{/* tasks is initialTasks on first render, then live data */}
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
```
## Caching Configuration
Control how long data stays fresh and when to refetch:
```tsx
const { data } = useQuery({
queryKey: ['tasks', accountId],
queryFn: fetchTasks,
// Data considered fresh for 5 minutes
staleTime: 5 * 60 * 1000,
// Keep unused data in cache for 30 minutes
gcTime: 30 * 60 * 1000,
// Refetch when window regains focus
refetchOnWindowFocus: true,
// Refetch every 60 seconds
refetchInterval: 60_000,
// Only refetch interval when tab is visible
refetchIntervalInBackground: false,
});
```
### Global Defaults
Set defaults for all queries in your QueryClient:
```tsx
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: true,
retry: 1,
},
mutations: {
retry: 0,
},
},
});
```
## Pagination
Implement paginated queries:
```tsx
'use client';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useState } from 'react';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
const PAGE_SIZE = 10;
export function PaginatedTasks({ accountId }: { accountId: string }) {
const [page, setPage] = useState(0);
const supabase = useSupabase();
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['tasks', { accountId, page }],
queryFn: async () => {
const from = page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { data, error, count } = await supabase
.from('tasks')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
return { tasks: data, total: count ?? 0 };
},
// Keep previous data while fetching next page
placeholderData: keepPreviousData,
});
const totalPages = Math.ceil((data?.total ?? 0) / PAGE_SIZE);
return (
<div>
<ul className={isPlaceholderData ? 'opacity-50' : ''}>
{data?.tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
<div className="flex gap-2 mt-4">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</button>
<span>
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages - 1}
>
Next
</button>
</div>
</div>
);
}
```
## Infinite Scroll
For infinite scrolling lists:
```tsx
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
const PAGE_SIZE = 20;
export function InfiniteTasksList({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['tasks', { accountId, infinite: true }],
queryFn: async ({ pageParam }) => {
const from = pageParam * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
return data;
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// Return undefined when no more pages
return lastPage.length === PAGE_SIZE ? allPages.length : undefined;
},
});
const tasks = data?.pages.flatMap((page) => page) ?? [];
return (
<div>
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
```
## Real-Time with Supabase Subscriptions
Combine React Query with Supabase real-time for live updates:
```tsx
'use client';
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function LiveTasks({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const queryClient = useQueryClient();
const { data: tasks } = useQuery({
queryKey: ['tasks', { accountId }],
queryFn: async () => {
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('account_id', accountId);
if (error) throw error;
return data;
},
});
// Subscribe to real-time changes
useEffect(() => {
const channel = supabase
.channel('tasks-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'tasks',
filter: `account_id=eq.${accountId}`,
},
() => {
// Invalidate and refetch on any change
queryClient.invalidateQueries({
queryKey: ['tasks', { accountId }],
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase, queryClient, accountId]);
return (
<ul>
{tasks?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
```
## Using Server Actions with React Query
Combine Server Actions with React Query mutations:
```tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTask } from './actions'; // Server Action
export function useCreateTask(accountId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTask, // Server Action as mutation function
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['tasks', { accountId }],
});
},
});
}
// Usage
function CreateTaskForm({ accountId }: { accountId: string }) {
const createTask = useCreateTask(accountId);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createTask.mutate({
title: formData.get('title') as string,
accountId,
});
}}
>
<input name="title" required />
<button disabled={createTask.isPending}>
{createTask.isPending ? 'Creating...' : 'Create'}
</button>
</form>
);
}
```
## Common Mistakes
### Forgetting 'use client'
```tsx
// WRONG: React Query hooks require client components
export function Tasks() {
const { data } = useQuery({ ... }); // Error: hooks can't run on server
}
// RIGHT: Mark as client component
'use client';
export function Tasks() {
const { data } = useQuery({ ... });
}
```
### Unstable Query Keys
```tsx
// WRONG: New object reference on every render causes infinite refetches
const { data } = useQuery({
queryKey: ['tasks', { accountId, filters: { status: 'pending' } }],
queryFn: fetchTasks,
});
// RIGHT: Use stable references
const filters = useMemo(() => ({ status: 'pending' }), []);
const { data } = useQuery({
queryKey: ['tasks', { accountId, ...filters }],
queryFn: fetchTasks,
});
// OR: Spread primitive values directly
const { data } = useQuery({
queryKey: ['tasks', accountId, 'pending'],
queryFn: fetchTasks,
});
```
### Not Handling Loading States
```tsx
// WRONG: Assuming data exists
function Tasks() {
const { data } = useQuery({ ... });
return <ul>{data.map(...)}</ul>; // data might be undefined
}
// RIGHT: Handle all states
function Tasks() {
const { data, isLoading, error } = useQuery({ ... });
if (isLoading) return <Skeleton />;
if (error) return <Error />;
if (!data?.length) return <Empty />;
return <ul>{data.map(...)}</ul>;
}
```
## Next Steps
- [Server Components](server-components) - Initial data loading
- [Server Actions](server-actions) - Mutations with Server Actions
- [Supabase Clients](supabase-clients) - Browser vs server clients

View File

@@ -0,0 +1,567 @@
---
status: "published"
title: "API Route Handlers in Next.js"
label: "Route Handlers"
description: "Build API endpoints with Next.js Route Handlers. Covers the enhanceRouteHandler utility, webhook handling, CSRF protection, and when to use Route Handlers vs Server Actions."
order: 2
---
[Route Handlers](/blog/tutorials/server-actions-vs-route-handlers) create HTTP API endpoints in Next.js by exporting functions named GET, POST, PUT, or DELETE from a `route.ts` file.
While Server Actions handle most mutations, Route Handlers are essential for webhooks (Stripe, Lemon Squeezy), external API access, streaming responses, and scenarios needing custom HTTP headers or status codes.
MakerKit's `enhanceRouteHandler` adds authentication and validation. Tested with Next.js 16 (async headers/params).
{% callout title="When to use Route Handlers" %}
**Use Route Handlers** for webhooks, external services calling your API, streaming responses, and public APIs. **Use Server Actions** for mutations from your own app (forms, button clicks).
{% /callout %}
## When to Use Route Handlers
**Use Route Handlers for:**
- Webhook endpoints (Stripe, Lemon Squeezy, GitHub, etc.)
- External services calling your API
- Public APIs for third-party consumption
- Streaming responses or Server-Sent Events
- Custom headers, status codes, or response formats
**Use Server Actions instead for:**
- Form submissions from your own app
- Mutations triggered by user interactions
- Any operation that doesn't need HTTP details
## Basic Route Handler
Create a `route.ts` file in any route segment:
```tsx
// app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
}
```
This creates an endpoint at `/api/health` that responds to GET requests.
### HTTP Methods
Export functions named after HTTP methods:
```tsx
// app/api/tasks/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
// Handle GET /api/tasks
}
export async function POST(request: Request) {
// Handle POST /api/tasks
}
export async function PUT(request: Request) {
// Handle PUT /api/tasks
}
export async function DELETE(request: Request) {
// Handle DELETE /api/tasks
}
```
## Using enhanceRouteHandler
The `enhanceRouteHandler` utility adds authentication, validation, and captcha verification:
```tsx
import { NextResponse } from 'next/server';
import * as z from 'zod';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const POST = enhanceRouteHandler(
async ({ body, user, request }) => {
// body is validated against the schema
// user is the authenticated user
// request is the original NextRequest
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('tasks')
.insert({
title: body.title,
account_id: body.accountId,
created_by: user.id,
})
.select()
.single();
if (error) {
return NextResponse.json(
{ error: 'Failed to create task' },
{ status: 500 }
);
}
return NextResponse.json({ task: data }, { status: 201 });
},
{
schema: CreateTaskSchema,
auth: true, // Require authentication (default)
}
);
```
### Configuration Options
```tsx
enhanceRouteHandler(handler, {
// Zod schema for request body validation
schema: MySchema,
// Require authentication (default: true)
auth: true,
// Require captcha verification (default: false)
captcha: false,
});
```
### Public Endpoints
For public endpoints (no authentication required):
```tsx
export const GET = enhanceRouteHandler(
async ({ request }) => {
// user will be undefined
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('public_content')
.select('*')
.limit(10);
return NextResponse.json({ content: data });
},
{ auth: false }
);
```
## Dynamic Route Parameters
Access route parameters in Route Handlers:
```tsx
// app/api/tasks/[id]/route.ts
import { NextResponse } from 'next/server';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export const GET = enhanceRouteHandler(
async ({ user, params }) => {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('id', params.id)
.single();
if (error || !data) {
return NextResponse.json(
{ error: 'Task not found' },
{ status: 404 }
);
}
return NextResponse.json({ task: data });
},
{ auth: true }
);
export const DELETE = enhanceRouteHandler(
async ({ user, params }) => {
const supabase = getSupabaseServerClient();
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', params.id)
.eq('created_by', user.id);
if (error) {
return NextResponse.json(
{ error: 'Failed to delete task' },
{ status: 500 }
);
}
return new Response(null, { status: 204 });
},
{ auth: true }
);
```
## Webhook Handling
Webhooks require special handling since they come from external services without user authentication.
### Stripe Webhook Example
```tsx
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
// Use admin client since webhooks don't have user context
const supabase = getSupabaseServerAdminClient();
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await supabase
.from('subscriptions')
.update({ status: 'active' })
.eq('stripe_customer_id', session.customer);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await supabase
.from('subscriptions')
.update({ status: 'cancelled' })
.eq('stripe_subscription_id', subscription.id);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
```
### Generic Webhook Pattern
```tsx
// app/api/webhooks/[provider]/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
type WebhookHandler = {
verifySignature: (body: string, signature: string) => boolean;
handleEvent: (event: unknown) => Promise<void>;
};
const handlers: Record<string, WebhookHandler> = {
stripe: {
verifySignature: (body, sig) => { /* ... */ },
handleEvent: async (event) => { /* ... */ },
},
github: {
verifySignature: (body, sig) => { /* ... */ },
handleEvent: async (event) => { /* ... */ },
},
};
export async function POST(
request: Request,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
const handler = handlers[provider];
if (!handler) {
return NextResponse.json(
{ error: 'Unknown provider' },
{ status: 404 }
);
}
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('x-signature') ?? '';
if (!handler.verifySignature(body, signature)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
try {
const event = JSON.parse(body);
await handler.handleEvent(event);
return NextResponse.json({ received: true });
} catch (error) {
console.error(`Webhook error (${provider}):`, error);
return NextResponse.json(
{ error: 'Processing failed' },
{ status: 500 }
);
}
}
```
## CSRF Protection
CSRF protection is handled natively by Next.js Server Actions. No manual CSRF token management is needed.
Routes under `/api/*` are intended for external access (webhooks, third-party integrations) and do not have CSRF protection. Use authentication checks via `enhanceRouteHandler` with `auth: true` if needed.
## Streaming Responses
Route Handlers support streaming for real-time data:
```tsx
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const data = JSON.stringify({ count: i, timestamp: Date.now() });
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
await new Promise((resolve) => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
```
## File Uploads
Handle file uploads with Route Handlers:
```tsx
// app/api/upload/route.ts
import { NextResponse } from 'next/server';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export const POST = enhanceRouteHandler(
async ({ request, user }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type' },
{ status: 400 }
);
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json(
{ error: 'File too large' },
{ status: 400 }
);
}
const supabase = getSupabaseServerClient();
const fileName = `${user.id}/${Date.now()}-${file.name}`;
const { error } = await supabase.storage
.from('uploads')
.upload(fileName, file);
if (error) {
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
);
}
const { data: urlData } = supabase.storage
.from('uploads')
.getPublicUrl(fileName);
return NextResponse.json({
url: urlData.publicUrl,
});
},
{ auth: true }
);
```
## Error Handling
### Consistent Error Responses
Create a helper for consistent error responses:
```tsx
// lib/api-errors.ts
import { NextResponse } from 'next/server';
export function apiError(
message: string,
status: number = 500,
details?: Record<string, unknown>
) {
return NextResponse.json(
{
error: message,
...details,
},
{ status }
);
}
export function notFound(resource: string = 'Resource') {
return apiError(`${resource} not found`, 404);
}
export function unauthorized(message: string = 'Unauthorized') {
return apiError(message, 401);
}
export function badRequest(message: string, field?: string) {
return apiError(message, 400, field ? { field } : undefined);
}
```
Usage:
```tsx
import { notFound, badRequest } from '@/lib/api-errors';
export const GET = enhanceRouteHandler(
async ({ params }) => {
const task = await getTask(params.id);
if (!task) {
return notFound('Task');
}
return NextResponse.json({ task });
},
{ auth: true }
);
```
## Route Handler vs Server Action
| Scenario | Use |
|----------|-----|
| Form submission from your app | Server Action |
| Button click triggers mutation | Server Action |
| Webhook from Stripe/GitHub | Route Handler |
| External service needs your API | Route Handler |
| Need custom status codes | Route Handler |
| Need streaming response | Route Handler |
| Need to set specific headers | Route Handler |
## Common Mistakes
### Forgetting to Verify Webhook Signatures
```tsx
// WRONG: Trusting webhook data without verification
export async function POST(request: Request) {
const event = await request.json();
await processEvent(event); // Anyone can call this!
}
// RIGHT: Verify signature before processing
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('x-signature');
if (!verifySignature(body, signature)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
await processEvent(event);
}
```
### Using Wrong Client in Webhooks
```tsx
// WRONG: Regular client in webhook (no user session)
export async function POST(request: Request) {
const supabase = getSupabaseServerClient();
// This will fail - no user session for RLS
await supabase.from('subscriptions').update({ ... });
}
// RIGHT: Admin client for webhook operations
export async function POST(request: Request) {
// Verify signature first!
const supabase = getSupabaseServerAdminClient();
await supabase.from('subscriptions').update({ ... });
}
```
## Next Steps
- [Server Actions](server-actions) - For mutations from your app
- [CSRF Protection](csrf-protection) - Secure your endpoints
- [Captcha Protection](captcha-protection) - Bot protection

View File

@@ -0,0 +1,816 @@
---
status: "published"
title: "Server Actions for Data Mutations"
label: "Server Actions"
description: "Use Server Actions to handle form submissions and data mutations in MakerKit. Covers authActionClient, validation, authentication, revalidation, and captcha protection."
order: 1
---
Server Actions are async functions marked with `'use server'` that run on the server but can be called directly from client components. They handle form submissions, data mutations, and any operation that modifies your database. MakerKit's `authActionClient` adds authentication and Zod validation with zero boilerplate, while `publicActionClient` and `captchaActionClient` handle public and captcha-protected actions respectively. Tested with Next.js 16 and React 19.
{% callout title="When to use Server Actions" %}
**Use Server Actions** for any mutation: form submissions, button clicks that create/update/delete data, and operations needing server-side validation. Use Route Handlers only for webhooks and external API access.
{% /callout %}
## Basic Server Action
A Server Action is any async function in a file marked with `'use server'`:
```tsx
'use server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function createTask(formData: FormData) {
const supabase = getSupabaseServerClient();
const title = formData.get('title') as string;
const { error } = await supabase.from('tasks').insert({ title });
if (error) {
return { success: false, error: error.message };
}
return { success: true };
}
```
This works, but lacks validation, authentication, and proper error handling. The action clients solve these problems.
## Using authActionClient
The `authActionClient` creates type-safe, validated server actions with built-in authentication:
1. **Authentication** - Verifies the user is logged in
2. **Validation** - Validates input against a Zod schema
3. **Type Safety** - Full end-to-end type inference
```tsx
'use server';
import * as z from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().optional(),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// data is typed and validated
// user is the authenticated user
const supabase = getSupabaseServerClient();
const { error } = await supabase.from('tasks').insert({
title: data.title,
description: data.description,
account_id: data.accountId,
created_by: user.id,
});
if (error) {
throw new Error('Failed to create task');
}
return { success: true };
});
```
### Available Action Clients
| Client | Import | Use Case |
|--------|--------|----------|
| `authActionClient` | `@kit/next/safe-action` | Requires authenticated user (most common) |
| `publicActionClient` | `@kit/next/safe-action` | No auth required (contact forms, etc.) |
| `captchaActionClient` | `@kit/next/safe-action` | Requires CAPTCHA + auth |
### Public Actions
For public actions (like contact forms), use `publicActionClient`:
```tsx
'use server';
import * as z from 'zod';
import { publicActionClient } from '@kit/next/safe-action';
const ContactFormSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
});
export const submitContactForm = publicActionClient
.inputSchema(ContactFormSchema)
.action(async ({ parsedInput: data }) => {
// No user context - this is a public action
await sendEmail(data);
return { success: true };
});
```
## Calling Server Actions from Components
### With useAction (Recommended)
The `useAction` hook from `next-safe-action/hooks` is the primary way to call server actions from client components:
```tsx
'use client';
import { useAction } from 'next-safe-action/hooks';
import { createTask } from './actions';
export function CreateTaskForm({ accountId }: { accountId: string }) {
const { execute, isPending } = useAction(createTask, {
onSuccess: ({ data }) => {
// Handle success
},
onError: ({ error }) => {
// Handle error
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
title: formData.get('title') as string,
accountId,
});
};
return (
<form onSubmit={handleSubmit}>
<input type="text" name="title" placeholder="Task title" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Task'}
</button>
</form>
);
}
```
### With useActionState (React 19)
`useActionState` works with plain Server Actions (not `next-safe-action` wrapped actions). Define a plain action for this pattern:
```tsx
// actions.ts
'use server';
export async function createTaskFormAction(prevState: unknown, formData: FormData) {
const title = formData.get('title') as string;
const accountId = formData.get('accountId') as string;
// validate and create task...
return { success: true };
}
```
```tsx
'use client';
import { useActionState } from 'react';
import { createTaskFormAction } from './actions';
export function CreateTaskForm({ accountId }: { accountId: string }) {
const [state, formAction, isPending] = useActionState(createTaskFormAction, null);
return (
<form action={formAction}>
<input type="hidden" name="accountId" value={accountId} />
<input type="text" name="title" placeholder="Task title" />
{state?.error && (
<p className="text-destructive">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Task'}
</button>
</form>
);
}
```
{% alert type="info" %}
`useActionState` expects a plain server action with signature `(prevState, formData) => newState`. For `next-safe-action` wrapped actions, use the `useAction` hook from `next-safe-action/hooks` instead.
{% /alert %}
### Direct Function Calls
Call Server Actions directly for more complex scenarios:
```tsx
'use client';
import { useState, useTransition } from 'react';
import { createTask } from './actions';
export function CreateTaskButton({ accountId }: { accountId: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleClick = () => {
startTransition(async () => {
try {
const result = await createTask({
title: 'New Task',
accountId,
});
if (!result?.data?.success) {
setError('Failed to create task');
}
} catch (e) {
setError('An unexpected error occurred');
}
});
};
return (
<>
<button onClick={handleClick} disabled={isPending}>
{isPending ? 'Creating...' : 'Quick Add Task'}
</button>
{error && <p className="text-destructive">{error}</p>}
</>
);
}
```
## Revalidating Data
After mutations, revalidate cached data so the UI reflects changes:
### Revalidate by Path
```tsx
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
await supabase.from('tasks').insert({ /* ... */ });
// Revalidate the tasks page
revalidatePath('/tasks');
// Or revalidate with layout
revalidatePath('/tasks', 'layout');
return { success: true };
});
```
### Revalidate by Tag
For more granular control, use cache tags:
```tsx
'use server';
import * as z from 'zod';
import { revalidateTag } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const UpdateTaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
});
export const updateTask = authActionClient
.inputSchema(UpdateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
await supabase.from('tasks').update(data).eq('id', data.id);
// Revalidate all queries tagged with 'tasks'
revalidateTag('tasks');
// Or revalidate specific task
revalidateTag(`task-${data.id}`);
return { success: true };
});
```
### Redirecting After Mutation
```tsx
'use server';
import * as z from 'zod';
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
const { data: task } = await supabase
.from('tasks')
.insert({ /* ... */ })
.select('id')
.single();
// Redirect to the new task
redirect(`/tasks/${task.id}`);
});
```
## Error Handling
### Returning Errors
Return structured errors for the client to handle:
```tsx
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1),
accountId: z.string().uuid(),
});
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
// Check for duplicate title
const { data: existing } = await supabase
.from('tasks')
.select('id')
.eq('title', data.title)
.eq('account_id', data.accountId)
.single();
if (existing) {
return {
success: false,
error: 'A task with this title already exists',
};
}
const { error } = await supabase.from('tasks').insert({ /* ... */ });
if (error) {
// Log for debugging, return user-friendly message
console.error('Failed to create task:', error);
return {
success: false,
error: 'Failed to create task. Please try again.',
};
}
revalidatePath('/tasks');
return { success: true };
});
```
### Throwing Errors
For unexpected errors, throw to trigger error boundaries:
```tsx
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const DeleteTaskSchema = z.object({
taskId: z.string().uuid(),
});
export const deleteTask = authActionClient
.inputSchema(DeleteTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', data.taskId)
.eq('created_by', user.id); // Ensure ownership
if (error) {
// This will be caught by error boundaries
// and reported to your monitoring provider
throw new Error('Failed to delete task');
}
revalidatePath('/tasks');
return { success: true };
});
```
## Captcha Protection
For sensitive actions, add Cloudflare Turnstile captcha verification:
### Server Action Setup
```tsx
'use server';
import * as z from 'zod';
import { captchaActionClient } from '@kit/next/safe-action';
const TransferFundsSchema = z.object({
amount: z.number().positive(),
toAccountId: z.string().uuid(),
captchaToken: z.string(),
});
export const transferFunds = captchaActionClient
.inputSchema(TransferFundsSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// Captcha is verified before this runs
// ... transfer logic
});
```
### Client Component with Captcha
```tsx
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useCaptcha } from '@kit/auth/captcha/client';
import { transferFunds } from './actions';
export function TransferForm({ captchaSiteKey }: { captchaSiteKey: string }) {
const captcha = useCaptcha({ siteKey: captchaSiteKey });
const { execute, isPending } = useAction(transferFunds, {
onSuccess: () => {
// Handle success
},
onSettled: () => {
// Always reset captcha after submission
captcha.reset();
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
amount: Number(formData.get('amount')),
toAccountId: formData.get('toAccountId') as string,
captchaToken: captcha.token,
});
};
return (
<form onSubmit={handleSubmit}>
<input type="number" name="amount" placeholder="Amount" />
<input type="text" name="toAccountId" placeholder="Recipient" />
{/* Render captcha widget */}
{captcha.field}
<button type="submit" disabled={isPending}>
{isPending ? 'Transferring...' : 'Transfer'}
</button>
</form>
);
}
```
See [Captcha Protection](captcha-protection) for detailed setup instructions.
## Real-World Example: Team Settings
Here's a complete example of Server Actions for team management:
```tsx
// lib/server/team-actions.ts
'use server';
import * as z from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getLogger } from '@kit/shared/logger';
const UpdateTeamSchema = z.object({
teamId: z.string().uuid(),
name: z.string().min(2).max(50),
slug: z.string().min(2).max(30).regex(/^[a-z0-9-]+$/),
});
const InviteMemberSchema = z.object({
teamId: z.string().uuid(),
email: z.string().email(),
role: z.enum(['member', 'admin']),
});
const RemoveMemberSchema = z.object({
teamId: z.string().uuid(),
userId: z.string().uuid(),
});
export const updateTeam = authActionClient
.inputSchema(UpdateTeamSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const logger = await getLogger();
const supabase = getSupabaseServerClient();
logger.info({ teamId: data.teamId, userId: user.id }, 'Updating team');
// Check if slug is taken
const { data: existing } = await supabase
.from('accounts')
.select('id')
.eq('slug', data.slug)
.neq('id', data.teamId)
.single();
if (existing) {
return {
success: false,
error: 'This URL is already taken',
field: 'slug',
};
}
const { error } = await supabase
.from('accounts')
.update({ name: data.name, slug: data.slug })
.eq('id', data.teamId);
if (error) {
logger.error({ error, teamId: data.teamId }, 'Failed to update team');
return { success: false, error: 'Failed to update team' };
}
revalidatePath(`/home/${data.slug}/settings`);
return { success: true };
});
export const inviteMember = authActionClient
.inputSchema(InviteMemberSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
// Check if already a member
const { data: existing } = await supabase
.from('account_members')
.select('id')
.eq('account_id', data.teamId)
.eq('user_email', data.email)
.single();
if (existing) {
return { success: false, error: 'User is already a member' };
}
// Create invitation
const { error } = await supabase.from('invitations').insert({
account_id: data.teamId,
email: data.email,
role: data.role,
invited_by: user.id,
});
if (error) {
return { success: false, error: 'Failed to send invitation' };
}
revalidatePath(`/home/[account]/settings/members`, 'page');
return { success: true };
});
export const removeMember = authActionClient
.inputSchema(RemoveMemberSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
// Prevent removing yourself
if (data.userId === user.id) {
return { success: false, error: 'You cannot remove yourself' };
}
const { error } = await supabase
.from('account_members')
.delete()
.eq('account_id', data.teamId)
.eq('user_id', data.userId);
if (error) {
return { success: false, error: 'Failed to remove member' };
}
revalidatePath(`/home/[account]/settings/members`, 'page');
return { success: true };
});
```
## Common Mistakes
### Forgetting to Revalidate
```tsx
// WRONG: Data changes but UI doesn't update
export const updateTask = authActionClient
.inputSchema(UpdateTaskSchema)
.action(async ({ parsedInput: data }) => {
await supabase.from('tasks').update(data).eq('id', data.id);
return { success: true };
});
// RIGHT: Revalidate after mutation
export const updateTask = authActionClient
.inputSchema(UpdateTaskSchema)
.action(async ({ parsedInput: data }) => {
await supabase.from('tasks').update(data).eq('id', data.id);
revalidatePath('/tasks');
return { success: true };
});
```
### Using try/catch Incorrectly
```tsx
// WRONG: Swallowing errors silently
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data }) => {
try {
await supabase.from('tasks').insert(data);
} catch (e) {
// Error is lost, user sees "success"
}
return { success: true };
});
// RIGHT: Return or throw errors
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data }) => {
const { error } = await supabase.from('tasks').insert(data);
if (error) {
return { success: false, error: 'Failed to create task' };
}
return { success: true };
});
```
### Not Validating Ownership
```tsx
// WRONG: Any user can delete any task
export const deleteTask = authActionClient
.inputSchema(DeleteTaskSchema)
.action(async ({ parsedInput: data }) => {
await supabase.from('tasks').delete().eq('id', data.taskId);
});
// RIGHT: Verify ownership (or use RLS)
export const deleteTask = authActionClient
.inputSchema(DeleteTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', data.taskId)
.eq('created_by', user.id); // User can only delete their own tasks
if (error) {
return { success: false, error: 'Task not found or access denied' };
}
return { success: true };
});
```
## Using enhanceAction (Deprecated)
{% callout title="Deprecated" %}
`enhanceAction` is still available but deprecated. Use `authActionClient`, `publicActionClient`, or `captchaActionClient` for new code.
{% /callout %}
The `enhanceAction` utility from `@kit/next/actions` wraps a Server Action with authentication, Zod validation, and optional captcha verification:
```tsx
'use server';
import * as z from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
accountId: z.string().uuid(),
});
// Authenticated action (default)
export const createTask = enhanceAction(
async (data, user) => {
const supabase = getSupabaseServerClient();
await supabase.from('tasks').insert({
title: data.title,
account_id: data.accountId,
created_by: user.id,
});
return { success: true };
},
{ schema: CreateTaskSchema }
);
// Public action (no auth required)
export const submitContactForm = enhanceAction(
async (data) => {
await sendEmail(data);
return { success: true };
},
{ schema: ContactFormSchema, auth: false }
);
// With captcha verification
export const sensitiveAction = enhanceAction(
async (data, user) => {
// captcha verified before this runs
},
{ schema: MySchema, captcha: true }
);
```
### Configuration Options
```tsx
enhanceAction(handler, {
schema: MySchema, // Zod schema for input validation
auth: true, // Require authentication (default: true)
captcha: false, // Require captcha verification (default: false)
});
```
### Migrating to authActionClient
```tsx
// Before (enhanceAction)
export const myAction = enhanceAction(
async (data, user) => { /* ... */ },
{ schema: MySchema }
);
// After (authActionClient)
export const myAction = authActionClient
.inputSchema(MySchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
/* ... */
});
```
| enhanceAction option | v3 equivalent |
|---------------------|---------------|
| `{ auth: true }` (default) | `authActionClient` |
| `{ auth: false }` | `publicActionClient` |
| `{ captcha: true }` | `captchaActionClient` |
## Next Steps
- [Route Handlers](route-handlers) - For webhooks and external APIs
- [Captcha Protection](captcha-protection) - Protect sensitive actions
- [React Query](react-query) - Combine with optimistic updates

View File

@@ -0,0 +1,487 @@
---
status: "published"
title: "Data Fetching with Server Components"
label: "Server Components"
description: "Load data in Next.js Server Components with Supabase. Covers streaming, Suspense boundaries, parallel data loading, caching, and error handling patterns."
order: 3
---
Server Components fetch data on the server during rendering, streaming HTML directly to the browser without adding to your JavaScript bundle. They're the default for all data loading in MakerKit because they're secure (queries never reach the client), SEO-friendly (content renders for search engines), and fast (no client-side fetching waterfalls). Tested with Next.js 16 and React 19.
{% callout title="When to use Server Components" %}
**Use Server Components** (the default) for page loads, SEO content, and data that doesn't need real-time updates. Only switch to Client Components with React Query when you need optimistic updates, real-time subscriptions, or client-side filtering.
{% /callout %}
## Why Server Components for Data Fetching
Server Components provide significant advantages for data loading:
- **No client bundle impact** - Database queries don't increase JavaScript bundle size
- **Direct database access** - Query Supabase directly without API round-trips
- **Streaming** - Users see content progressively as data loads
- **SEO-friendly** - Content is rendered on the server for search engines
- **Secure by default** - Queries never reach the browser
## Basic Data Fetching
Every component in Next.js is a Server Component by default. Add the `async` keyword to fetch data directly:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function TasksPage() {
const supabase = getSupabaseServerClient();
const { data: tasks, error } = await supabase
.from('tasks')
.select('id, title, completed, created_at')
.order('created_at', { ascending: false });
if (error) {
throw new Error('Failed to load tasks');
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
```
## Streaming with Suspense
Suspense boundaries let you show loading states while data streams in. This prevents the entire page from waiting for slow queries.
### Page-Level Loading States
Create a `loading.tsx` file next to your page to show a loading UI while the page data loads:
```tsx
// app/tasks/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse bg-muted rounded" />
<div className="h-64 animate-pulse bg-muted rounded" />
</div>
);
}
```
### Component-Level Suspense
For granular control, wrap individual components in Suspense boundaries:
```tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
{/* Stats load first */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
{/* Tasks can load independently */}
<Suspense fallback={<TasksSkeleton />}>
<RecentTasks />
</Suspense>
{/* Activity loads last */}
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function DashboardStats() {
const supabase = getSupabaseServerClient();
const { data } = await supabase.rpc('get_dashboard_stats');
return <StatsDisplay stats={data} />;
}
async function RecentTasks() {
const supabase = getSupabaseServerClient();
const { data } = await supabase.from('tasks').select('*').limit(5);
return <TasksList tasks={data} />;
}
```
## Parallel Data Loading
Load multiple data sources simultaneously to minimize waterfall requests. Use `Promise.all` to fetch in parallel:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function AccountDashboard({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account } = await params;
const supabase = getSupabaseServerClient();
// All queries run in parallel
const [tasksResult, membersResult, statsResult] = await Promise.all([
supabase
.from('tasks')
.select('*')
.eq('account_slug', account)
.limit(10),
supabase
.from('account_members')
.select('*, user:users(name, avatar_url)')
.eq('account_slug', account),
supabase.rpc('get_account_stats', { account_slug: account }),
]);
return (
<Dashboard
tasks={tasksResult.data}
members={membersResult.data}
stats={statsResult.data}
/>
);
}
```
### Avoiding Waterfalls
A waterfall occurs when queries depend on each other sequentially:
```tsx
// BAD: Waterfall - each query waits for the previous
async function SlowDashboard() {
const supabase = getSupabaseServerClient();
const { data: account } = await supabase
.from('accounts')
.select('*')
.single();
// This waits for account to load first
const { data: tasks } = await supabase
.from('tasks')
.select('*')
.eq('account_id', account.id);
// This waits for tasks to load
const { data: members } = await supabase
.from('members')
.select('*')
.eq('account_id', account.id);
}
// GOOD: Parallel loading when data is independent
async function FastDashboard({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const [tasks, members] = await Promise.all([
supabase.from('tasks').select('*').eq('account_id', accountId),
supabase.from('members').select('*').eq('account_id', accountId),
]);
}
```
## Caching Strategies
### Request Deduplication
Next.js automatically deduplicates identical fetch requests within a single render. If multiple components need the same data, wrap your data fetching in React's `cache()`:
```tsx
import { cache } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// This query runs once per request, even if called multiple times
export const getAccount = cache(async (slug: string) => {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('accounts')
.select('*')
.eq('slug', slug)
.single();
if (error) throw error;
return data;
});
// Both components can call getAccount('acme') - only one query runs
async function AccountHeader({ slug }: { slug: string }) {
const account = await getAccount(slug);
return <h1>{account.name}</h1>;
}
async function AccountSidebar({ slug }: { slug: string }) {
const account = await getAccount(slug);
return <nav>{/* uses account.settings */}</nav>;
}
```
### Using `unstable_cache` for Persistent Caching
For data that doesn't change often, use Next.js's `unstable_cache` to cache across requests:
```tsx
import { unstable_cache } from 'next/cache';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const getCachedPricingPlans = unstable_cache(
async () => {
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('pricing_plans')
.select('*')
.eq('active', true);
return data;
},
['pricing-plans'], // Cache key
{
revalidate: 3600, // Revalidate every hour
tags: ['pricing'], // Tag for manual revalidation
}
);
export default async function PricingPage() {
const plans = await getCachedPricingPlans();
return <PricingTable plans={plans} />;
}
```
To invalidate the cache after updates:
```tsx
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePricingPlan(data: PlanUpdate) {
const supabase = getSupabaseServerClient();
await supabase.from('pricing_plans').update(data).eq('id', data.id);
// Invalidate the pricing cache
revalidateTag('pricing');
}
```
## Error Handling
### Error Boundaries
Create an `error.tsx` file to catch errors in your route segment:
```tsx
// app/tasks/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-4 border border-destructive rounded-lg">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-muted-foreground">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded"
>
Try again
</button>
</div>
);
}
```
### Graceful Degradation
For non-critical data, handle errors gracefully instead of throwing:
```tsx
async function OptionalWidget() {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('widgets')
.select('*')
.limit(5);
// Don't crash the page if this fails
if (error || !data?.length) {
return null; // or return a fallback UI
}
return <WidgetList widgets={data} />;
}
```
## Real-World Example: Team Dashboard
Here's a complete example combining multiple patterns:
```tsx
// app/home/[account]/page.tsx
import { Suspense } from 'react';
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// Cached account loader - reusable across components
const getAccount = cache(async (slug: string) => {
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('accounts')
.select('*')
.eq('slug', slug)
.single();
return data;
});
export default async function TeamDashboard({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account: slug } = await params;
const account = await getAccount(slug);
if (!account) {
notFound();
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{account.name}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Stats stream in first */}
<Suspense fallback={<StatsSkeleton />}>
<AccountStats accountId={account.id} />
</Suspense>
{/* Tasks load independently */}
<Suspense fallback={<TasksSkeleton />}>
<RecentTasks accountId={account.id} />
</Suspense>
</div>
{/* Activity feed can load last */}
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed accountId={account.id} />
</Suspense>
</div>
);
}
async function AccountStats({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const { data } = await supabase.rpc('get_account_stats', {
p_account_id: accountId,
});
return (
<div className="grid grid-cols-3 gap-4">
<StatCard title="Tasks" value={data.total_tasks} />
<StatCard title="Completed" value={data.completed_tasks} />
<StatCard title="Members" value={data.member_count} />
</div>
);
}
async function RecentTasks({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const { data: tasks } = await supabase
.from('tasks')
.select('id, title, completed, assignee:users(name)')
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.limit(5);
return (
<div className="border rounded-lg p-4">
<h2 className="font-semibold mb-4">Recent Tasks</h2>
<ul className="space-y-2">
{tasks?.map((task) => (
<li key={task.id} className="flex items-center gap-2">
<span className={task.completed ? 'line-through' : ''}>
{task.title}
</span>
{task.assignee && (
<span className="text-sm text-muted-foreground">
- {task.assignee.name}
</span>
)}
</li>
))}
</ul>
</div>
);
}
async function ActivityFeed({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const { data: activities } = await supabase
.from('activity_log')
.select('*, user:users(name)')
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.limit(10);
return (
<div className="border rounded-lg p-4">
<h2 className="font-semibold mb-4">Recent Activity</h2>
<ul className="space-y-2">
{activities?.map((activity) => (
<li key={activity.id} className="text-sm">
<span className="font-medium">{activity.user.name}</span>{' '}
{activity.action}
</li>
))}
</ul>
</div>
);
}
```
## When to Use Client Components Instead
Server Components are great for initial page loads, but some scenarios need client components:
- **Real-time updates** - Use React Query with Supabase subscriptions
- **User interactions** - Sorting, filtering, pagination with instant feedback
- **Forms** - Complex forms with validation and state management
- **Optimistic updates** - Update UI before server confirms
For these cases, load initial data in Server Components and pass to client components:
```tsx
// Server Component - loads initial data
export default async function TasksPage() {
const tasks = await loadTasks();
return <TasksManager initialTasks={tasks} />;
}
// Client Component - handles interactivity
'use client';
function TasksManager({ initialTasks }) {
const [tasks, setTasks] = useState(initialTasks);
// ... sorting, filtering, real-time updates
}
```
## Next Steps
- [Server Actions](server-actions) - Mutate data from Server Components
- [React Query](react-query) - Client-side data management
- [Route Handlers](route-handlers) - Build API endpoints

View File

@@ -0,0 +1,354 @@
---
status: "published"
title: "Supabase Clients in Next.js"
label: "Supabase Clients"
description: "How to use Supabase clients in browser and server environments. Includes the standard client, server client, and admin client for bypassing RLS."
order: 0
---
MakerKit provides three Supabase clients for different environments: `useSupabase()` for Client Components, `getSupabaseServerClient()` for Server Components and Server Actions, and `getSupabaseServerAdminClient()` for admin operations that bypass Row Level Security. Use the right client for your context to ensure security and proper RLS enforcement. As of Next.js 16 and React 19, these patterns are tested and recommended.
{% callout title="Which client should I use?" %}
**In Client Components**: Use `useSupabase()` hook. **In Server Components or Server Actions**: Use `getSupabaseServerClient()`. **For webhooks or admin tasks**: Use `getSupabaseServerAdminClient()` (bypasses RLS).
{% /callout %}
## Client Overview
| Client | Environment | RLS | Use Case |
|--------|-------------|-----|----------|
| `useSupabase()` | Browser (React) | Yes | Client components, real-time subscriptions |
| `getSupabaseServerClient()` | Server | Yes | Server Components, Server Actions, Route Handlers |
| `getSupabaseServerAdminClient()` | Server | **Bypassed** | Admin operations, migrations, webhooks |
## Browser Client
Use the `useSupabase` hook in client components. This client runs in the browser and respects all RLS policies.
```tsx
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function TasksList() {
const supabase = useSupabase();
const handleComplete = async (taskId: string) => {
const { error } = await supabase
.from('tasks')
.update({ completed: true })
.eq('id', taskId);
if (error) {
console.error('Failed to complete task:', error.message);
}
};
return (
<button onClick={() => handleComplete('task-123')}>
Complete Task
</button>
);
}
```
### Real-time Subscriptions
The browser client supports real-time subscriptions for live updates:
```tsx
'use client';
import { useEffect, useState } from 'react';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function LiveTasksList({ accountId }: { accountId: string }) {
const supabase = useSupabase();
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
// Subscribe to changes
const channel = supabase
.channel('tasks-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'tasks',
filter: `account_id=eq.${accountId}`,
},
(payload) => {
if (payload.eventType === 'INSERT') {
setTasks((prev) => [...prev, payload.new as Task]);
}
if (payload.eventType === 'UPDATE') {
setTasks((prev) =>
prev.map((t) => (t.id === payload.new.id ? payload.new as Task : t))
);
}
if (payload.eventType === 'DELETE') {
setTasks((prev) => prev.filter((t) => t.id !== payload.old.id));
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase, accountId]);
return <ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>;
}
```
## Server Client
Use `getSupabaseServerClient()` in all server environments: Server Components, Server Actions, and Route Handlers. This is a unified client that works across all server contexts.
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// Server Component
export default async function TasksPage() {
const supabase = getSupabaseServerClient();
const { data: tasks, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false });
if (error) {
throw new Error('Failed to load tasks');
}
return <TasksList tasks={tasks} />;
}
```
### Server Actions
The same client works in Server Actions:
```tsx
'use server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { authActionClient } from '@kit/next/safe-action';
export const createTask = authActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const supabase = getSupabaseServerClient();
const { error } = await supabase.from('tasks').insert({
title: data.title,
account_id: data.accountId,
created_by: user.id,
});
if (error) {
throw new Error('Failed to create task');
}
return { success: true };
});
```
### Route Handlers
And in Route Handlers:
```tsx
import { NextResponse } from 'next/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { enhanceRouteHandler } from '@kit/next/routes';
export const GET = enhanceRouteHandler(
async ({ user }) => {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('created_by', user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ tasks: data });
},
{ auth: true }
);
```
## Admin Client (Use with Caution)
The admin client bypasses Row Level Security entirely. It uses the service role key and should only be used for:
- Webhook handlers that need to write data without user context
- Admin operations in protected admin routes
- Database migrations or seed scripts
- Background jobs running outside user sessions
```tsx
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
// Example: Webhook handler that needs to update user data
export async function POST(request: Request) {
const payload = await request.json();
// Verify webhook signature first!
if (!verifyWebhookSignature(request)) {
return new Response('Unauthorized', { status: 401 });
}
// Admin client bypasses RLS - use only when necessary
const supabase = getSupabaseServerAdminClient();
const { error } = await supabase
.from('subscriptions')
.update({ status: payload.status })
.eq('stripe_customer_id', payload.customer);
if (error) {
return new Response('Failed to update', { status: 500 });
}
return new Response('OK', { status: 200 });
}
```
### Security Warning
The admin client has unrestricted database access. Before using it:
1. **Verify the request** - Always validate webhook signatures or admin tokens
2. **Validate all input** - Never trust incoming data without validation
3. **Audit access** - Log admin operations for security audits
4. **Minimize scope** - Only query/update what's necessary
```tsx
// WRONG: Using admin client without verification
export async function dangerousEndpoint(request: Request) {
const supabase = getSupabaseServerAdminClient();
const { userId } = await request.json();
// This deletes ANY user - extremely dangerous!
await supabase.from('users').delete().eq('id', userId);
}
// RIGHT: Verify authorization before admin operations
export async function safeEndpoint(request: Request) {
// 1. Verify the request comes from a trusted source
if (!verifyAdminToken(request)) {
return new Response('Unauthorized', { status: 401 });
}
// 2. Validate input
const parsed = AdminActionSchema.safeParse(await request.json());
if (!parsed.success) {
return new Response('Invalid input', { status: 400 });
}
// 3. Now safe to use admin client
const supabase = getSupabaseServerAdminClient();
// ... perform operation
}
```
## TypeScript Integration
All clients are fully typed with your database schema. Generate types from your Supabase project:
```bash
pnpm supabase gen types typescript --project-id your-project-id > packages/supabase/src/database.types.ts
```
Then your queries get full autocomplete and type checking:
```tsx
const supabase = getSupabaseServerClient();
// TypeScript knows the shape of 'tasks' table
const { data } = await supabase
.from('tasks') // autocomplete table names
.select('id, title, completed, created_at') // autocomplete columns
.eq('completed', false); // type-safe filter values
// data is typed as Pick<Task, 'id' | 'title' | 'completed' | 'created_at'>[]
```
## Common Mistakes
### Using Browser Client on Server
```tsx
// WRONG: useSupabase is a React hook, can't use in Server Components
export default async function Page() {
const supabase = useSupabase(); // This will error
}
// RIGHT: Use server client
export default async function Page() {
const supabase = getSupabaseServerClient();
}
```
### Using Admin Client When Not Needed
```tsx
// WRONG: Using admin client for regular user operations
export const getUserTasks = authActionClient
.action(async ({ ctx: { user } }) => {
const supabase = getSupabaseServerAdminClient(); // Unnecessary, bypasses RLS
return supabase.from('tasks').select('*').eq('user_id', user.id);
});
// RIGHT: Use regular server client, RLS handles authorization
export const getUserTasks = authActionClient
.action(async ({ ctx: { user } }) => {
const supabase = getSupabaseServerClient(); // RLS ensures user sees only their data
return supabase.from('tasks').select('*');
});
```
### Creating Multiple Client Instances
```tsx
// WRONG: Creating new client on every call
async function getTasks() {
const supabase = getSupabaseServerClient();
return supabase.from('tasks').select('*');
}
async function getUsers() {
const supabase = getSupabaseServerClient(); // Another instance
return supabase.from('users').select('*');
}
// This is actually fine - the client is lightweight and shares the same
// cookie/auth state. But if you're making multiple queries in one function,
// reuse the instance:
// BETTER: Reuse client within a function
async function loadDashboard() {
const supabase = getSupabaseServerClient();
const [tasks, users] = await Promise.all([
supabase.from('tasks').select('*'),
supabase.from('users').select('*'),
]);
return { tasks, users };
}
```
## Next Steps
Now that you understand the Supabase clients, learn how to use them in different contexts:
- [Server Components](server-components) - Loading data for pages
- [Server Actions](server-actions) - Mutations and form handling
- [React Query](react-query) - Client-side data management

View File

@@ -0,0 +1,159 @@
---
status: "published"
description: "The Next.js Supabase Turbo Dev Tools allows you to debug environment variables using contextual information and error messages."
title: "Debugging Environment Variables in Next.js Supabase"
label: "Environment Variables"
order: 0
---
The Next.js Supabase Turbo Dev Tools allows you to debug environment variables using contextual information and error messages.
{% sequence title="How to debug environment variables using the Next.js Supabase Turbo Dev Tools" description="The Next.js Supabase Turbo Dev Tools allows you to debug environment variables using contextual information and error messages." %}
[Getting Started with the Dev Tool](#getting-started-with-the-dev-tool)
[Development Mode](#development-mode)
[Production Mode](#production-mode)
[Contextual Validation](#contextual-validation)
[Using the Dev Tool to Debug Environment Variables](#using-the-dev-tool-to-debug-environment-variables)
[Debugging Production Environment Variables](#debugging-production-environment-variables)
[Adding your own Environment Variables](#adding-your-own-environment-variables)
{% /sequence %}
{% img src="/assets/images/dev-tools-env-variables.webp" width="1000"
height="600" /%}
## Getting Started with the Dev Tool
If you run the `pnpm run dev` command, you will see the Dev Tools at `http://localhost:3010/variables`.
You can choose two different modes:
1. **Development Mode**: This mode is used when you run the `pnpm run dev` command
2. **Production Mode**: This mode is used when you run the `pnpm run build` command
### Development Mode
In the Development Mode, the Dev Tools will show you the environment variables used during development. This is useful when you are developing your application and want to see the environment variables that are used in your application.
This mode will use the variables from the following files:
1. `.env`
2. `.env.development`
3. `.env.local`
### Production Mode
In the Production Mode, the Dev Tools will show you the environment variables used during production. This is useful when you are deploying your application and want to see the environment variables that are used in your application.
This mode will use the variables from the following files:
1. `.env`
2. `.env.production`
3. `.env.local`
4. `.env.production.local`
### Generating Environment Variables for Production
The right-hand side of the Dev Tool shows the effective environment variables that will be used in production. That is, the value that will ultimately be used in your application based on where they're defined.
The "Copy" button will copy the environment variables to your clipboard in a format that is ready to be pasted into your hosting provider's environment variables.
The Copy button will merge the variables from each environment file using the effective order of resolution.
The recommendation is to create a new file at `apps/web/.env.production.local` and paste the copied variables into it, so that they will override the variables in the other files. Afterwards, copy the result using the "Copy" button again and paste it into your hosting provider's environment variables.
### Contextual Validation
Thanks to contextual validation, we can validate environment variables based
on the value of other environment variables. For example, if you have a variable
`NEXT_PUBLIC_BILLING_PROVIDER` with the value `stripe`, we can validate that
all the variables required for Stripe are set.
## Using the Dev Tool to Debug Environment Variables
The Dev tool shows at a glance the current state of your environment variables. It also shows you the errors that might occur when using the environment variables.
1. **Valid**: This shows you the valid values for the environment variable. Bear in mind, the fact a value is valid does not mean it is correct. It only means that the data-type is correct for the variable.
2. **Invalid**: This shows you the errors that might occur when using the environment variable. For example, if you try to use an invalid data-type, the Dev Tool will show you an error message. It will also warn when a variable is required but not set.
3. **Overridden**: This shows you if the environment variable is overridden. If the variable is overridden, the Dev Tool will show you the value that is being used.
Use the filters to narrow down the variables you want to debug.
### Debugging Production Environment Variables
Of course, most of your production environment variables will be set in your hosting provider for security reasons. To temporarily debug your production environment variables, you can use the following steps:
1. Copy the variables from your hosting provider
2. Create a file at `apps/web/.env.production.local`. This file will be ignored by Git.
3. Paste the copied variables into the `apps/web/.env.production.local` file.
4. Use `Production` as the mode in the Dev Tool.
5. Analyze the data in the Dev Tool.
**Important:** Delete the `apps/web/.env.production.local` file when you're done or store it securely.
## Adding your own Environment Variables
During your development workflow, you may need to add new environment
variables. So that you can debug your application, you can add your own
environment variables to the Dev Tool.
Let's assume you want to add the following environment variables:
```bash
NEXT_PUBLIC_ANALYTICS_ENABLED=true
NEXT_PUBLIC_ANALYTICS_API_KEY=value
```
To add these variables, you need to create a new file in the `apps/dev-tool/app/variables/lib/env-variables-model.ts` file.
The file should look like this:
```tsx {% title="apps/dev-tool/app/variables/lib/env-variables-model.ts" %}
[
{
name: 'NEXT_PUBLIC_ANALYTICS_ENABLED',
description: 'Enables analytics',
category: 'Analytics',
validate: ({ value }) => {
return z
.coerce
.boolean()
.optional()
.safeParse(value);
},
},
{
name: 'NEXT_PUBLIC_ANALYTICS_API_KEY',
description: 'API Key for the analytics service',
category: 'Analytics',
contextualValidation: {
dependencies: [{
variable: 'NEXT_PUBLIC_ANALYTICS_ENABLED',
condition: (value) => {
return value === 'true';
},
message:
'NEXT_PUBLIC_ANALYTICS_API_KEY is required when NEXT_PUBLIC_ANALYTICS_ENABLED is set to "true"',
}],
validate: ({ value }) => {
return z
.string()
.min(1, 'An API key is required when analytics is enabled')
.safeParse(value);
}
}
}]
```
In the above, we added two new environment variables: `NEXT_PUBLIC_ANALYTICS_ENABLED` and `NEXT_PUBLIC_ANALYTICS_API_KEY`.
We also added a validation function for the `NEXT_PUBLIC_ANALYTICS_API_KEY` variable. This function checks if the `NEXT_PUBLIC_ANALYTICS_ENABLED` variable is set to `true`. If it is, the `NEXT_PUBLIC_ANALYTICS_API_KEY` variable becomes required. If it is not, the `NEXT_PUBLIC_ANALYTICS_API_KEY` variable is optional.
In this way, you can make sure that your environment variables are valid and meet the requirements of your application.

View File

@@ -0,0 +1,39 @@
---
status: "published"
description: "The Next.js Supabase Turbo Dev Tools allows you to edit translations and use AI to translate them."
title: "Translations Editor"
label: "Translations Editor"
order: 1
---
The Translations Editor is a tool that allows you to edit translations and use AI to translate them.
It's a simple editor that allows you to edit translations for your project.
{% img src="/assets/images/dev-tools-translations.webp" width="1000"
height="600" /%}
## Set OpenAI API Key
First, you need to set the OpenAI API Key in the `apps/dev-tool/.env.local` file.
```bash apps/dev-tool/.env.local
OPENAI_API_KEY=your-openai-api-key
```
Either make sure your key has access to the `gpt-4o-mini` model or set the `LLM_MODEL_NAME` environment variable to whichever model you have access to.
## Adding a new language
First, you need to add the language to the `packages/i18n/src/locales.tsx` file as described in the [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) documentation.
## Generate Translations with AI
The Translations Editor allows you to generate translations with AI.
You can use the AI to translate the translations for you by clicking the "Translate missing with AI" button.
## Editing Translations
Every time you update a translation, it will be saved automatically to the same file it's defined in.

View File

@@ -0,0 +1,263 @@
---
status: "published"
label: "Adding a Turborepo App"
title: "Add a New Application to Your Makerkit Monorepo"
description: "Create additional applications in your Turborepo monorepo using git subtree to maintain updates from Makerkit while building separate products."
order: 13
---
Add new applications to your Makerkit monorepo using `git subtree` to clone the `apps/web` template while maintaining the ability to pull updates from the Makerkit repository. This is useful for building multiple products (e.g., a main app and an admin dashboard) that share the same packages and infrastructure.
{% alert type="warning" title="Advanced Topic" %}
This guide is for advanced use cases where you need multiple applications in a single monorepo. For most projects, a single `apps/web` application is sufficient. Creating a separate repository may be simpler if you don't need to share code between applications.
{% /alert %}
{% sequence title="Add a Turborepo Application" description="Create a new application from the web template" %}
[Create the subtree branch](#step-1-create-the-subtree-branch)
[Add the new application](#step-2-add-the-new-application)
[Configure the application](#step-3-configure-the-new-application)
[Keep it updated](#step-4-pulling-updates)
{% /sequence %}
## When to Add a New Application
Add a new Turborepo application when:
- **Multiple products**: You're building separate products that share authentication, billing, or UI components
- **Admin dashboard**: You need a separate admin interface with different routing and permissions
- **API server**: You want a dedicated API application separate from your main web app
- **Mobile companion**: You're building a React Native or Expo app that shares business logic
Keep a single application when:
- You only need one web application
- Different features can live under different routes in `apps/web`
- Separation isn't worth the complexity
## Step 1: Create the Subtree Branch
First, create a branch that contains only the `apps/web` folder. This branch serves as the template for new applications.
```bash
git subtree split --prefix=apps/web --branch web-branch
```
This command:
1. Extracts the history of `apps/web` into a new branch
2. Creates `web-branch` containing only the `apps/web` contents
3. Preserves commit history for that folder
## Step 2: Add the New Application
Create your new application by pulling from the subtree branch.
For example, to create a `pdf-chat` application:
```bash
git subtree add --prefix=apps/pdf-chat origin web-branch --squash
```
This command:
1. Creates `apps/pdf-chat` with the same structure as `apps/web`
2. Squashes the history into a single commit (cleaner git log)
3. Sets up tracking for future updates
### Verify the Application
```bash
ls apps/pdf-chat
```
You should see the same structure as `apps/web`:
```
apps/pdf-chat/
├── app/
├── components/
├── config/
├── lib/
├── supabase/
├── next.config.mjs
├── package.json
└── ...
```
## Step 3: Configure the New Application
### Update package.json
Change the package name and any app-specific settings:
```json {% title="apps/pdf-chat/package.json" %}
{
"name": "pdf-chat",
"version": "0.0.1",
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001"
}
}
```
### Update Environment Variables
Create a separate `.env.local` for the new application:
```bash {% title="apps/pdf-chat/.env.local" %}
NEXT_PUBLIC_SITE_URL=http://localhost:3001
NEXT_PUBLIC_APP_NAME="PDF Chat"
# ... other environment variables
```
### Update Supabase Configuration (if separate)
If the application needs its own database, update `apps/pdf-chat/supabase/config.toml` with unique ports and project settings.
### Add Turbo Configuration
Update the root `turbo.json` to include your new application:
```json {% title="turbo.json" %}
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"pdf-chat#dev": {
"dependsOn": ["^build"],
"persistent": true
}
}
}
```
### Run the New Application
```bash
# Run just the new app
pnpm --filter pdf-chat dev
# Run all apps in parallel
pnpm dev
```
## Step 4: Pulling Updates
When Makerkit releases updates, follow these steps to sync them to your new application.
### Pull Upstream Changes
First, pull the latest changes from Makerkit:
```bash
git pull upstream main
```
### Update the Subtree Branch
Re-extract the `apps/web` folder into the subtree branch:
```bash
git subtree split --prefix=apps/web --branch web-branch
```
### Push the Branch
Push the updated branch to your repository:
```bash
git push origin web-branch
```
### Pull Into Your Application
Finally, pull the updates into your new application:
```bash
git subtree pull --prefix=apps/pdf-chat origin web-branch --squash
```
### Resolve Conflicts
If you've modified files that were also changed upstream, you'll need to resolve conflicts:
```bash
# After conflicts appear
git status # See conflicted files
# Edit files to resolve conflicts
git add .
git commit -m "Merge upstream changes into pdf-chat"
```
## Update Workflow Summary
```bash
# 1. Get latest from Makerkit
git pull upstream main
# 2. Update the template branch
git subtree split --prefix=apps/web --branch web-branch
git push origin web-branch
# 3. Pull into each additional app
git subtree pull --prefix=apps/pdf-chat origin web-branch --squash
git subtree pull --prefix=apps/admin origin web-branch --squash
```
## Troubleshooting
**"fatal: refusing to merge unrelated histories"**
Add the `--squash` flag to ignore history differences:
```bash
git subtree pull --prefix=apps/pdf-chat origin web-branch --squash
```
**Subtree branch doesn't exist on remote**
Push it first:
```bash
git push origin web-branch
```
**Application won't start (port conflict)**
Update the port in `package.json`:
```json
{
"scripts": {
"dev": "next dev --port 3001"
}
}
```
**Shared packages not resolving**
Ensure the new app's `package.json` includes the workspace dependencies:
```json
{
"dependencies": {
"@kit/ui": "workspace:*",
"@kit/supabase": "workspace:*"
}
}
```
Then run `pnpm install` from the repository root.
## Related Resources
- [Adding Turborepo Packages](/docs/next-supabase-turbo/development/adding-turborepo-package) for creating shared packages
- [Technical Details](/docs/next-supabase-turbo/installation/technical-details) for monorepo structure
- [Clone Repository](/docs/next-supabase-turbo/installation/clone-repository) for initial setup

View File

@@ -0,0 +1,364 @@
---
status: "published"
label: "Adding a Turborepo Package"
title: "Add a Shared Package to Your Makerkit Monorepo"
description: "Create reusable packages for shared business logic, utilities, or components across your Turborepo monorepo applications."
order: 14
---
Create shared packages in your Makerkit monorepo using `turbo gen` to scaffold a new package at `packages/@kit/<name>`. Shared packages let you reuse business logic, utilities, or components across multiple applications while maintaining a single source of truth.
{% alert type="default" title="When to Create a Package" %}
Create a package when you have code that needs to be shared across multiple applications or when you want to enforce clear boundaries between different parts of your codebase. For code used only in `apps/web`, a folder within the app is simpler.
{% /alert %}
{% sequence title="Create a Shared Package" description="Add a new package to your monorepo" %}
[Generate the package](#step-1-generate-the-package)
[Configure exports](#step-2-configure-exports)
[Add to Next.js config](#step-3-add-to-nextjs-config)
[Use in your application](#step-4-use-the-package)
{% /sequence %}
## When to Create a Package
Create a shared package when:
- **Multiple applications**: Code needs to be used across `apps/web` and other applications
- **Clear boundaries**: You want to enforce separation between different domains
- **Reusable utilities**: Generic utilities that could be used in any application
- **Shared types**: TypeScript types shared across the codebase
Keep code in `apps/web` when:
- It's only used in one application
- It's tightly coupled to specific routes or pages
- Creating a package adds complexity without benefit
## Step 1: Generate the Package
Use the Turborepo generator to scaffold a new package:
```bash
turbo gen
```
Follow the prompts:
1. Select **"Create a new package"**
2. Enter the package name (e.g., `analytics`)
3. Optionally add dependencies
The generator creates a package at `packages/@kit/analytics` with this structure:
```
packages/@kit/analytics/
├── src/
│ └── index.ts
├── package.json
└── tsconfig.json
```
### Package.json Structure
```json {% title="packages/@kit/analytics/package.json" %}
{
"name": "@kit/analytics",
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.0"
}
}
```
## Step 2: Configure Exports
### Single Export (Simple)
For packages with a single entry point, export everything from `index.ts`:
```typescript {% title="packages/@kit/analytics/src/index.ts" %}
export { trackEvent, trackPageView } from './tracking';
export { AnalyticsProvider } from './provider';
export type { AnalyticsEvent, AnalyticsConfig } from './types';
```
Import in your application:
```typescript
import { trackEvent, AnalyticsProvider } from '@kit/analytics';
```
### Multiple Exports (Tree-Shaking)
For packages with client and server code, use multiple exports for better tree-shaking:
```json {% title="packages/@kit/analytics/package.json" %}
{
"name": "@kit/analytics",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts"
}
}
```
Create separate entry points:
```typescript {% title="packages/@kit/analytics/src/client.ts" %}
// Client-side analytics (runs in browser)
export { useAnalytics } from './hooks/use-analytics';
export { AnalyticsProvider } from './components/provider';
```
```typescript {% title="packages/@kit/analytics/src/server.ts" %}
// Server-side analytics (runs on server only)
export { trackServerEvent } from './server/tracking';
export { getAnalyticsClient } from './server/client';
```
Import the specific export:
```typescript
// In a Client Component
import { useAnalytics } from '@kit/analytics/client';
// In a Server Component or Server Action
import { trackServerEvent } from '@kit/analytics/server';
```
### When to Use Multiple Exports
Use multiple exports when:
- **Client/server separation**: Code that should only run in one environment
- **Large packages**: Reduce bundle size by allowing apps to import only what they need
- **Optional features**: Features that not all consumers need
## Step 3: Add to Next.js Config
For hot module replacement (HMR) to work during development, add your package to the `INTERNAL_PACKAGES` array in `apps/web/next.config.mjs`:
```javascript {% title="apps/web/next.config.mjs" %}
const INTERNAL_PACKAGES = [
'@kit/ui',
'@kit/auth',
'@kit/supabase',
// ... existing packages
'@kit/analytics', // Add your new package
];
```
This tells Next.js to:
1. Transpile the package (since it's TypeScript)
2. Watch for changes and trigger HMR
3. Include it in the build optimization
## Step 4: Use the Package
### Add as Dependency
Add the package to your application's dependencies:
```json {% title="apps/web/package.json" %}
{
"dependencies": {
"@kit/analytics": "workspace:*"
}
}
```
Run `pnpm install` to link the workspace package.
### Import and Use
```typescript {% title="apps/web/app/layout.tsx" %}
import { AnalyticsProvider } from '@kit/analytics';
export default function RootLayout({ children }) {
return (
<html>
<body>
<AnalyticsProvider>
{children}
</AnalyticsProvider>
</body>
</html>
);
}
```
```typescript {% title="apps/web/app/home/page.tsx" %}
import { trackPageView } from '@kit/analytics';
export default function HomePage() {
trackPageView({ page: 'home' });
return <div>Welcome</div>;
}
```
## Package Development Patterns
### Adding Dependencies
Add dependencies to your package:
```bash
pnpm --filter @kit/analytics add zod
```
### Using Other Workspace Packages
Reference other workspace packages:
```json {% title="packages/@kit/analytics/package.json" %}
{
"dependencies": {
"@kit/shared": "workspace:*"
}
}
```
### TypeScript Configuration
The package's `tsconfig.json` should extend the root configuration:
```json {% title="packages/@kit/analytics/tsconfig.json" %}
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
```
### Testing Packages
Add tests alongside your package code:
```
packages/@kit/analytics/
├── src/
│ ├── index.ts
│ └── tracking.ts
└── tests/
└── tracking.test.ts
```
Run tests:
```bash
pnpm --filter @kit/analytics test
```
## Example: Creating a Feature Package
Here's a complete example of creating a `notifications` package:
### 1. Generate
```bash
turbo gen
# Name: notifications
```
### 2. Structure
```
packages/@kit/notifications/
├── src/
│ ├── index.ts
│ ├── client.ts
│ ├── server.ts
│ ├── components/
│ │ └── notification-bell.tsx
│ ├── hooks/
│ │ └── use-notifications.ts
│ └── server/
│ └── send-notification.ts
└── package.json
```
### 3. Exports
```json {% title="packages/@kit/notifications/package.json" %}
{
"name": "@kit/notifications",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts"
},
"dependencies": {
"@kit/supabase": "workspace:*"
}
}
```
### 4. Implementation
```typescript {% title="packages/@kit/notifications/src/client.ts" %}
export { NotificationBell } from './components/notification-bell';
export { useNotifications } from './hooks/use-notifications';
```
```typescript {% title="packages/@kit/notifications/src/server.ts" %}
export { sendNotification } from './server/send-notification';
```
### 5. Use
```typescript
// Client Component
import { NotificationBell } from '@kit/notifications/client';
// Server Action
import { sendNotification } from '@kit/notifications/server';
```
## Troubleshooting
**Module not found**
1. Ensure the package is in `INTERNAL_PACKAGES` in `next.config.mjs`
2. Run `pnpm install` to link workspace packages
3. Check the export path matches your import
**Types not resolving**
Ensure `tsconfig.json` includes the package paths:
```json
{
"compilerOptions": {
"paths": {
"@kit/*": ["./packages/@kit/*/src"]
}
}
}
```
**HMR not working**
Verify the package is listed in `INTERNAL_PACKAGES` and restart the dev server.
## Related Resources
- [Adding Turborepo Apps](/docs/next-supabase-turbo/development/adding-turborepo-app) for creating new applications
- [Technical Details](/docs/next-supabase-turbo/installation/technical-details) for monorepo architecture

View File

@@ -0,0 +1,304 @@
---
status: "published"
label: "Application tests (E2E)"
title: "Writing Application Tests (E2E) with Playwright"
description: "Learn how to write Application Tests (E2E) with Playwright to test your application and ensure it works as expected"
order: 11
---
End-to-end (E2E) tests are crucial for ensuring your application works correctly from the user's perspective. This guide covers best practices for writing reliable, maintainable E2E tests using Playwright in your Makerkit application.
## Core Testing Principles
### 1. Test Structure and Organization
Your E2E tests are organized in the `apps/e2e/tests/` directory with the following structure:
```
apps/e2e/tests/
├── authentication/ # Auth-related tests
│ ├── auth.spec.ts # Test specifications
│ └── auth.po.ts # Page Object Model
├── team-accounts/ # Team functionality tests
├── invitations/ # Invitation flow tests
├── utils/ # Shared utilities
│ ├── mailbox.ts # Email testing utilities
│ ├── otp.po.ts # OTP verification utilities
│ └── billing.po.ts # Billing test utilities
└── playwright.config.ts # Playwright configuration
```
**Key Principles:**
- Each feature has its own directory with `.spec.ts` and `.po.ts` files
- Shared utilities are in the `utils/` directory
- Page Object Model (POM) pattern is used consistently
### 2. Page Object Model Pattern
The Page Object Model encapsulates page interactions and makes tests more maintainable. Here's how it's implemented:
```typescript
// auth.po.ts
export class AuthPageObject {
private readonly page: Page;
private readonly mailbox: Mailbox;
constructor(page: Page) {
this.page = page;
this.mailbox = new Mailbox(page);
}
async signIn(params: { email: string; password: string }) {
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
await this.page.click('button[type="submit"]');
}
async signOut() {
await this.page.click('[data-test="account-dropdown-trigger"]');
await this.page.click('[data-test="account-dropdown-sign-out"]');
}
}
```
**Best Practices:**
- Group related functionality in Page Objects
- Use descriptive method names that reflect user actions
- Encapsulate complex workflows in single methods
- Return promises or use async/await consistently
The test file would look like this:
```typescript
import { expect, test } from '@playwright/test';
import { AuthPageObject } from './auth.po';
test.describe('Auth flow', () => {
test.describe.configure({ mode: 'serial' });
let email: string;
let auth: AuthPageObject;
test.beforeEach(async ({ page }) => {
auth = new AuthPageObject(page);
});
test('will sign-up and redirect to the home page', async ({ page }) => {
await auth.goToSignUp();
email = auth.createRandomEmail();
console.log(`Signing up with email ${email} ...`);
await auth.signUp({
email,
password: 'password',
repeatPassword: 'password',
});
await auth.visitConfirmEmailLink(email);
await page.waitForURL('**/home');
});
});
```
1. The test file instantiates the `AuthPageObject` before each test
2. The Page Object wraps the logic for the auth flow so that we can reuse it in the tests
## Data-Test Attributes
Use `data-test` attributes to create stable, semantic selectors that won't break when UI changes.
### ✅ Good: Using data-test attributes
```typescript
// In your React component
<button data-test="submit-button" onClick={handleSubmit}>
Submit
</button>
// In your test
await this.page.click('[data-test="submit-button"]');
```
### ❌ Bad: Using fragile selectors
```typescript
// Fragile - breaks if class names or text changes
await this.page.click('.btn-primary');
await this.page.click('button:has-text("Submit")');
```
### Common Data-Test Patterns
```typescript
// Form elements
<input data-test="email-input" name="email" />
<input data-test="password-input" name="password" />
<button data-test="submit-button" type="submit">Submit</button>
// Navigation
<button data-test="account-dropdown-trigger">Account</button>
<a data-test="settings-link" href="/settings">Settings</a>
// Lists and rows
<div data-test="team-member-row" data-user-id={user.id}>
<span data-test="member-role-badge">{role}</span>
</div>
// Forms with specific purposes
<form data-test="create-team-form">
<input data-test="team-name-input" />
<button data-test="create-team-button">Create</button>
</form>
```
## Retry-ability with expect().toPass()
Use `expect().toPass()` to wrap operations that might be flaky due to timing issues or async operations.
### ✅ Good: Using expect().toPass()
```typescript
async visitConfirmEmailLink(email: string) {
return expect(async () => {
const res = await this.mailbox.visitMailbox(email, { deleteAfter: true });
expect(res).not.toBeNull();
}).toPass();
}
async openAccountsSelector() {
return expect(async () => {
await this.page.click('[data-test="account-selector-trigger"]');
return expect(
this.page.locator('[data-test="account-selector-content"]'),
).toBeVisible();
}).toPass();
}
```
### ❌ Bad: Not using retry mechanisms
```typescript
// This might fail due to timing issues
async openAccountsSelector() {
await this.page.click('[data-test="account-selector-trigger"]');
await expect(
this.page.locator('[data-test="account-selector-content"]'),
).toBeVisible();
}
```
### When to Use expect().toPass()
- **Email operations**: Waiting for emails to arrive
- **Navigation**: Waiting for URL changes after actions
- **Async UI updates**: Operations that trigger network requests
- **External dependencies**: Interactions with third-party services
## Test Isolation and Deterministic Results
Test isolation is crucial for reliable test suites:
1. Make sure each tests sets up its own context and data
2. Never rely on data from other tests
3. For maximum isolation, you should create your own data for each test - however this can be time-consuming so you should take it into account when writing your tests
### 1. Independent Test Data
```typescript
// Generate unique test data for each test
createRandomEmail() {
const value = Math.random() * 10000000000000;
return `${value.toFixed(0)}@makerkit.dev`;
}
createTeamName() {
const id = Math.random().toString(36).substring(2, 8);
return {
teamName: `Test Team ${id}`,
slug: `test-team-${id}`,
};
}
```
## Email Testing with Mailbox
The `Mailbox` utility helps test email-dependent flows using Mailpit.
### 1. Basic Email Operations
```typescript
export class Mailbox {
static URL = 'http://127.0.0.1:54324';
async visitMailbox(email: string, params: { deleteAfter: boolean; subject?: string }) {
const json = await this.getEmail(email, params);
if (email !== json.To[0]!.Address) {
throw new Error(`Email address mismatch. Expected ${email}, got ${json.To[0]!.Address}`);
}
const el = parse(json.HTML);
const linkHref = el.querySelector('a')?.getAttribute('href');
return this.page.goto(linkHref);
}
}
```
## Race conditions
Race conditions issues are common in E2E tests. Testing UIs is inherently asynchronous, and you need to be careful about the order of operations.
In many cases, your application will execute async operations. In such cases, you want to use Playwright's utilities to wait for the operation to complete.
Below is a common pattern for handling async operations in E2E tests:
1. Click the button
2. Wait for the async operation to complete
3. Proceed with the test (expectations, assertions, etc.)
```typescript
const button = page.locator('[data-test="submit-button"]');
const response = page.waitForResponse((resp) => {
return resp.url().includes(`/your-api-endpoint`);
});
await Promise.all([button.click(), response]);
// proceed with the test
```
The pattern above ensures that the test will only proceed once the async operation has completed.
### Handling race conditions using timeouts
Timeouts are generally discouraged in E2E tests. However, in some cases, you may want to use them to avoid flaky tests when every other solution failed.
```tsx
await page.waitForTimeout(1000);
```
In general, during development, most operations resolve within 50-100ms - so these would be an appropriate amount of time to wait if you hit overly flaky tests.
## Testing Checklist
When writing E2E tests, ensure you:
- [ ] Use `data-test` attributes for element selection
- [ ] Implement Page Object Model pattern
- [ ] Wrap flaky operations in `expect().toPass()`
- [ ] Generate unique test data for each test run
- [ ] Clean up state between tests
- [ ] Handle async operations properly
- [ ] Test both happy path and error scenarios
- [ ] Include proper assertions and validations
- [ ] Follow naming conventions for test files and methods
- [ ] Document complex test scenarios
By following these best practices, you'll create robust, maintainable E2E tests that provide reliable feedback about your application's functionality.

View File

@@ -0,0 +1,215 @@
---
status: "published"
label: "Getting Started with Development"
order: 0
title: "Local Development Guide for the Next.js Supabase Starter Kit"
description: "Set up your development environment, understand Makerkit's architecture patterns, and navigate the development guides."
---
Start local development by running `pnpm dev` to launch the Next.js app and Supabase services. Makerkit uses a security-first, account-centric architecture where all business data belongs to accounts (personal or team), protected by Row Level Security (RLS) policies enforced at the database level.
{% sequence title="Development Setup" description="Get started with local development" %}
[Start development services](#development-environment)
[Understand the architecture](#development-philosophy)
[Navigate the guides](#development-guides-overview)
[Follow common patterns](#common-development-patterns)
{% /sequence %}
## Development Environment
### Starting Services
```bash
# Start all services (Next.js app + Supabase)
pnpm dev
# Or start individually
pnpm --filter web dev # Next.js app (port 3000)
pnpm run supabase:web:start # Local Supabase
```
### Key URLs
| Service | URL | Purpose |
|---------|-----|---------|
| Main app | http://localhost:3000 | Your application |
| Supabase Studio | http://localhost:54323 | Database admin UI |
| Inbucket (email) | http://localhost:54324 | Local email testing |
### Common Commands
```bash
# Database
pnpm run supabase:web:reset # Reset database to clean state
pnpm --filter web supabase:typegen # Regenerate TypeScript types
# Development
pnpm typecheck # Type check all packages
pnpm lint:fix # Fix linting issues
pnpm format:fix # Format code
```
## Development Philosophy
Makerkit is built around three core principles that guide all development decisions:
### Security by Default
Every feature leverages Row Level Security (RLS) and the permission system. Access controls are built into the database layer, not application code. When you add a new table, you also add RLS policies that enforce who can read, write, and delete data.
### Multi-Tenant from Day One
All business data belongs to accounts (personal or team). This design enables both B2C and B2B use cases while ensuring proper data isolation. Every table that holds user-generated data includes an `account_id` foreign key.
### Type-Safe Development
TypeScript types are auto-generated from your database schema. When you modify the database, run `pnpm --filter web supabase:typegen` to update types. This ensures end-to-end type safety from database to UI.
## Development Guides Overview
### Database & Data Layer
Start here to understand the foundation:
| Guide | Description |
|-------|-------------|
| [Database Architecture](/docs/next-supabase-turbo/development/database-architecture) | Multi-tenant data model, security patterns, core tables |
| [Database Schema](/docs/next-supabase-turbo/development/database-schema) | Add tables, RLS policies, triggers, and relationships |
| [Migrations](/docs/next-supabase-turbo/development/migrations) | Create and apply schema changes |
| [Database Functions](/docs/next-supabase-turbo/development/database-functions) | Built-in functions for permissions, roles, subscriptions |
| [Database Tests](/docs/next-supabase-turbo/development/database-tests) | Test RLS policies with pgTAP |
| [Database Webhooks](/docs/next-supabase-turbo/development/database-webhooks) | React to database changes |
### Application Development
| Guide | Description |
|-------|-------------|
| [Loading Data](/docs/next-supabase-turbo/development/loading-data-from-database) | Fetch data in Server Components and Client Components |
| [Writing Data](/docs/next-supabase-turbo/development/writing-data-to-database) | Server Actions, forms, and mutations |
| [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) | RBAC implementation and permission checks |
### Frontend & Marketing
| Guide | Description |
|-------|-------------|
| [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) | Landing pages, pricing, FAQ |
| [Legal Pages](/docs/next-supabase-turbo/development/legal-pages) | Privacy policy, terms of service |
| [SEO](/docs/next-supabase-turbo/development/seo) | Metadata, sitemap, structured data |
| [External Marketing Website](/docs/next-supabase-turbo/development/external-marketing-website) | Redirect to Framer, Webflow, etc. |
### Architecture & Testing
| Guide | Description |
|-------|-------------|
| [Application Tests (E2E)](/docs/next-supabase-turbo/development/application-tests) | Playwright E2E testing patterns |
| [Adding Turborepo Apps](/docs/next-supabase-turbo/development/adding-turborepo-app) | Add new applications to the monorepo |
| [Adding Turborepo Packages](/docs/next-supabase-turbo/development/adding-turborepo-package) | Create shared packages |
## Common Development Patterns
### The Account-Centric Pattern
Every business entity references an `account_id`:
```sql
create table public.projects (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
created_at timestamptz not null default now()
);
```
### The Security-First Pattern
Every table has RLS enabled with explicit policies:
```sql
alter table public.projects enable row level security;
create policy "Members can view their projects"
on public.projects
for select
to authenticated
using (public.has_role_on_account(account_id));
create policy "Users with write permission can create projects"
on public.projects
for insert
to authenticated
with check (public.has_permission(auth.uid(), account_id, 'projects.write'::app_permissions));
```
### The Type-Safe Pattern
Database types are auto-generated:
```typescript
import type { Database } from '@kit/supabase/database';
type Project = Database['public']['Tables']['projects']['Row'];
type NewProject = Database['public']['Tables']['projects']['Insert'];
```
### The Server Action Pattern
Use `authActionClient` for validated, authenticated server actions:
```typescript
import { authActionClient } from '@kit/next/safe-action';
import * as z from 'zod';
const schema = z.object({
name: z.string().min(1),
accountId: z.string().uuid(),
});
export const createProject = authActionClient
.inputSchema(schema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
// data is validated, user is authenticated
const supabase = getSupabaseServerClient();
const { data: project } = await supabase
.from('projects')
.insert({ name: data.name, account_id: data.accountId })
.select()
.single();
return project;
});
```
## Recommended Learning Path
### 1. Foundation (Start Here)
1. [Database Architecture](/docs/next-supabase-turbo/development/database-architecture) - Understand the multi-tenant model
2. [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) - Learn RBAC implementation
3. [Database Schema](/docs/next-supabase-turbo/development/database-schema) - Build your first feature
### 2. Core Development
4. [Loading Data](/docs/next-supabase-turbo/development/loading-data-from-database) - Data fetching patterns
5. [Writing Data](/docs/next-supabase-turbo/development/writing-data-to-database) - Forms and mutations
6. [Migrations](/docs/next-supabase-turbo/development/migrations) - Schema change workflow
### 3. Advanced (As Needed)
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) - Custom database logic
- [Database Webhooks](/docs/next-supabase-turbo/development/database-webhooks) - Event-driven features
- [Database Tests](/docs/next-supabase-turbo/development/database-tests) - Test RLS policies
## Next Steps
1. **Read [Database Architecture](/docs/next-supabase-turbo/development/database-architecture)** to understand the foundation
2. **Plan your first feature** - define entities, relationships, and access rules
3. **Implement step-by-step** following the [Database Schema](/docs/next-supabase-turbo/development/database-schema) guide
4. **Test your RLS policies** using [Database Tests](/docs/next-supabase-turbo/development/database-tests)
The guides are designed to be practical and production-ready. Each builds on knowledge from previous ones, developing your expertise with Makerkit's architecture and patterns.

View File

@@ -0,0 +1,821 @@
---
title: "Database Architecture in Makerkit"
label: "Database Architecture"
description: "Deep dive into Makerkit's database schema, security model, and best practices for building secure multi-tenant SaaS applications"
---
Makerkit implements a sophisticated, security-first database architecture designed for multi-tenant SaaS applications.
This guide provides a comprehensive overview of the database schema, security patterns, and best practices you should follow when extending the system.
{% sequence title="Database Architecture" description="Deep dive into Makerkit's database schema, security model, and best practices for building secure multi-tenant SaaS applications" %}
[Multi-Tenant Design](#multi-tenant-design)
[Core Tables](#core-tables)
[Authentication & Security](#authentication-security)
[Billing & Commerce](#billing-commerce)
[Features & Functionality](#features-functionality)
[Database Functions & Views](#database-functions-views)
[Database Schema Relationships](#database-schema-relationships)
[Understanding the Database Tables](#understanding-the-database-tables)
[Extending the Database: Decision Trees and Patterns](#extending-the-database-decision-trees-and-patterns)
[Security Model](#security-model)
[Summary](#summary)
{% /sequence %}
### Multi-Tenant Design
Makerkit supports two types of accounts, providing flexibility for both B2C and B2B use cases:
#### Personal Accounts
Individual user accounts where the user ID equals the account ID. Perfect for B2C applications or personal workspaces.
```sql
-- Personal account characteristics
- id = auth.uid() (user's ID)
- is_personal_account = true
- slug = NULL (no public URL needed)
- Automatically created on user signup
```
#### Team Accounts
Shared workspaces with multiple members, roles, and permissions. Ideal for B2B applications or collaborative features.
```sql
-- Team account characteristics
- id = UUID (unique account ID)
- is_personal_account = false
- slug = unique string (for public URLs)
- Members managed through accounts_memberships
```
### Complete Database Schema
Makerkit's database consists of 17 core tables organized across several functional areas:
#### Core Tables
| Table | Purpose | Key Relationships |
|-------|---------|------------------|
| `accounts` | Multi-tenant accounts (personal/team) | References `auth.users` as owner |
| `accounts_memberships` | Team membership with roles | Links `auth.users` to `accounts` |
| `roles` | Role definitions with hierarchy | Referenced by memberships |
| `role_permissions` | Permissions per role | Links roles to app permissions |
#### Authentication & Security
| Table | Purpose | Key Features |
|-------|---------|--------------|
| `nonces` | OTP for sensitive operations | Purpose-based, auto-expiring |
| `invitations` | Team invitation system | Token-based with role assignment |
#### Billing & Commerce
| Table | Purpose | Provider Support |
|-------|---------|-----------------|
| `billing_customers` | Customer records per provider | Stripe, LemonSqueezy, Paddle |
| `subscriptions` | Active subscriptions | Multiple billing providers |
| `subscription_items` | Subscription line items | Flat, per-seat, metered pricing |
| `orders` | One-time purchases | Product sales, licenses |
| `order_items` | Order line items | Detailed purchase records |
#### Features & Functionality
| Table | Purpose | Key Features |
|-------|---------|--------------|
| `notifications` | Multi-channel notifications | In-app, email, real-time |
#### Database Functions & Views
| Type | Purpose | Security Model |
|------|---------|----------------|
| Views | Data access abstractions | Security invoker for RLS |
| Functions | Business logic & helpers | Security definer with validation |
| Triggers | Data consistency | Automatic field updates |
### Database Schema Relationships
{% img src="/images/database-architecture.webp" width="1000" height="1000" alt="Database Architecture" /%}
## Understanding the Database Tables
This section provides detailed explanations of each table group, their relationships, and practical guidance on how to work with them effectively.
### Core Multi-Tenancy Tables
The foundation of Makerkit's architecture rests on a sophisticated multi-tenant design that seamlessly handles both individual users and collaborative teams.
#### The `accounts` Table: Your Tenancy Foundation
The `accounts` table serves as the cornerstone of Makerkit's multi-tenant architecture. Every piece of data in your application ultimately belongs to an account, making this table critical for data isolation and security.
**When to use personal accounts**: Personal accounts are automatically created when users sign up and are perfect for B2C applications, personal productivity tools, or individual workspaces. The account ID directly matches the user's authentication ID, creating a simple 1:1 relationship that's easy to reason about.
**When to use team accounts**: Team accounts enable collaborative features essential for B2B SaaS applications. They support multiple members with different permission levels, shared resources, and centralized billing. Each team account gets a unique slug for branded URLs like `yourapp.com/acme-corp`.
```sql
-- Example: Creating a team account for collaboration
INSERT INTO accounts (name, is_personal_account, slug)
VALUES ('Acme Corporation', false, 'acme-corp');
```
**Key architectural decisions**: The conditional constraint system ensures data integrity - personal accounts cannot have slugs (they don't need public URLs), while team accounts must have them. This prevents common mistakes and enforces the intended usage patterns.
#### The `accounts_memberships` Table: Team Collaboration Hub
This junction table manages the many-to-many relationship between users and team accounts. It's where team collaboration comes to life through role-based access control.
**Understanding membership lifecycle**: When a team account is created, the creator automatically becomes a member with the highest role. Additional members join through invitations or direct assignment. The composite primary key (user_id, account_id) ensures users can't have duplicate memberships in the same account.
**Role hierarchy in action**: The system uses a numerical hierarchy where lower numbers indicate higher privileges. An owner (hierarchy level 1) can manage all aspects of the account, while members (hierarchy level 2) have limited permissions. This makes it easy to add new roles between existing ones.
```sql
-- Example: Adding a member to a team
INSERT INTO accounts_memberships (user_id, account_id, account_role)
VALUES ('user-uuid', 'team-account-uuid', 'member');
```
**Best practices for membership management**: Always validate role hierarchy when promoting or demoting members. The system prevents removing the primary owner's membership to maintain account ownership integrity.
#### The `roles` and `role_permissions` Tables: Granular Access Control
These tables work together to provide a flexible, hierarchical permission system that can adapt to complex organizational structures.
**Designing permission systems**: The `roles` table defines named roles with hierarchy levels, while `role_permissions` maps specific permissions to each role. This separation allows you to easily modify what each role can do without restructuring your entire permission system.
**Permission naming conventions**: Permissions follow a `resource.action` pattern (e.g., `billing.manage`, `members.invite`). This makes them self-documenting and easy to understand. When adding new features, follow this pattern to maintain consistency.
```sql
-- Example: Creating a custom role with specific permissions
INSERT INTO roles (name, hierarchy_level) VALUES ('manager', 1.5);
INSERT INTO role_permissions (role, permission) VALUES
('manager', 'members.manage'),
('manager', 'settings.manage');
```
### Security and Access Control Tables
Makerkit implements multiple layers of security through specialized tables that handle authentication, authorization, and administrative access.
#### The `nonces` Table: Secure Operations Gateway
One-time tokens provide an additional security layer for sensitive operations that go beyond regular authentication. This table manages short-lived, purpose-specific codes that verify user intent for critical actions.
**Understanding token purposes**: Each token has a specific purpose (email verification, password reset, account deletion) and cannot be reused for other operations. This prevents token reuse attacks and ensures proper authorization flows.
**Implementation strategies**: Tokens automatically expire and are limited to specific scopes. When a user requests a new token for the same purpose, previous tokens are invalidated. This prevents accumulation of valid tokens and reduces security risks.
**Security considerations**: Always validate the IP address and user agent when possible. The table tracks these for audit purposes and can help detect suspicious activity.
Please refer to the [One-Time Tokens](../api/otp-api) documentation for more details.
#### The `invitations` Table: Secure Team Building
The invitation system enables secure team expansion while maintaining strict access controls. It bridges the gap between open team joining and secure access management.
**Invitation workflow design**: Invitations are token-based with automatic expiration. The inviter's permissions are validated at creation time, ensuring only authorized users can extend invitations. Role assignment happens at invitation time, not acceptance, providing clear expectations.
**Managing invitation security**: Each invitation includes a cryptographically secure token that cannot be guessed. Expired invitations are automatically invalid, and the system tracks who sent each invitation for audit purposes.
```sql
-- Example: Creating a secure invitation
INSERT INTO invitations (email, account_id, role, invite_token, expires_at, invited_by)
VALUES ('new-member@company.com', 'team-uuid', 'member', 'secure-random-token', now() + interval '7 days', 'inviter-uuid');
```
**Best practices for invitations**: Set reasonable expiration times (typically 7 days), validate email addresses before sending, and provide clear role descriptions in invitation emails.
#### The `super_admins` Table: Platform Administration
This table manages platform-level administrators who can perform system-wide operations that transcend individual accounts. It's designed with the highest security standards.
**Admin privilege model**: Super admin status requires multi-factor authentication and is separate from regular account permissions. This creates a clear separation between application users and platform administrators.
**Security enforcement**: All super admin operations require MFA verification through the `is_aal2()` function. This ensures that even if an admin's password is compromised, sensitive operations remain protected.
### Billing and Commerce Infrastructure
Makerkit's billing system is designed to handle complex pricing models across multiple payment providers while maintaining clean data architecture.
#### The `billing_customers` Table: Payment Provider Bridge
This table creates the essential link between your application's accounts and external payment provider customer records. It's the foundation that enables multi-provider billing support.
**Provider abstraction benefits**: By storing customer IDs for each provider separately, you can migrate between billing providers, support multiple providers simultaneously, or offer region-specific payment options without data loss.
**Customer lifecycle management**: When an account first needs billing capabilities, a customer record is created with their chosen provider. This lazy creation approach prevents unnecessary external API calls and keeps your billing clean.
```sql
-- Example: Linking an account to Stripe
INSERT INTO billing_customers (account_id, customer_id, provider)
VALUES ('account-uuid', 'cus_stripe_customer_id', 'stripe');
```
**Multi-provider strategies**: Some applications use different providers for different markets (Stripe for US/EU, local providers for other regions). The table structure supports this with provider-specific customer records.
#### The `subscriptions` and `subscription_items` Tables: Flexible Pricing Models
These tables work together to support sophisticated pricing models including flat-rate, per-seat, and usage-based billing across multiple products and features.
**Subscription architecture**: The parent `subscriptions` table tracks overall subscription status, billing periods, and provider information. Child `subscription_items` handle individual components, enabling complex pricing like "basic plan + extra seats + API usage."
**Pricing model flexibility**: The `type` field in subscription items enables different billing models:
- **Flat**: Fixed monthly/yearly pricing
- **Per-seat**: Automatically adjusted based on team size
- **Metered**: Based on usage (API calls, storage, etc.)
```sql
-- Example: Complex subscription with multiple items
-- Base plan + per-seat pricing + metered API usage
INSERT INTO subscription_items (subscription_id, price_id, quantity, type) VALUES
('sub-uuid', 'price_base_plan', 1, 'flat'),
('sub-uuid', 'price_per_seat', 5, 'per_seat'),
('sub-uuid', 'price_api_calls', 0, 'metered');
```
**Automatic seat management**: The per-seat billing service automatically adjusts quantities when team members are added or removed. This eliminates manual billing adjustments and ensures accurate charges.
#### The `orders` and `order_items` Tables: One-Time Purchases
These tables handle non-recurring transactions like product purchases, one-time fees, or license sales that complement subscription revenue.
**Order vs subscription distinction**: Orders represent completed transactions for specific products or services, while subscriptions handle recurring billing. This separation enables hybrid business models with both recurring and one-time revenue streams.
**Order fulfillment tracking**: Orders include status tracking and detailed line items for complex transactions. This supports scenarios like software licenses, premium features, or physical products.
### Application Feature Tables
#### The `notifications` Table: Multi-Channel Communication
This table powers Makerkit's notification system, supporting both in-app notifications and email delivery with sophisticated targeting and lifecycle management.
**Channel strategy**: Notifications can target specific channels (in-app, email) or both. This enables rich notification experiences where users see immediate in-app alerts backed by email records for important updates.
**Lifecycle management**: Notifications include dismissal tracking and automatic expiration. This prevents notification bloat while ensuring important messages reach users. The metadata JSONB field stores channel-specific data like email templates or push notification payloads.
```sql
-- Example: Creating a billing notification
INSERT INTO notifications (account_id, type, channel, metadata, expires_at)
VALUES ('account-uuid', 'billing_issue', 'in_app', '{"severity": "high", "action_url": "/billing"}', now() + interval '30 days');
```
**Performance considerations**: Index notifications by account_id and dismissed status for fast user queries. Consider archiving old notifications to maintain performance as your application scales.
## Extending the Database: Decision Trees and Patterns
Understanding when and how to extend Makerkit's database requires careful consideration of data ownership, security, and scalability. This section provides practical guidance for common scenarios.
### Adding New Feature Tables
When building new features, you'll need to decide how they integrate with the existing multi-tenant architecture. Here's a decision framework:
#### Step 1: Determine Data Ownership
**Question**: Who owns this data - individual users or accounts?
**User-owned data**: Data like user preferences, personal settings, or individual activity logs should reference `auth.users` directly. This data follows the user across all their account memberships.
```sql
-- Example: User preferences that follow the user everywhere
CREATE TABLE user_preferences (
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
theme varchar(20) DEFAULT 'light',
language varchar(10) DEFAULT 'en',
email_notifications boolean DEFAULT true
);
```
**Account-owned data**: Business data, shared resources, and collaborative content should reference `accounts`. This ensures proper multi-tenant isolation and enables team collaboration.
```sql
-- Example: Account-owned documents with proper tenancy
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid REFERENCES accounts(id) ON DELETE CASCADE,
title text NOT NULL,
content text,
created_by uuid REFERENCES auth.users(id),
-- Always include account_id for multi-tenancy
CONSTRAINT documents_account_ownership CHECK (account_id IS NOT NULL)
);
```
#### Step 2: Define Access Patterns
**Public data within account**: Use standard RLS patterns that allow all account members to read but restrict writes based on permissions.
**Private data within account**: Add a `created_by` field and restrict access to the creator plus users with specific permissions.
**Hierarchical data**: Consider department-level or project-level access within accounts for complex organizations.
### Common Table Patterns
#### Pattern 1: Simple Account-Owned Resources
Most feature tables follow this pattern. They belong to an account and have basic RLS policies.
```sql
-- Template for account-owned resources
CREATE TABLE your_feature (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL,
name text NOT NULL,
description text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
created_by uuid REFERENCES auth.users(id),
updated_by uuid REFERENCES auth.users(id)
);
-- Standard RLS policy
CREATE POLICY "feature_account_access" ON your_feature
FOR ALL TO authenticated
USING (public.has_role_on_account(account_id))
WITH CHECK (public.has_permission(auth.uid(), account_id, 'feature.manage'));
```
#### Pattern 2: Hierarchical Resources
For features that need sub-categories or nested structures within accounts.
```sql
-- Example: Project categories with hierarchy
CREATE TABLE project_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL,
parent_id uuid REFERENCES project_categories(id) ON DELETE CASCADE,
name text NOT NULL,
path ltree, -- PostgreSQL ltree for efficient tree operations
-- Ensure hierarchy stays within account
CONSTRAINT categories_same_account CHECK (
parent_id IS NULL OR
(SELECT account_id FROM project_categories WHERE id = parent_id) = account_id
)
);
```
#### Pattern 3: Permission-Gated Features
For sensitive features that require specific permissions beyond basic account membership.
```sql
-- Example: Financial reports requiring special permissions
CREATE TABLE financial_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL,
report_data jsonb NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
created_by uuid REFERENCES auth.users(id)
);
-- Restrictive RLS requiring specific permission
CREATE POLICY "financial_reports_access" ON financial_reports
FOR ALL TO authenticated
USING (public.has_permission(auth.uid(), account_id, 'reports.financial'))
WITH CHECK (public.has_permission(auth.uid(), account_id, 'reports.financial'));
```
### Integration with Billing
When adding features that affect billing, consider these patterns:
#### Feature Access Control
For subscription-gated features, create lookup tables that determine feature availability.
```sql
-- Example: Feature access based on subscription
CREATE TABLE subscription_features (
subscription_id uuid REFERENCES subscriptions(id) ON DELETE CASCADE,
feature_name text NOT NULL,
enabled boolean DEFAULT true,
usage_limit integer, -- NULL means unlimited
PRIMARY KEY (subscription_id, feature_name)
);
-- Helper function to check feature access
CREATE OR REPLACE FUNCTION has_feature_access(
target_account_id uuid,
feature_name text
) RETURNS boolean AS $$
DECLARE
has_access boolean := false;
BEGIN
SELECT sf.enabled INTO has_access
FROM subscriptions s
JOIN subscription_features sf ON s.id = sf.subscription_id
WHERE s.account_id = target_account_id
AND sf.feature_name = has_feature_access.feature_name
AND s.active = true;
RETURN COALESCE(has_access, false);
END;
$$ LANGUAGE plpgsql;
```
### Security Best Practices for Extensions
#### Always Enable RLS
Never create a table without enabling Row Level Security. This should be your default approach.
```sql
-- ALWAYS do this for new tables
CREATE TABLE your_new_table (...);
ALTER TABLE your_new_table ENABLE ROW LEVEL SECURITY;
```
#### Validate Cross-Account References
When tables reference multiple accounts, ensure data integrity through constraints.
```sql
-- Example: Collaboration requests between accounts
CREATE TABLE collaboration_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
from_account_id uuid REFERENCES accounts(id) ON DELETE CASCADE,
to_account_id uuid REFERENCES accounts(id) ON DELETE CASCADE,
status text CHECK (status IN ('pending', 'accepted', 'rejected')),
-- Prevent self-collaboration
CONSTRAINT no_self_collaboration CHECK (from_account_id != to_account_id)
);
```
### Key Design Principles
1. **Account-Centric**: All data associates with accounts via foreign keys for proper multi-tenancy
2. **Security by Default**: RLS enabled on all tables with explicit permission checks
3. **Provider Agnostic**: Billing supports multiple payment providers (Stripe, LemonSqueezy, Paddle)
4. **Audit Ready**: Comprehensive tracking with created_by, updated_by, timestamps
5. **Scalable**: Proper indexing and cascade relationships for performance
## Security Model
### Row Level Security (RLS)
> **⚠️ CRITICAL WARNING**: Always enable RLS on new tables. This is your first line of defense against unauthorized access.
Makerkit enforces RLS on all tables with carefully crafted policies:
```sql
-- Example: Notes table with proper RLS
CREATE TABLE if not exists public.notes (
id uuid primary key default gen_random_uuid(),
account_id uuid references public.accounts(id) on delete cascade,
content text,
created_by uuid references auth.users(id)
);
-- Enable RLS (NEVER SKIP THIS!)
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
-- Read policy: Owner or team member can read
CREATE POLICY "notes_read" ON public.notes FOR SELECT
TO authenticated USING (
account_id = (select auth.uid()) -- Personal account
OR
public.has_role_on_account(account_id) -- Team member
);
-- Write policy: Specific permission required
CREATE POLICY "notes_manage" ON public.notes FOR ALL
TO authenticated USING (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
```
### Security Helper Functions
Makerkit provides battle-tested security functions. **Always use these instead of creating your own**:
#### Account Access Functions
```sql
-- Check if user owns the account
public.is_account_owner(account_id)
-- Check if user is a team member
public.has_role_on_account(account_id, role?)
-- Check specific permission
public.has_permission(user_id, account_id, permission)
-- Check if user can manage another member
public.can_action_account_member(account_id, target_user_id)
```
#### Security Check Functions
```sql
-- Verify user is super admin
public.is_super_admin()
-- Check MFA compliance
public.is_aal2()
public.is_mfa_compliant()
-- Check feature flags
public.is_set(field_name)
```
### SECURITY DEFINER Functions
> **🚨 DANGER**: SECURITY DEFINER functions bypass RLS. Only use when absolutely necessary and ALWAYS validate permissions first.
#### ❌ Bad Pattern - Never Do This
```sql
CREATE FUNCTION dangerous_delete_all()
RETURNS void
SECURITY DEFINER AS $$
BEGIN
-- This bypasses ALL security!
DELETE FROM sensitive_table;
END;
$$ LANGUAGE plpgsql;
```
#### ✅ Good Pattern - Always Validate First
```sql
CREATE FUNCTION safe_admin_operation(target_account_id uuid)
RETURNS void
SECURITY DEFINER
SET search_path = '' AS $$
BEGIN
-- MUST validate permissions FIRST
IF NOT public.is_account_owner(target_account_id) THEN
RAISE EXCEPTION 'Access denied: insufficient permissions';
END IF;
-- Now safe to proceed
-- Your operation here
END;
$$ LANGUAGE plpgsql;
```
## Core Tables Explained
### Accounts Table
The heart of the multi-tenant system:
```sql
public.accounts (
id -- UUID: Account identifier
primary_owner_user_id -- UUID: Account owner (ref auth.users)
name -- String: Display name
slug -- String: URL slug (NULL for personal)
email -- String: Contact email
is_personal_account -- Boolean: Account type
picture_url -- String: Avatar URL
public_data -- JSONB: Public metadata
)
```
**Key Features**:
- Automatic slug generation for team accounts
- Conditional constraints based on account type
- Protected fields preventing unauthorized updates
- Cascade deletion for data cleanup
### Memberships Table
Links users to team accounts with roles:
```sql
public.accounts_memberships (
user_id -- UUID: Member's user ID
account_id -- UUID: Team account ID
account_role -- String: Role name (owner/member)
PRIMARY KEY (user_id, account_id)
)
```
**Key Features**:
- Composite primary key prevents duplicates
- Role-based access control
- Automatic owner membership on account creation
- Prevention of owner removal
### Roles and Permissions
Hierarchical permission system:
```sql
public.roles (
name -- String: Role identifier
hierarchy_level -- Integer: Permission level (lower = more access)
)
public.role_permissions (
role -- String: Role name
permission -- Enum: Specific permission
)
```
**Available Permissions**:
- `roles.manage` - Manage team roles
- `billing.manage` - Handle billing
- `settings.manage` - Update settings
- `members.manage` - Manage members
- `invites.manage` - Send invitations
## Billing Architecture
### Subscription Model
```sql
billing_customers (
account_id -- Account reference
customer_id -- Provider's customer ID
provider -- stripe/lemonsqueezy/paddle
)
subscriptions (
customer_id -- Billing customer
status -- active/canceled/past_due
period_starts_at -- Current period start
period_ends_at -- Current period end
)
subscription_items (
subscription_id -- Parent subscription
price_id -- Provider's price ID
quantity -- Seats or usage
type -- flat/per_seat/metered
)
```
## Advanced Features
### Invitation System
Secure, token-based invitations:
```sql
public.invitations (
email -- Invitee's email
account_id -- Target team
invite_token -- Secure random token
expires_at -- Expiration timestamp
role -- Assigned role
)
```
**Security Features**:
- Unique tokens per invitation
- Automatic expiration
- Role hierarchy validation
- Batch invitation support
Generally speaking, you don't need to use this internally unless you are customizing the invitation system.
**Use Cases**:
- Email verification
- Password reset
- Sensitive operations
- Account deletion
### Notifications
Multi-channel notification system:
```sql
public.notifications (
account_id -- Target account
channel -- in_app/email
type -- Notification category
dismissed -- Read status
expires_at -- Auto-cleanup
metadata -- Additional data
)
```
### Creating New Tables
```sql
-- 1. Create table with proper structure
CREATE TABLE if not exists public.your_table (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid REFERENCES accounts(id) ON DELETE CASCADE NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
created_by uuid REFERENCES auth.users(id),
-- your fields here
);
-- 2. Add comments for documentation
COMMENT ON TABLE public.your_table IS 'Description of your table';
COMMENT ON COLUMN public.your_table.account_id IS 'Account ownership';
-- 3. Create indexes for performance
CREATE INDEX idx_your_table_account_id ON public.your_table(account_id);
CREATE INDEX idx_your_table_created_at ON public.your_table(created_at DESC);
-- 4. Enable RLS (NEVER SKIP!)
ALTER TABLE public.your_table ENABLE ROW LEVEL SECURITY;
-- 5. Grant appropriate access
REVOKE ALL ON public.your_table FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.your_table TO authenticated;
-- 6. Create RLS policies
CREATE POLICY "your_table_select" ON public.your_table
FOR SELECT TO authenticated
USING (
account_id = (select auth.uid())
OR public.has_role_on_account(account_id)
);
CREATE POLICY "your_table_insert" ON public.your_table
FOR INSERT TO authenticated
WITH CHECK (
account_id = (select auth.uid())
OR public.has_permission(auth.uid(), account_id, 'your_feature.create')
);
```
### 3. Creating Views
```sql
-- Always use security invoker for views
CREATE VIEW public.your_view
WITH (security_invoker = true) AS
SELECT
t.*,
a.name as account_name
FROM your_table t
JOIN accounts a ON a.id = t.account_id;
-- Grant access
GRANT SELECT ON public.your_view TO authenticated;
```
{% alert type="warning" title="Security Invoker for Views" %}
Always use security invoker set to true for views.
{% /alert %}
### 4. Writing Triggers
```sql
-- Update timestamp trigger
CREATE TRIGGER update_your_table_updated_at
BEFORE UPDATE ON public.your_table
FOR EACH ROW
EXECUTE FUNCTION kit.trigger_set_timestamps();
-- Audit trigger
CREATE TRIGGER track_your_table_changes
BEFORE INSERT OR UPDATE ON public.your_table
FOR EACH ROW
EXECUTE FUNCTION kit.trigger_set_user_tracking();
```
### 5. Storage Security
When implementing file storage:
```sql
-- Create bucket with proper RLS
INSERT INTO storage.buckets (id, name, public)
VALUES ('your_files', 'your_files', false);
-- RLS policy validating account ownership
CREATE POLICY "your_files_policy" ON storage.objects
FOR ALL USING (
bucket_id = 'your_files'
AND public.has_role_on_account(
(storage.foldername(name))[1]::uuid
)
);
```
**Note:** The above assumes that `(storage.foldername(name))[1]::uuid` is the account id.
You can scope the account's files with the ID of the account, so that this RLS can protect the files from other accounts.
## Summary
Makerkit's database architecture provides:
- ✅ **Secure multi-tenancy** with RLS and permission checks
- ✅ **Flexible account types** for B2C and B2B use cases
- ✅ **Comprehensive billing** support for multiple providers
- ✅ **Built-in security** patterns and helper functions
- ✅ **Scalable design** with proper indexes and constraints
By following these patterns and best practices, you can confidently extend Makerkit's database while maintaining security, performance, and data integrity.
Remember: when in doubt, always err on the side of security and use the provided helper functions rather than creating custom solutions.

View File

@@ -0,0 +1,341 @@
---
status: "published"
label: "Database Functions"
order: 3
title: "PostgreSQL Database Functions for Multi-Tenant SaaS"
description: "Use built-in database functions for permissions, roles, subscriptions, and MFA checks. Includes has_permission, is_account_owner, has_active_subscription, and more."
---
Makerkit includes built-in PostgreSQL functions for common multi-tenant operations like permission checks, role verification, and subscription status. Use these functions in RLS policies and application code to enforce consistent security rules across your database.
{% sequence title="Database Functions Reference" description="Built-in functions for multi-tenant operations" %}
[Call functions from SQL and RPC](#calling-database-functions)
[Account ownership and membership](#account-functions)
[Permission checks](#permission-functions)
[Subscription and billing](#subscription-functions)
[MFA and authentication](#authentication-functions)
{% /sequence %}
## Calling Database Functions
### From SQL (RLS Policies)
Use functions directly in SQL schemas and RLS policies:
```sql
-- In an RLS policy
create policy "Users can view their projects"
on public.projects
for select
using (
public.has_role_on_account(account_id)
);
```
### From Application Code (RPC)
Call functions via Supabase RPC:
```tsx
const { data: isOwner, error } = await supabase.rpc('is_account_owner', {
account_id: accountId,
});
if (isOwner) {
// User owns this account
}
```
## Account Functions
### is_account_owner
Check if the current user owns an account. Returns `true` if the account is the user's personal account or if they created a team account.
```sql
public.is_account_owner(account_id uuid) returns boolean
```
**Use cases:**
- Restrict account deletion to owners
- Gate billing management
- Control team settings access
**Example RLS:**
```sql
create policy "Only owners can delete accounts"
on public.accounts
for delete
using (public.is_account_owner(id));
```
### has_role_on_account
Check if the current user has membership on an account, optionally with a specific role.
```sql
public.has_role_on_account(
account_id uuid,
account_role varchar(50) default null
) returns boolean
```
**Parameters:**
- `account_id`: The account to check
- `account_role`: Optional role name (e.g., `'owner'`, `'member'`). If omitted, returns `true` for any membership.
**Example RLS:**
```sql
-- Any member can view
create policy "Members can view projects"
on public.projects
for select
using (public.has_role_on_account(account_id));
-- Only owners can update
create policy "Owners can update projects"
on public.projects
for update
using (public.has_role_on_account(account_id, 'owner'));
```
### is_team_member
Check if a specific user is a member of a team account.
```sql
public.is_team_member(
account_id uuid,
user_id uuid
) returns boolean
```
**Use case:** Verify team membership when the current user context isn't available.
### can_action_account_member
Check if the current user can perform actions on another team member (remove, change role, etc.).
```sql
public.can_action_account_member(
target_team_account_id uuid,
target_user_id uuid
) returns boolean
```
**Logic:**
1. If current user is account owner: `true`
2. If target user is account owner: `false`
3. Otherwise: Compare role hierarchy levels
**Example:**
```tsx
const { data: canRemove } = await supabase.rpc('can_action_account_member', {
target_team_account_id: teamId,
target_user_id: memberId,
});
if (!canRemove) {
throw new Error('Cannot remove a user with equal or higher role');
}
```
## Permission Functions
### has_permission
Check if a user has a specific permission on an account. This is the primary function for granular access control.
```sql
public.has_permission(
user_id uuid,
account_id uuid,
permission_name app_permissions
) returns boolean
```
**Parameters:**
- `user_id`: The user to check (use `auth.uid()` for current user)
- `account_id`: The account context
- `permission_name`: A value from the `app_permissions` enum
**Default permissions:**
```sql
create type public.app_permissions as enum(
'roles.manage',
'billing.manage',
'settings.manage',
'members.manage',
'invites.manage'
);
```
**Example RLS:**
```sql
create policy "Users with tasks.write can insert tasks"
on public.tasks
for insert
with check (
public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)
);
```
**Example RPC:**
```tsx
async function checkTaskWritePermission(accountId: string) {
const { data: hasPermission } = await supabase.rpc('has_permission', {
user_id: (await supabase.auth.getUser()).data.user?.id,
account_id: accountId,
permission: 'tasks.write',
});
return hasPermission;
}
```
See [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) for adding custom permissions.
## Subscription Functions
### has_active_subscription
Check if an account has an active or trialing subscription.
```sql
public.has_active_subscription(account_id uuid) returns boolean
```
**Returns `true` when:**
- Subscription status is `active`
- Subscription status is `trialing`
**Returns `false` when:**
- No subscription exists
- Status is `canceled`, `past_due`, `unpaid`, `incomplete`, etc.
**Example RLS:**
```sql
create policy "Only paid accounts can create projects"
on public.projects
for insert
with check (
public.has_active_subscription(account_id)
);
```
**Example application code:**
```tsx
const { data: isPaid } = await supabase.rpc('has_active_subscription', {
account_id: accountId,
});
if (!isPaid) {
redirect('/pricing');
}
```
## Authentication Functions
### is_super_admin
Check if the current user is a super admin. Requires:
- User is authenticated
- User has `super_admin` role
- User is currently signed in with MFA (AAL2)
```sql
public.is_super_admin() returns boolean
```
**Example RLS:**
```sql
create policy "Super admins can view all accounts"
on public.accounts
for select
using (public.is_super_admin());
```
### is_mfa_compliant
Check if the current user meets MFA requirements. Returns `true` when:
- User enabled MFA and is signed in with MFA (AAL2)
- User has not enabled MFA (AAL1 is sufficient)
```sql
public.is_mfa_compliant() returns boolean
```
**Use case:** Allow users without MFA to continue normally while enforcing MFA for users who enabled it.
### is_aal2
Check if the current user is signed in with MFA (AAL2 authentication level).
```sql
public.is_aal2() returns boolean
```
**Use case:** Require MFA for sensitive operations regardless of user settings.
**Example:**
```sql
-- Require MFA for billing operations
create policy "MFA required for billing changes"
on public.billing_settings
for all
using (public.is_aal2());
```
## Configuration Functions
### is_set
Check if a configuration value is set in the `public.config` table.
```sql
public.is_set(field_name text) returns boolean
```
**Example:**
```sql
-- Check if a feature flag is enabled
select public.is_set('enable_new_dashboard');
```
## Function Reference Table
| Function | Purpose | Common Use |
|----------|---------|------------|
| `is_account_owner(account_id)` | Check account ownership | Delete, billing access |
| `has_role_on_account(account_id, role?)` | Check membership/role | View, edit access |
| `is_team_member(account_id, user_id)` | Check specific user membership | Team operations |
| `can_action_account_member(account_id, user_id)` | Check member management rights | Remove, role change |
| `has_permission(user_id, account_id, permission)` | Check granular permission | Feature access |
| `has_active_subscription(account_id)` | Check billing status | Paid features |
| `is_super_admin()` | Check admin status | Admin operations |
| `is_mfa_compliant()` | Check MFA compliance | Security policies |
| `is_aal2()` | Check MFA authentication | Sensitive operations |
## Related Resources
- [Permissions and Roles](/docs/next-supabase-turbo/development/permissions-and-roles) for adding custom permissions
- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for extending your schema
- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) for RLS patterns
- [Database Tests](/docs/next-supabase-turbo/development/database-tests) for testing database functions

View File

@@ -0,0 +1,542 @@
---
status: "published"
label: "Extending the DB Schema"
order: 2
title: "Extending the Database Schema in Next.js Supabase"
description: "Learn how to create new migrations and update the database schema in your Next.js Supabase application"
---
{% sequence title="Steps to create a new migration" description="Learn how to create new migrations and update the database schema in your Next.js Supabase application" %}
[Planning Your Schema Extension](#planning-your-schema-extension)
[Creating Schema Files](#creating-schema-files)
[Permissions and Access Control](#permissions-and-access-control)
[Building Tables with RLS](#building-tables-with-rls)
[Advanced Patterns](#advanced-patterns)
[Testing and Deployment](#testing-and-deployment)
{% /sequence %}
This guide walks you through extending Makerkit's database schema with new tables and features. We'll use a comprehensive example that demonstrates best practices, security patterns, and integration with Makerkit's multi-tenant architecture.
## Planning Your Schema Extension
Before writing any SQL, it's crucial to understand how your new features fit into Makerkit's multi-tenant architecture.
### Decision Framework
**Step 1: Determine Data Ownership**
Ask yourself: "Who owns this data - individual users or accounts?"
- **User-owned data**: Personal preferences, activity logs, user settings
- **Account-owned data**: Business content, shared resources, collaborative features
**Step 2: Define Access Patterns**
- **Public within account**: All team members can access
- **Private within account**: Only creator + specific permissions
- **Admin-only**: Requires special permissions or super admin access
**Step 3: Consider Integration Points**
- Does this feature affect billing? (usage tracking, feature gates)
- Does it need notifications? (in-app alerts, email triggers)
- Should it have audit trails? (compliance, change tracking)
## Creating Schema Files
Makerkit organizes database schema in numbered files for proper ordering. Follow this workflow:
### 1. Create Your Schema File
```bash
# Create a new schema file with the next number
touch apps/web/supabase/schemas/18-notes-feature.sql
```
### 2. Apply Development Workflow
```bash
# Start Supabase
pnpm supabase:web:start
# Create migration from your schema file
pnpm --filter web run supabase:db:diff -f notes-feature
# Restart with new schema
pnpm supabase:web:reset
# Generate TypeScript types
pnpm supabase:web:typegen
```
## Permissions and Access Control
### Adding New Permissions
Makerkit defines permissions in the `public.app_permissions` enum. Add feature-specific permissions:
```sql
-- Add new permissions for your feature
ALTER TYPE public.app_permissions ADD VALUE 'notes.create';
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
ALTER TYPE public.app_permissions ADD VALUE 'notes.delete';
COMMIT;
```
**Note:** The Supabase diff function does not support adding new permissions to enum types. Please add the new permissions manually instead of using the diff function.
**Permission Naming Convention**: Use the pattern `resource.action` for consistency:
- `notes.create` - Create new notes
- `notes.manage` - Edit existing notes
- `notes.delete` - Delete notes
- `notes.share` - Share with external users
### Role Assignment
Consider which roles should have which permissions by default:
```sql
-- Grant permissions to roles
INSERT INTO public.role_permissions (role, permission) VALUES
('owner', 'notes.create'),
('owner', 'notes.manage'),
('owner', 'notes.delete'),
('owner', 'notes.share'),
('member', 'notes.create'),
('member', 'notes.manage');
```
## Building Tables with RLS
Let's create a comprehensive notes feature that demonstrates various patterns and best practices.
### Core Notes Table
```sql
-- Create the main notes table with all standard fields
CREATE TABLE IF NOT EXISTS public.notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
title varchar(500) NOT NULL,
content text,
is_published boolean NOT NULL DEFAULT false,
tags text[] DEFAULT '{}',
metadata jsonb DEFAULT '{}',
-- Audit fields (always include these)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES auth.users(id),
updated_by uuid REFERENCES auth.users(id),
-- Data integrity constraints
CONSTRAINT notes_title_length CHECK (length(title) >= 1),
CONSTRAINT notes_account_required CHECK (account_id IS NOT NULL)
);
-- Add helpful comments for documentation
COMMENT ON TABLE public.notes IS 'User-generated notes with sharing capabilities';
COMMENT ON COLUMN public.notes.account_id IS 'Account that owns this note (multi-tenant isolation)';
COMMENT ON COLUMN public.notes.is_published IS 'Whether note is visible to all account members';
COMMENT ON COLUMN public.notes.tags IS 'Searchable tags for categorization';
COMMENT ON COLUMN public.notes.metadata IS 'Flexible metadata (view preferences, etc.)';
```
### Performance Indexes
Consider creating indexes for your query patterns if you are scaling to a large number of records.
```sql
-- Essential indexes for performance
CREATE INDEX idx_notes_account_id ON public.notes(account_id);
CREATE INDEX idx_notes_created_at ON public.notes(created_at DESC);
CREATE INDEX idx_notes_account_created ON public.notes(account_id, created_at DESC);
CREATE INDEX idx_notes_published ON public.notes(account_id, is_published) WHERE is_published = true;
CREATE INDEX idx_notes_tags ON public.notes USING gin(tags);
```
### Security Setup
```sql
-- Always enable RLS (NEVER skip this!)
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
-- Revoke default permissions and grant explicitly
REVOKE ALL ON public.notes FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated, service_role;
```
### RLS Policies
Create comprehensive policies that handle both personal and team accounts:
```sql
-- SELECT policy: Read published notes or own private notes
CREATE POLICY "notes_select" ON public.notes
FOR SELECT TO authenticated
USING (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: member can read published notes
(public.has_role_on_account(account_id) AND is_published = true)
OR
-- Team account: creator can read their own drafts
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
-- Team account: users with manage permission can read all
public.has_permission(auth.uid(), account_id, 'notes.manage')
);
-- INSERT policy: Must have create permission
CREATE POLICY "notes_insert" ON public.notes
FOR INSERT TO authenticated
WITH CHECK (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: must have create permission
public.has_permission(auth.uid(), account_id, 'notes.create')
);
-- UPDATE policy: Owner or manager can edit
CREATE POLICY "notes_update" ON public.notes
FOR UPDATE TO authenticated
USING (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: creator can edit their own
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
-- Team account: users with manage permission
public.has_permission(auth.uid(), account_id, 'notes.manage')
)
WITH CHECK (
-- Same conditions for updates
account_id = (SELECT auth.uid())
OR
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
public.has_permission(auth.uid(), account_id, 'notes.manage')
);
-- DELETE policy: Stricter permissions required
CREATE POLICY "notes_delete" ON public.notes
FOR DELETE TO authenticated
USING (
-- Personal account: direct ownership
account_id = (SELECT auth.uid())
OR
-- Team account: creator can delete own notes
(public.has_role_on_account(account_id) AND created_by = auth.uid())
OR
-- Team account: users with delete permission
public.has_permission(auth.uid(), account_id, 'notes.delete')
);
```
### Automatic Triggers
Add triggers for common patterns:
```sql
-- Automatically update timestamps
CREATE TRIGGER notes_updated_at
BEFORE UPDATE ON public.notes
FOR EACH ROW
EXECUTE FUNCTION kit.trigger_set_timestamps();
-- Track who made changes
CREATE TRIGGER notes_track_changes
BEFORE INSERT OR UPDATE ON public.notes
FOR EACH ROW
EXECUTE FUNCTION kit.trigger_set_user_tracking();
```
## Advanced Patterns
### 1. Hierarchical Notes (Categories)
```sql
-- Note categories with hierarchy
CREATE TABLE IF NOT EXISTS public.note_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
parent_id uuid REFERENCES public.note_categories(id) ON DELETE CASCADE,
name varchar(255) NOT NULL,
color varchar(7), -- hex color codes
path ltree, -- efficient tree operations
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES auth.users(id),
-- Ensure hierarchy stays within account
CONSTRAINT categories_same_account CHECK (
parent_id IS NULL OR
(SELECT account_id FROM public.note_categories WHERE id = parent_id) = account_id
),
-- Prevent circular references
CONSTRAINT categories_no_self_parent CHECK (id != parent_id)
);
-- Link notes to categories
ALTER TABLE public.notes ADD COLUMN category_id uuid REFERENCES public.note_categories(id) ON DELETE SET NULL;
-- Index for tree operations
CREATE INDEX idx_note_categories_path ON public.note_categories USING gist(path);
CREATE INDEX idx_note_categories_account ON public.note_categories(account_id, parent_id);
```
### 2. Note Sharing and Collaboration
```sql
-- External sharing tokens
CREATE TABLE IF NOT EXISTS public.note_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
note_id uuid NOT NULL REFERENCES public.notes(id) ON DELETE CASCADE,
share_token varchar(64) NOT NULL UNIQUE,
expires_at timestamptz,
password_hash varchar(255), -- optional password protection
view_count integer DEFAULT 0,
max_views integer, -- optional view limit
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES auth.users(id),
-- Ensure token uniqueness
CONSTRAINT share_token_format CHECK (share_token ~ '^[a-zA-Z0-9_-]{32,64}$')
);
-- Function to generate secure share tokens
CREATE OR REPLACE FUNCTION generate_note_share_token()
RETURNS varchar(64) AS $$
BEGIN
RETURN encode(gen_random_bytes(32), 'base64url');
END;
$$ LANGUAGE plpgsql;
```
### 3. Usage Tracking for Billing
```sql
-- Track note creation for usage-based billing
CREATE TABLE IF NOT EXISTS public.note_usage_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
action varchar(50) NOT NULL, -- 'create', 'share', 'export'
note_count integer DEFAULT 1,
date date DEFAULT CURRENT_DATE,
-- Daily aggregation
UNIQUE(account_id, action, date)
);
-- Function to track note usage
CREATE OR REPLACE FUNCTION track_note_usage(
target_account_id uuid,
usage_action varchar(50)
) RETURNS void AS $$
BEGIN
INSERT INTO public.note_usage_logs (account_id, action, note_count)
VALUES (target_account_id, usage_action, 1)
ON CONFLICT (account_id, action, date)
DO UPDATE SET note_count = note_usage_logs.note_count + 1;
END;
$$ LANGUAGE plpgsql;
-- Trigger to track note creation
CREATE OR REPLACE FUNCTION trigger_track_note_creation()
RETURNS trigger AS $$
BEGIN
PERFORM track_note_usage(NEW.account_id, 'create');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER notes_track_creation
AFTER INSERT ON public.notes
FOR EACH ROW
EXECUTE FUNCTION trigger_track_note_creation();
```
### 4. Feature Access Control
```sql
-- Check if account has access to advanced note features
CREATE OR REPLACE FUNCTION has_advanced_notes_access(target_account_id uuid)
RETURNS boolean AS $$
DECLARE
has_access boolean := false;
BEGIN
-- Check active subscription with advanced features
SELECT EXISTS(
SELECT 1
FROM public.subscriptions s
JOIN public.subscription_items si ON s.id = si.subscription_id
WHERE s.account_id = target_account_id
AND s.status = 'active'
AND si.price_id IN ('price_pro_plan', 'price_enterprise_plan')
) INTO has_access;
RETURN has_access;
END;
$$ LANGUAGE plpgsql;
-- Restrictive policy for advanced features
CREATE POLICY "notes_advanced_features" ON public.notes
AS RESTRICTIVE
FOR ALL TO authenticated
USING (
-- Basic features always allowed
is_published = true
OR category_id IS NULL
OR tags = '{}'
OR
-- Advanced features require subscription
has_advanced_notes_access(account_id)
);
```
## Security Enhancements
### MFA Compliance
For sensitive note operations, enforce MFA:
```sql
-- Require MFA for note deletion
CREATE POLICY "notes_delete_mfa" ON public.notes
AS RESTRICTIVE
FOR DELETE TO authenticated
USING (public.is_mfa_compliant());
```
### Super Admin Access
Allow super admins to access all notes for support purposes:
```sql
-- Super admin read access (for support)
CREATE POLICY "notes_super_admin_access" ON public.notes
FOR SELECT TO authenticated
USING (public.is_super_admin());
```
### Rate Limiting
Implement basic rate limiting for note creation:
```sql
-- Rate limiting: max 100 notes per day per account
CREATE OR REPLACE FUNCTION check_note_creation_limit(target_account_id uuid)
RETURNS boolean AS $$
DECLARE
daily_count integer;
BEGIN
SELECT COALESCE(note_count, 0) INTO daily_count
FROM public.note_usage_logs
WHERE account_id = target_account_id
AND action = 'create'
AND date = CURRENT_DATE;
RETURN daily_count < 100; -- Adjust limit as needed
END;
$$ LANGUAGE plpgsql;
-- Policy to enforce rate limiting
CREATE POLICY "notes_rate_limit" ON public.notes
AS RESTRICTIVE
FOR INSERT TO authenticated
WITH CHECK (check_note_creation_limit(account_id));
```
### Type Generation
After schema changes, always update TypeScript types:
```bash
# reset the database
pnpm supabase:web:reset
# Generate new types
pnpm supabase:web:typegen
# Verify types work in your application
pnpm typecheck
```
## Example Usage in Application
With your schema complete, here's how to use it in your application:
```typescript
// Server component - automatically inherits RLS protection
import { getSupabaseServerClient } from '@kit/supabase/server-client';
async function NotesPage({ params }: { params: Promise<{ account: string }> }) {
const { account } = await params;
const client = getSupabaseServerClient();
// RLS automatically filters to accessible notes
const { data: notes } = await client
.from('notes')
.select(`
*,
category:note_categories(name, color),
creator:created_by(name, avatar_url)
`)
.eq('account_id', params.account)
.order('created_at', { ascending: false });
return <NotesList notes={notes} />;
}
```
From the client component, you can use the `useQuery` hook to fetch the notes.
```typescript
// Client component with real-time updates
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
function useNotes(accountId: string) {
const supabase = useSupabase();
return useQuery({
queryKey: ['notes', accountId],
queryFn: async () => {
const { data } = await supabase
.from('notes')
.select('*, category:note_categories(name)')
.eq('account_id', accountId);
return data;
}
});
}
```
## Summary
You've now created a comprehensive notes feature that demonstrates:
✅ **Proper multi-tenancy** with account-based data isolation
✅ **Granular permissions** using Makerkit's role system
✅ **Advanced features** like categories, sharing, and usage tracking
✅ **Security best practices** with comprehensive RLS policies
✅ **Performance optimization** with proper indexing
✅ **Integration patterns** with billing and feature gates
This pattern can be adapted for any feature in your SaaS application. Remember to always:
- Start with proper planning and data ownership decisions
- Enable RLS and create comprehensive policies
- Add appropriate indexes for your query patterns
- Test thoroughly before deploying
- Update TypeScript types after schema changes
Your database schema is now production-ready and follows Makerkit's security and architecture best practices!

View File

@@ -0,0 +1,961 @@
---
status: "published"
label: "Database tests"
title: "Database Testing with pgTAP"
description: "Learn how to write comprehensive database tests using pgTAP to secure your application against common vulnerabilities"
order: 12
---
Database testing is critical for ensuring your application's security and data integrity. This guide covers how to write comprehensive database tests using **pgTAP** and **Basejump utilities** to protect against common vulnerabilities.
## Why Database Testing Matters
Database tests verify that your **Row Level Security (RLS)** policies work correctly and protect against:
- **Unauthorized data access** - Users reading data they shouldn't see
- **Data modification attacks** - Users updating/deleting records they don't own
- **Privilege escalation** - Users gaining higher permissions than intended
- **Cross-account data leaks** - Team members accessing other teams' data
- **Storage security bypasses** - Unauthorized file access
## Test Infrastructure
### Required Extensions
Makerkit uses these extensions for database testing:
```sql
-- Install Basejump test helpers
create extension "basejump-supabase_test_helpers" version '0.0.6';
-- The extension provides authentication simulation
-- and user management utilities
```
### Makerkit Test Helpers
The `/apps/web/supabase/tests/database/00000-makerkit-helpers.sql` file provides essential utilities:
```sql
-- Authenticate as a test user
select makerkit.authenticate_as('user_identifier');
-- Get account by slug
select makerkit.get_account_by_slug('team-slug');
-- Get account ID by slug
select makerkit.get_account_id_by_slug('team-slug');
-- Set user as super admin
select makerkit.set_super_admin();
-- Set MFA level
select makerkit.set_session_aal('aal1'); -- or 'aal2'
```
## Test Structure
Every pgTAP test follows this structure:
```sql
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan(); -- or select plan(N) for exact test count
-- Test setup
select makerkit.set_identifier('user1', 'user1@example.com');
select makerkit.set_identifier('user2', 'user2@example.com');
-- Your tests here
select is(
actual_result,
expected_result,
'Test description'
);
select * from finish();
rollback;
```
## Bypassing RLS in Tests
When you need to set up test data or verify data exists independently of RLS policies, use role switching:
### Role Types for Testing
```sql
-- postgres: Full superuser access, bypasses all RLS
set local role postgres;
-- service_role: Service-level access, bypasses RLS
set local role service_role;
-- authenticated: Normal user with RLS enforced (default for makerkit.authenticate_as)
-- No need to set this explicitly - makerkit.authenticate_as() handles it
```
### Common Patterns for Role Switching
#### Pattern 1: Setup Test Data
```sql
-- Use postgres role to insert test data that bypasses RLS
set local role postgres;
insert into accounts_memberships (account_id, user_id, account_role)
values (team_id, user_id, 'member');
-- Test as normal user (RLS enforced)
select makerkit.authenticate_as('member');
select isnt_empty($$ select * from team_data $$, 'Member can see team data');
```
#### Pattern 2: Verify Data Exists
```sql
-- Test that unauthorized user cannot see data
select makerkit.authenticate_as('unauthorized_user');
select is_empty($$ select * from private_data $$, 'Unauthorized user sees nothing');
-- Use postgres role to verify data actually exists
set local role postgres;
select isnt_empty($$ select * from private_data $$, 'Data exists (confirms RLS filtering)');
```
#### Pattern 3: Grant Permissions for Testing
```sql
-- Use postgres role to grant permissions
set local role postgres;
insert into role_permissions (role, permission)
values ('custom-role', 'invites.manage');
-- Test as user with the role
select makerkit.authenticate_as('custom_role_user');
select lives_ok($$ select create_invitation(...) $$, 'User with permission can invite');
```
### When to Use Each Role
#### Use `postgres` role when:
- Setting up complex test data with foreign key relationships
- Inserting data that would normally be restricted by RLS
- Verifying data exists independently of user permissions
- Modifying system tables (roles, permissions, etc.)
#### Use `service_role` when:
- You need RLS bypass but want to stay closer to application-level permissions
- Testing service-level operations
- Working with data that should be accessible to services but not users
#### Use `makerkit.authenticate_as()` when:
- Testing normal user operations (automatically sets `authenticated` role)
- Verifying RLS policies work correctly
- Testing user-specific access patterns
### Complete Test Example
```sql
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- Setup test users
select makerkit.set_identifier('owner', 'owner@example.com');
select makerkit.set_identifier('member', 'member@example.com');
select makerkit.set_identifier('stranger', 'stranger@example.com');
-- Create team (as owner)
select makerkit.authenticate_as('owner');
select public.create_team_account('TestTeam');
-- Add member using postgres role (bypasses RLS)
set local role postgres;
insert into accounts_memberships (account_id, user_id, account_role)
values (
(select id from accounts where slug = 'testteam'),
tests.get_supabase_uid('member'),
'member'
);
-- Test member access (RLS enforced)
select makerkit.authenticate_as('member');
select isnt_empty(
$$ select * from accounts where slug = 'testteam' $$,
'Member can see their team'
);
-- Test stranger cannot see team (RLS enforced)
select makerkit.authenticate_as('stranger');
select is_empty(
$$ select * from accounts where slug = 'testteam' $$,
'Stranger cannot see team due to RLS'
);
-- Verify team actually exists (bypass RLS)
set local role postgres;
select isnt_empty(
$$ select * from accounts where slug = 'testteam' $$,
'Team exists in database (confirms RLS is working, not missing data)'
);
select * from finish();
rollback;
```
### Key Principles
1. **Use `postgres` role for test setup**, then switch back to test actual user permissions
2. **Always verify data exists** using `postgres` role when testing that users cannot see data
3. **Never test application logic as `postgres`** - it bypasses all security
4. **Use role switching to confirm RLS is filtering**, not that data is missing
## Basic Security Testing Patterns
### 1. Testing Data Isolation
Verify users can only access their own data:
```sql
-- Create test users
select makerkit.set_identifier('owner', 'owner@example.com');
select tests.create_supabase_user('stranger', 'stranger@example.com');
-- Owner creates a record
select makerkit.authenticate_as('owner');
insert into notes (title, content, user_id)
values ('Secret Note', 'Private content', auth.uid());
-- Stranger cannot see the record
select makerkit.authenticate_as('stranger');
select is_empty(
$$ select * from notes where title = 'Secret Note' $$,
'Strangers cannot see other users notes'
);
```
### 2. Testing Write Protection
Ensure users cannot modify others' data:
```sql
-- Owner creates a record
select makerkit.authenticate_as('owner');
insert into posts (title, user_id)
values ('My Post', auth.uid()) returning id;
-- Store the post ID for testing
\set post_id (select id from posts where title = 'My Post')
-- Stranger cannot update the record
select makerkit.authenticate_as('stranger');
select throws_ok(
$$ update posts set title = 'Hacked!' where id = :post_id $$,
'update or delete on table "posts" violates row-level security policy',
'Strangers cannot update other users posts'
);
```
### 3. Testing Permission Systems
Verify role-based access control:
```sql
-- Test that only users with 'posts.manage' permission can create posts
select makerkit.authenticate_as('member');
select throws_ok(
$$ insert into admin_posts (title, content) values ('Test', 'Content') $$,
'new row violates row-level security policy',
'Members without permission cannot create admin posts'
);
-- Grant permission and test again
set local role postgres;
insert into user_permissions (user_id, permission)
values (tests.get_supabase_uid('member'), 'posts.manage');
select makerkit.authenticate_as('member');
select lives_ok(
$$ insert into admin_posts (title, content) values ('Test', 'Content') $$,
'Members with permission can create admin posts'
);
```
## Team Account Security Testing
### Testing Team Membership Access
```sql
-- Setup team and members
select makerkit.authenticate_as('owner');
select public.create_team_account('TestTeam');
-- Add member to team
set local role postgres;
insert into accounts_memberships (account_id, user_id, account_role)
values (
makerkit.get_account_id_by_slug('testteam'),
tests.get_supabase_uid('member'),
'member'
);
-- Test member can see team data
select makerkit.authenticate_as('member');
select isnt_empty(
$$ select * from team_posts where account_id = makerkit.get_account_id_by_slug('testteam') $$,
'Team members can see team posts'
);
-- Test non-members cannot see team data
select makerkit.authenticate_as('stranger');
select is_empty(
$$ select * from team_posts where account_id = makerkit.get_account_id_by_slug('testteam') $$,
'Non-members cannot see team posts'
);
```
### Testing Role Hierarchy
```sql
-- Test that members cannot promote themselves
select makerkit.authenticate_as('member');
select throws_ok(
$$ update accounts_memberships
set account_role = 'owner'
where user_id = auth.uid() $$,
'Only the account_role can be updated',
'Members cannot promote themselves to owner'
);
-- Test that members cannot remove the owner
select throws_ok(
$$ delete from accounts_memberships
where user_id = tests.get_supabase_uid('owner')
and account_id = makerkit.get_account_id_by_slug('testteam') $$,
'The primary account owner cannot be removed from the account membership list',
'Members cannot remove the account owner'
);
```
## Storage Security Testing
```sql
-- Test file access control
select makerkit.authenticate_as('user1');
-- User can upload to their own folder
select lives_ok(
$$ insert into storage.objects (bucket_id, name, owner, owner_id)
values ('avatars', auth.uid()::text, auth.uid(), auth.uid()) $$,
'Users can upload files with their own UUID as filename'
);
-- User cannot upload using another user's UUID as filename
select makerkit.authenticate_as('user2');
select throws_ok(
$$ insert into storage.objects (bucket_id, name, owner, owner_id)
values ('avatars', tests.get_supabase_uid('user1')::text, auth.uid(), auth.uid()) $$,
'new row violates row-level security policy',
'Users cannot upload files with other users UUIDs as filename'
);
```
## Common Testing Patterns
### 1. Cross-Account Data Isolation
```sql
-- Verify team A members cannot access team B data
select makerkit.authenticate_as('team_a_member');
insert into documents (title, team_id)
values ('Secret Doc', makerkit.get_account_id_by_slug('team-a'));
select makerkit.authenticate_as('team_b_member');
select is_empty(
$$ select * from documents where title = 'Secret Doc' $$,
'Team B members cannot see Team A documents'
);
```
### 2. Function Security Testing
```sql
-- Test that protected functions check permissions
select makerkit.authenticate_as('regular_user');
select throws_ok(
$$ select admin_delete_all_posts() $$,
'permission denied for function admin_delete_all_posts',
'Regular users cannot call admin functions'
);
-- Test with proper permissions
select makerkit.set_super_admin();
select lives_ok(
$$ select admin_delete_all_posts() $$,
'Super admins can call admin functions'
);
```
### 3. Invitation Security Testing
```sql
-- Test invitation creation permissions
select makerkit.authenticate_as('member');
-- Members can invite to same or lower roles
select lives_ok(
$$ insert into invitations (email, account_id, role, invite_token)
values ('new@example.com', makerkit.get_account_id_by_slug('team'), 'member', gen_random_uuid()) $$,
'Members can invite other members'
);
-- Members cannot invite to higher roles
select throws_ok(
$$ insert into invitations (email, account_id, role, invite_token)
values ('admin@example.com', makerkit.get_account_id_by_slug('team'), 'owner', gen_random_uuid()) $$,
'new row violates row-level security policy',
'Members cannot invite owners'
);
```
## Advanced Testing Techniques
### 1. Testing Edge Cases
```sql
-- Test NULL handling in RLS policies
select lives_ok(
$$ select * from posts where user_id IS NULL $$,
'Queries with NULL filters should not crash'
);
-- Test empty result sets
select is_empty(
$$ select * from posts where user_id = '00000000-0000-0000-0000-000000000000'::uuid $$,
'Invalid UUIDs should return empty results'
);
```
### 2. Performance Testing
```sql
-- Test that RLS policies don't create N+1 queries
select makerkit.authenticate_as('team_owner');
-- This should be efficient even with many team members
select isnt_empty(
$$ select p.*, u.name from posts p join users u on p.user_id = u.id
where p.team_id = makerkit.get_account_id_by_slug('large-team') $$,
'Joined queries with RLS should perform well'
);
```
### 3. Testing Trigger Security
```sql
-- Test that triggers properly validate permissions
select makerkit.authenticate_as('regular_user');
select throws_ok(
$$ update sensitive_settings set admin_only_field = 'hacked' $$,
'You do not have permission to update this field',
'Triggers should prevent unauthorized field updates'
);
```
## Best Practices
### 1. Always Test Both Positive and Negative Cases
- Verify authorized users CAN access data
- Verify unauthorized users CANNOT access data
### 2. Test All CRUD Operations
- CREATE: Can users insert the records they should?
- READ: Can users only see their authorized data?
- UPDATE: Can users only modify records they own?
- DELETE: Can users only remove their own records?
### 3. Use Descriptive Test Names
```sql
select is(
actual_result,
expected_result,
'Team members should be able to read team posts but not modify other teams data'
);
```
### 4. Test Permission Boundaries
- Test the minimum permission level that grants access
- Test that one level below is denied
- Test that users with higher permissions can also access
### 5. Clean Up After Tests
Always use transactions that rollback:
```sql
begin;
-- Your tests here
rollback; -- This cleans up all test data
```
## Common Anti-Patterns to Avoid
❌ **Don't test only happy paths**
```sql
-- Bad: Only testing that authorized access works
select isnt_empty($$ select * from posts $$, 'User can see posts');
```
✅ **Test both authorized and unauthorized access**
```sql
-- Good: Test both positive and negative cases
select makerkit.authenticate_as('owner');
select isnt_empty($$ select * from posts where user_id = auth.uid() $$, 'Owner can see own posts');
select makerkit.authenticate_as('stranger');
select is_empty($$ select * from posts where user_id != auth.uid() $$, 'Stranger cannot see others posts');
```
❌ **Don't forget to test cross-account scenarios**
```sql
-- Bad: Only testing within same account
select lives_ok($$ insert into team_docs (title) values ('Doc') $$, 'Can create doc');
```
✅ **Test cross-account isolation**
```sql
-- Good: Test that team A cannot access team B data
select makerkit.authenticate_as('team_a_member');
insert into team_docs (title, team_id) values ('Secret', team_a_id);
select makerkit.authenticate_as('team_b_member');
select is_empty($$ select * from team_docs where title = 'Secret' $$, 'Team B cannot see Team A docs');
```
## Testing Silent RLS Failures
**Critical Understanding**: RLS policies often fail **silently**. They don't throw errors - they just filter out data or prevent operations. This makes testing RLS policies tricky because you need to verify what **didn't** happen, not just what did.
### Why RLS Failures Are Silent
```sql
-- RLS policies work by:
-- 1. INSERT/UPDATE: If the policy check fails, the operation is ignored (no error)
-- 2. SELECT: If the policy fails, rows are filtered out (no error)
-- 3. DELETE: If the policy fails, nothing is deleted (no error)
```
### Testing Silent SELECT Filtering
When RLS policies prevent users from seeing data, queries return empty results instead of errors:
```sql
-- Setup: Create posts for different users
select makerkit.authenticate_as('user_a');
insert into posts (title, content, user_id)
values ('User A Post', 'Content A', auth.uid());
select makerkit.authenticate_as('user_b');
insert into posts (title, content, user_id)
values ('User B Post', 'Content B', auth.uid());
-- Test: User A cannot see User B's posts (silent filtering)
select makerkit.authenticate_as('user_a');
select is_empty(
$$ select * from posts where title = 'User B Post' $$,
'User A cannot see User B posts due to RLS filtering'
);
-- Test: User A can still see their own posts
select isnt_empty(
$$ select * from posts where title = 'User A Post' $$,
'User A can see their own posts'
);
-- Critical: Verify the post actually exists by switching context
select makerkit.authenticate_as('user_b');
select isnt_empty(
$$ select * from posts where title = 'User B Post' $$,
'User B post actually exists (not a test data issue)'
);
```
### Testing Silent UPDATE/DELETE Prevention
RLS policies can silently prevent modifications without throwing errors:
```sql
-- Setup: User A creates a post
select makerkit.authenticate_as('user_a');
insert into posts (title, content, user_id)
values ('Original Title', 'Original Content', auth.uid())
returning id;
-- Store the post ID for testing
\set post_id (select id from posts where title = 'Original Title')
-- Test: User B attempts to modify User A's post (silently fails)
select makerkit.authenticate_as('user_b');
update posts set title = 'Hacked Title' where id = :post_id;
-- Verify the update was silently ignored
select makerkit.authenticate_as('user_a');
select is(
(select title from posts where id = :post_id),
'Original Title',
'User B update attempt was silently ignored by RLS'
);
-- Test: User B attempts to delete User A's post (silently fails)
select makerkit.authenticate_as('user_b');
delete from posts where id = :post_id;
-- Verify the delete was silently ignored
select makerkit.authenticate_as('user_a');
select isnt_empty(
$$ select * from posts where title = 'Original Title' $$,
'User B delete attempt was silently ignored by RLS'
);
```
### Testing Silent INSERT Prevention
INSERT operations can also fail silently with restrictive RLS policies:
```sql
-- Test: Non-admin tries to insert into admin_settings table
select makerkit.authenticate_as('regular_user');
-- Attempt to insert (may succeed but be silently filtered on read)
insert into admin_settings (key, value) values ('test_key', 'test_value');
-- Critical: Don't just check for errors - verify the data isn't there
select is_empty(
$$ select * from admin_settings where key = 'test_key' $$,
'Regular user cannot insert admin settings (silent prevention)'
);
-- Verify an admin can actually insert this data
set local role postgres;
insert into admin_settings (key, value) values ('admin_key', 'admin_value');
select makerkit.set_super_admin();
select isnt_empty(
$$ select * from admin_settings where key = 'admin_key' $$,
'Admins can insert admin settings (confirms table works)'
);
```
### Testing Row-Level Filtering with Counts
Use count comparisons to detect silent filtering:
```sql
-- Setup: Create team data
select makerkit.authenticate_as('team_owner');
insert into team_documents (title, team_id) values
('Doc 1', (select id from accounts where slug = 'team-a')),
('Doc 2', (select id from accounts where slug = 'team-a')),
('Doc 3', (select id from accounts where slug = 'team-a'));
-- Test: Team member sees all team docs
select makerkit.authenticate_as('team_member_a');
select is(
(select count(*) from team_documents where team_id = (select id from accounts where slug = 'team-a')),
3::bigint,
'Team member can see all team documents'
);
-- Test: Non-member sees no team docs (silent filtering)
select makerkit.authenticate_as('external_user');
select is(
(select count(*) from team_documents where team_id = (select id from accounts where slug = 'team-a')),
0::bigint,
'External user cannot see any team documents due to RLS filtering'
);
```
### Testing Partial Data Exposure
Sometimes RLS policies expose some fields but not others:
```sql
-- Test: Public can see user profiles but not sensitive data
select tests.create_supabase_user('public_user', 'public@example.com');
-- Create user profile with sensitive data
select makerkit.authenticate_as('profile_owner');
insert into user_profiles (user_id, name, email, phone, ssn) values
(auth.uid(), 'John Doe', 'john@example.com', '555-1234', '123-45-6789');
-- Test: Public can see basic info but not sensitive fields
select makerkit.authenticate_as('public_user');
select is(
(select name from user_profiles where user_id = tests.get_supabase_uid('profile_owner')),
'John Doe',
'Public can see user name'
);
-- Critical: Test that sensitive fields are silently filtered
select is(
(select ssn from user_profiles where user_id = tests.get_supabase_uid('profile_owner')),
null,
'Public cannot see SSN (silently filtered by RLS)'
);
select is(
(select phone from user_profiles where user_id = tests.get_supabase_uid('profile_owner')),
null,
'Public cannot see phone number (silently filtered by RLS)'
);
```
### Testing Cross-Account Data Isolation
Verify users cannot access other accounts' data:
```sql
-- Setup: Create data for multiple teams
select makerkit.authenticate_as('team_a_owner');
insert into billing_info (team_id, subscription_id) values
((select id from accounts where slug = 'team-a'), 'sub_123');
select makerkit.authenticate_as('team_b_owner');
insert into billing_info (team_id, subscription_id) values
((select id from accounts where slug = 'team-b'), 'sub_456');
-- Test: Team A members cannot see Team B billing (silent filtering)
select makerkit.authenticate_as('team_a_member');
select is_empty(
$$ select * from billing_info where subscription_id = 'sub_456' $$,
'Team A members cannot see Team B billing info'
);
-- Test: Team A members can see their own billing
select isnt_empty(
$$ select * from billing_info where subscription_id = 'sub_123' $$,
'Team A members can see their own billing info'
);
-- Verify both billing records actually exist
set local role postgres;
select is(
(select count(*) from billing_info),
2::bigint,
'Both billing records exist in database (not a test data issue)'
);
```
### Testing Permission Boundary Edge Cases
Test the exact boundaries where permissions change:
```sql
-- Setup users with different permission levels
select makerkit.authenticate_as('admin_user');
select makerkit.authenticate_as('editor_user');
select makerkit.authenticate_as('viewer_user');
-- Test: Admins can see all data
select makerkit.authenticate_as('admin_user');
select isnt_empty(
$$ select * from sensitive_documents $$,
'Admins can see sensitive documents'
);
-- Test: Editors cannot see sensitive docs (silent filtering)
select makerkit.authenticate_as('editor_user');
select is_empty(
$$ select * from sensitive_documents $$,
'Editors cannot see sensitive documents due to RLS'
);
-- Test: Viewers cannot see sensitive docs (silent filtering)
select makerkit.authenticate_as('viewer_user');
select is_empty(
$$ select * from sensitive_documents $$,
'Viewers cannot see sensitive documents due to RLS'
);
```
### Testing Multi-Condition RLS Policies
When RLS policies have multiple conditions, test each condition:
```sql
-- Policy example: Users can only see posts if they are:
-- 1. The author, OR
-- 2. A team member of the author's team, AND
-- 3. The post is published
-- Test condition 1: Author can see unpublished posts
select makerkit.authenticate_as('author');
insert into posts (title, published, user_id) values
('Draft Post', false, auth.uid());
select isnt_empty(
$$ select * from posts where title = 'Draft Post' $$,
'Authors can see their own unpublished posts'
);
-- Test condition 2: Team members cannot see unpublished posts (silent filtering)
select makerkit.authenticate_as('team_member');
select is_empty(
$$ select * from posts where title = 'Draft Post' $$,
'Team members cannot see unpublished posts from teammates'
);
-- Test condition 3: Team members can see published posts
select makerkit.authenticate_as('author');
update posts set published = true where title = 'Draft Post';
select makerkit.authenticate_as('team_member');
select isnt_empty(
$$ select * from posts where title = 'Draft Post' $$,
'Team members can see published posts from teammates'
);
-- Test condition boundary: Non-team members cannot see any posts
select makerkit.authenticate_as('external_user');
select is_empty(
$$ select * from posts where title = 'Draft Post' $$,
'External users cannot see any posts (even published ones)'
);
```
### Common Silent Failure Patterns to Test
#### 1. The "Empty Result" Pattern
```sql
-- Always test that restricted queries return empty results, not errors
select is_empty(
$$ select * from restricted_table where condition = true $$,
'Unauthorized users see empty results, not errors'
);
```
#### 2. The "No-Effect" Pattern
```sql
-- Test that unauthorized modifications have no effect
update restricted_table set field = 'hacked' where id = target_id;
select is(
(select field from restricted_table where id = target_id),
'original_value',
'Unauthorized updates are silently ignored'
);
```
#### 3. The "Partial Visibility" Pattern
```sql
-- Test that only authorized fields are visible
select is(
(select public_field from mixed_table where id = target_id),
'visible_value',
'Public fields are visible'
);
select is(
(select private_field from mixed_table where id = target_id),
null,
'Private fields are silently filtered out'
);
```
#### 4. The "Context Switch" Verification Pattern
```sql
-- Always verify data exists by switching to authorized context
select makerkit.authenticate_as('unauthorized_user');
select is_empty(
$$ select * from protected_data $$,
'Unauthorized user sees no data'
);
-- Switch to authorized user to prove data exists
select makerkit.authenticate_as('authorized_user');
select isnt_empty(
$$ select * from protected_data $$,
'Data actually exists (confirms RLS filtering, not missing data)'
);
```
### Best Practices for Silent Failure Testing
#### ✅ Do: Test Both Positive and Negative Cases
```sql
-- Test that authorized users CAN access data
select makerkit.authenticate_as('authorized_user');
select isnt_empty($$ select * from protected_data $$, 'Authorized access works');
-- Test that unauthorized users CANNOT access data (silent filtering)
select makerkit.authenticate_as('unauthorized_user');
select is_empty($$ select * from protected_data $$, 'Unauthorized access silently filtered');
```
#### ✅ Do: Verify Data Exists in Different Context
```sql
-- Don't just test that unauthorized users see nothing
-- Verify the data actually exists by checking as an authorized user
select makerkit.authenticate_as('data_owner');
select isnt_empty($$ select * from my_data $$, 'Data exists');
select makerkit.authenticate_as('unauthorized_user');
select is_empty($$ select * from my_data $$, 'But unauthorized user cannot see it');
```
#### ✅ Do: Test Modification Boundaries
```sql
-- Test that unauthorized modifications are ignored
update sensitive_table set value = 'hacked';
select is(
(select value from sensitive_table),
'original_value',
'Unauthorized updates silently ignored'
);
```
#### ❌ Don't: Expect Errors from RLS Violations
```sql
-- Bad: RLS violations usually don't throw errors
select throws_ok(
$$ select * from protected_data $$,
'permission denied'
);
-- Good: RLS violations return empty results
select is_empty(
$$ select * from protected_data $$,
'Unauthorized users see no data due to RLS filtering'
);
```
#### ❌ Don't: Test Only Happy Paths
```sql
-- Bad: Only testing authorized access
select isnt_empty($$ select * from my_data $$, 'I can see my data');
-- Good: Test both authorized and unauthorized access
select makerkit.authenticate_as('owner');
select isnt_empty($$ select * from my_data $$, 'Owner can see data');
select makerkit.authenticate_as('stranger');
select is_empty($$ select * from my_data $$, 'Stranger cannot see data');
```
Remember: **RLS is designed to be invisible to attackers**. Your tests must verify this invisibility by checking for empty results and unchanged data, not for error messages.
## Running Tests
To run your database tests:
```bash
# Start Supabase locally
pnpm supabase:web:start
# Run all database tests
pnpm supabase:web:test
# Run specific test file
pnpm supabase test ./tests/database/your-test.test.sql
```
Your tests will help ensure your application is secure against common database vulnerabilities and that your RLS policies work as expected.

View File

@@ -0,0 +1,263 @@
---
status: "published"
label: "Database Webhooks"
order: 6
title: "Database Webhooks in the Next.js Supabase Starter Kit"
description: "Handle database change events with webhooks to send notifications, sync external services, and trigger custom logic when data changes."
---
Database webhooks let you execute custom code when rows are inserted, updated, or deleted in your Supabase tables. Makerkit provides a typed webhook handler at `@kit/database-webhooks` that processes these events in a Next.js API route.
{% sequence title="Database Webhooks Setup" description="Configure and handle database change events" %}
[Understand the webhook system](#how-database-webhooks-work)
[Add custom handlers](#adding-custom-webhook-handlers)
[Configure webhook triggers](#configuring-webhook-triggers)
[Test webhooks locally](#testing-webhooks-locally)
{% /sequence %}
## How Database Webhooks Work
Supabase database webhooks fire HTTP requests to your application when specified database events occur. The flow is:
1. A row is inserted, updated, or deleted in a table
2. Supabase sends a POST request to your webhook endpoint
3. Your handler processes the event and executes custom logic
4. The handler returns a success response
Makerkit includes built-in handlers for:
- **User deletion**: Cleans up related subscriptions and data
- **User signup**: Sends welcome emails
- **Invitation creation**: Sends invitation emails
You can extend this with your own handlers.
## Adding Custom Webhook Handlers
The webhook endpoint is at `apps/web/app/api/db/webhook/route.ts`. Add your handlers to the `handleEvent` callback:
```tsx {% title="apps/web/app/api/db/webhook/route.ts" %}
import { getDatabaseWebhookHandlerService } from '@kit/database-webhooks';
import { enhanceRouteHandler } from '@kit/next/routes';
export const POST = enhanceRouteHandler(
async ({ request }) => {
const service = getDatabaseWebhookHandlerService();
try {
const signature = request.headers.get('X-Supabase-Event-Signature');
if (!signature) {
return new Response('Missing signature', { status: 400 });
}
const body = await request.clone().json();
await service.handleWebhook({
body,
signature,
async handleEvent(change) {
// Handle new project creation
if (change.type === 'INSERT' && change.table === 'projects') {
await notifyTeamOfNewProject(change.record);
}
// Handle subscription cancellation
if (change.type === 'UPDATE' && change.table === 'subscriptions') {
if (change.record.status === 'canceled') {
await sendCancellationSurvey(change.record);
}
}
// Handle user deletion
if (change.type === 'DELETE' && change.table === 'accounts') {
await cleanupExternalServices(change.old_record);
}
},
});
return new Response(null, { status: 200 });
} catch (error) {
console.error('Webhook error:', error);
return new Response(null, { status: 500 });
}
},
{ auth: false },
);
```
### RecordChange Type
The `change` object is typed to your database schema:
```tsx
import type { Database } from '@kit/supabase/database';
type Tables = Database['public']['Tables'];
type TableChangeType = 'INSERT' | 'UPDATE' | 'DELETE';
interface RecordChange<
Table extends keyof Tables,
Row = Tables[Table]['Row'],
> {
type: TableChangeType;
table: Table;
record: Row; // Current row data (null for DELETE)
schema: 'public';
old_record: Row | null; // Previous row data (null for INSERT)
}
```
### Type-Safe Handlers
Cast to specific table types for better type safety:
```tsx
import type { RecordChange } from '@kit/database-webhooks';
type ProjectChange = RecordChange<'projects'>;
type SubscriptionChange = RecordChange<'subscriptions'>;
async function handleEvent(change: RecordChange<keyof Tables>) {
if (change.table === 'projects') {
const projectChange = change as ProjectChange;
// projectChange.record is now typed to the projects table
console.log(projectChange.record.name);
}
}
```
### Async Handlers
For long-running operations, consider using background jobs:
```tsx
async handleEvent(change) {
if (change.type === 'INSERT' && change.table === 'orders') {
// Queue for background processing instead of blocking
await queueOrderProcessing(change.record.id);
}
}
```
## Configuring Webhook Triggers
Webhooks are configured in Supabase. You can set them up via SQL or the Dashboard.
### SQL Configuration
Add a trigger in your schema file at `apps/web/supabase/schemas/`:
```sql {% title="apps/web/supabase/schemas/webhooks.sql" %}
-- Create the webhook trigger for the projects table
create trigger projects_webhook
after insert or update or delete on public.projects
for each row execute function supabase_functions.http_request(
'https://your-app.com/api/db/webhook',
'POST',
'{"Content-Type":"application/json"}',
'{}',
'5000'
);
```
### Dashboard Configuration
1. Open your Supabase project dashboard
2. Navigate to **Database** > **Webhooks**
3. Click **Create a new hook**
4. Configure:
- **Name**: `projects_webhook`
- **Table**: `projects`
- **Events**: INSERT, UPDATE, DELETE
- **Type**: HTTP Request
- **URL**: `https://your-app.com/api/db/webhook`
- **Method**: POST
### Webhook Security
Supabase automatically signs webhook payloads using the `X-Supabase-Event-Signature` header. The `@kit/database-webhooks` package verifies this signature against your `SUPABASE_DB_WEBHOOK_SECRET` environment variable.
Configure the webhook secret:
```bash {% title=".env.local" %}
SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret
```
Set the same secret in your Supabase webhook configuration. The handler validates signatures automatically, rejecting requests with missing or invalid signatures.
## Testing Webhooks Locally
### Local Development Setup
When running Supabase locally, webhooks need to reach your Next.js server:
1. Start your development server on a known port:
```bash
pnpm run dev
```
2. Configure the webhook URL in your local Supabase to point to `http://host.docker.internal:3000/api/db/webhook` (Docker) or `http://localhost:3000/api/db/webhook`.
### Manual Testing
Test your webhook handler by sending a mock request:
```bash
curl -X POST http://localhost:3000/api/db/webhook \
-H "Content-Type: application/json" \
-H "X-Supabase-Event-Signature: your-secret-key" \
-d '{
"type": "INSERT",
"table": "projects",
"schema": "public",
"record": {
"id": "test-id",
"name": "Test Project",
"account_id": "account-id"
},
"old_record": null
}'
```
Expected response: `200 OK`
### Debugging Tips
**Webhook not firing**: Check that the trigger exists in Supabase and the URL is correct.
**Handler not executing**: Add logging to trace the event flow:
```tsx
async handleEvent(change) {
console.log('Received webhook:', {
type: change.type,
table: change.table,
recordId: change.record?.id,
});
}
```
**Timeout errors**: Move long operations to background jobs. Webhooks should respond quickly.
## Common Use Cases
| Use Case | Trigger | Action |
|----------|---------|--------|
| Welcome email | INSERT on `users` | Send onboarding email |
| Invitation email | INSERT on `invitations` | Send invite link |
| Subscription change | UPDATE on `subscriptions` | Sync with CRM |
| User deletion | DELETE on `accounts` | Clean up external services |
| Audit logging | INSERT/UPDATE/DELETE | Write to audit table |
| Search indexing | INSERT/UPDATE | Update search index |
## Related Resources
- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for extending your schema
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) for built-in SQL functions
- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration) for sending emails from webhooks

View File

@@ -0,0 +1,210 @@
---
status: "published"
label: "External Marketing Website"
title: "External Marketing Website in the Next.js Supabase Turbo Starter Kit"
description: "Configure Makerkit to redirect marketing pages to an external website built with Framer, Webflow, or WordPress."
order: 9
---
Redirect Makerkit's marketing pages to an external website by configuring the `proxy.ts` middleware. This lets you use Framer, Webflow, or WordPress for your marketing site while keeping Makerkit for your SaaS application.
{% sequence title="External Marketing Website Setup" description="Configure redirects to your external marketing site" %}
[Understand the architecture](#when-to-use-an-external-marketing-website)
[Configure the middleware](#configuring-the-middleware)
[Handle subpaths and assets](#handling-subpaths-and-assets)
[Verify the redirects](#verify-the-redirects)
{% /sequence %}
## When to Use an External Marketing Website
Use an external marketing website when:
- **Marketing team independence**: Your marketing team needs to update content without developer involvement
- **Design flexibility**: You want visual builders like Framer or Webflow for landing pages
- **Content management**: WordPress or a headless CMS better fits your content workflow
- **A/B testing**: Your marketing tools integrate better with external platforms
Keep marketing pages in Makerkit when:
- You want a unified codebase and deployment
- Your team is comfortable with React and Tailwind
- You need tight integration between marketing and app features
## Configuring the Middleware
{% alert type="default" title="Next.js 16+" %}
In Next.js 16+, Makerkit uses `proxy.ts` for middleware. Prior versions used `middleware.ts`.
{% /alert %}
Edit `apps/web/proxy.ts` to redirect marketing pages:
```typescript {% title="apps/web/proxy.ts" %}
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
const EXTERNAL_MARKETING_URL = 'https://your-marketing-site.com';
const MARKETING_PAGES = [
'/',
'/pricing',
'/faq',
'/contact',
'/about',
'/blog',
'/privacy-policy',
'/terms-of-service',
'/cookie-policy',
];
export function proxy(req: NextRequest) {
if (isMarketingPage(req)) {
const redirectUrl = new URL(
req.nextUrl.pathname,
EXTERNAL_MARKETING_URL
);
// Preserve query parameters
redirectUrl.search = req.nextUrl.search;
return NextResponse.redirect(redirectUrl, { status: 301 });
}
// Continue with existing middleware logic
return NextResponse.next();
}
function isMarketingPage(req: NextRequest): boolean {
const pathname = req.nextUrl.pathname;
return MARKETING_PAGES.some((page) => {
if (page === '/') {
return pathname === '/';
}
return pathname === page || pathname.startsWith(`${page}/`);
});
}
```
### Configuration Options
| Option | Description |
|--------|-------------|
| `EXTERNAL_MARKETING_URL` | Your external marketing site's base URL |
| `MARKETING_PAGES` | Array of paths to redirect |
| Status code `301` | Permanent redirect (SEO-friendly) |
| Status code `302` | Temporary redirect (for testing) |
## Handling Subpaths and Assets
### Blog Posts with Dynamic Paths
If your blog uses dynamic paths like `/blog/post-slug`, handle them separately:
```typescript
const MARKETING_PAGES = [
// ... other pages
];
const MARKETING_PREFIXES = [
'/blog',
'/resources',
'/case-studies',
];
function isMarketingPage(req: NextRequest): boolean {
const pathname = req.nextUrl.pathname;
// Check exact matches
if (MARKETING_PAGES.includes(pathname)) {
return true;
}
// Check prefix matches
return MARKETING_PREFIXES.some((prefix) =>
pathname.startsWith(prefix)
);
}
```
### Excluding Application Routes
Keep certain routes in Makerkit even if they share a marketing prefix:
```typescript
const APP_ROUTES = [
'/blog/admin', // Blog admin panel stays in Makerkit
'/pricing/checkout', // Checkout flow stays in Makerkit
];
function isMarketingPage(req: NextRequest): boolean {
const pathname = req.nextUrl.pathname;
// Never redirect app routes
if (APP_ROUTES.some((route) => pathname.startsWith(route))) {
return false;
}
// ... rest of the logic
}
```
## Verify the Redirects
After configuring, verify redirects work correctly:
```bash
# Start the development server
pnpm run dev
# Test a redirect (should return 301)
curl -I http://localhost:3000/pricing
```
Expected output:
```
HTTP/1.1 301 Moved Permanently
Location: https://your-marketing-site.com/pricing
```
### Common Issues
**Redirect loops**: Ensure your external site doesn't redirect back to Makerkit.
**Missing query parameters**: The example code preserves query params. Verify UTM parameters pass through correctly.
**Asset requests**: Don't redirect asset paths like `/images/` or `/_next/`. The middleware should only match page routes.
## Environment-Based Configuration
Use environment variables for different environments:
```typescript {% title="apps/web/proxy.ts" %}
const EXTERNAL_MARKETING_URL = process.env.EXTERNAL_MARKETING_URL;
export function proxy(req: NextRequest) {
// Only redirect if external URL is configured
if (!EXTERNAL_MARKETING_URL) {
return NextResponse.next();
}
// ... redirect logic
}
```
```bash {% title=".env.production" %}
EXTERNAL_MARKETING_URL=https://your-marketing-site.com
```
This lets you keep marketing pages in Makerkit during development while redirecting in production.
## Related Resources
- [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) for customizing built-in marketing pages
- [SEO Configuration](/docs/next-supabase-turbo/development/seo) for sitemap and meta tag setup
- [Legal Pages](/docs/next-supabase-turbo/development/legal-pages) for privacy policy and terms pages

View File

@@ -0,0 +1,221 @@
---
status: "published"
label: "Legal Pages"
title: "Legal Pages in the Next.js Supabase Turbo Starter Kit"
description: "Create and customize legal pages including Terms of Service, Privacy Policy, and Cookie Policy in your Makerkit application."
order: 8
---
Legal pages in Makerkit are TSX files located at `apps/web/app/[locale]/(marketing)/(legal)/`. The kit includes placeholder files for Terms of Service, Privacy Policy, and Cookie Policy that you must customize with your own content.
{% sequence title="Legal Pages Setup" description="Configure your SaaS application's legal pages" %}
[Understand the included pages](#included-legal-pages)
[Customize the content](#customizing-legal-pages)
[Add new legal pages](#adding-new-legal-pages)
[Use a CMS for legal content](#using-a-cms-for-legal-pages)
{% /sequence %}
## Included Legal Pages
Makerkit includes three legal page templates:
| Page | File Location | URL |
|------|---------------|-----|
| Terms of Service | `apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx` | `/terms-of-service` |
| Privacy Policy | `apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx` | `/privacy-policy` |
| Cookie Policy | `apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx` | `/cookie-policy` |
{% alert type="error" title="Required: Add Your Own Content" %}
The included legal pages contain placeholder text only. You must replace this content with legally compliant policies for your jurisdiction and business model. Consult a lawyer for proper legal documentation.
{% /alert %}
## Customizing Legal Pages
### Basic MDX Structure
Each legal page uses MDX format with frontmatter:
```mdx {% title="apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx" %}
---
title: "Privacy Policy"
description: "How we collect, use, and protect your personal information"
---
# Privacy Policy
**Last updated: January 2026**
## Information We Collect
We collect information you provide directly...
## How We Use Your Information
We use the information we collect to...
## Contact Us
If you have questions about this Privacy Policy, contact us at...
```
### Adding Last Updated Dates
Include a visible "Last updated" date in your legal pages. This helps with compliance and user trust:
```mdx
**Last updated: January 15, 2026**
*This policy is effective as of the date above and replaces any prior versions.*
```
### Structuring Long Documents
For complex legal documents, use clear heading hierarchy:
```mdx
# Privacy Policy
## 1. Information Collection
### 1.1 Information You Provide
### 1.2 Information Collected Automatically
### 1.3 Information from Third Parties
## 2. Use of Information
### 2.1 Service Delivery
### 2.2 Communications
### 2.3 Analytics and Improvements
## 3. Data Sharing
...
```
## Adding New Legal Pages
Create additional legal pages in the `(legal)` directory:
```bash
# Create a new legal page
mkdir -p apps/web/app/\[locale\]/\(marketing\)/\(legal\)/acceptable-use
touch apps/web/app/\[locale\]/\(marketing\)/\(legal\)/acceptable-use/page.tsx
```
Add the content:
```mdx {% title="apps/web/app/[locale]/(marketing)/(legal)/acceptable-use/page.tsx" %}
---
title: "Acceptable Use Policy"
description: "Guidelines for using our service responsibly"
---
# Acceptable Use Policy
**Last updated: January 2026**
This Acceptable Use Policy outlines prohibited activities...
```
### Update Navigation
Add links to new legal pages in your footer or navigation. The footer typically lives in:
```
apps/web/app/[locale]/(marketing)/_components/site-footer.tsx
```
### Update Sitemap
Add new legal pages to your sitemap in `apps/web/app/sitemap.xml/route.ts`:
```typescript
function getPaths() {
const paths = [
// ... existing paths
'/acceptable-use', // Add new legal page
];
return paths.map((path) => ({
loc: new URL(path, appConfig.url).href,
lastmod: new Date().toISOString(),
}));
}
```
## Using a CMS for Legal Pages
For organizations that need non-developers to update legal content, use the CMS integration:
```tsx {% title="apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx" %}
import { createCmsClient } from '@kit/cms';
export default async function PrivacyPolicyPage() {
const cms = await createCmsClient();
const { title, content } = await cms.getContentBySlug({
slug: 'privacy-policy',
collection: 'pages',
});
return (
<article className="prose prose-gray max-w-3xl mx-auto py-12">
<h1>{title}</h1>
<div dangerouslySetInnerHTML={{ __html: content }} />
</article>
);
}
```
### CMS Setup for Legal Pages
1. Create a `pages` collection in your CMS (Keystatic, WordPress, or custom)
2. Add entries for each legal page with slugs matching the URL paths
3. Use the CMS admin interface to edit content without code changes
See the [CMS documentation](/docs/next-supabase-turbo/content/cms) for detailed setup instructions.
## Legal Page Best Practices
### What to Include
**Privacy Policy** should cover:
- What data you collect (personal info, usage data, cookies)
- How you use the data
- Third-party services (analytics, payment processors)
- User rights (access, deletion, portability)
- Contact information
**Terms of Service** should cover:
- Service description and limitations
- User responsibilities and prohibited uses
- Payment terms (if applicable)
- Intellectual property rights
- Limitation of liability
- Termination conditions
**Cookie Policy** should cover:
- Types of cookies used (essential, analytics, marketing)
- Purpose of each cookie type
- How to manage cookie preferences
- Third-party cookies
### Compliance Considerations
| Regulation | Requirements |
|------------|--------------|
| GDPR (EU) | Privacy policy, cookie consent, data subject rights |
| CCPA (California) | Privacy policy with specific disclosures, opt-out rights |
| LGPD (Brazil) | Privacy policy, consent mechanisms, data protection officer |
{% alert type="warning" title="Not Legal Advice" %}
This documentation provides technical guidance only. Consult qualified legal counsel to ensure your policies comply with applicable laws and regulations.
{% /alert %}
## Related Resources
- [CMS Configuration](/docs/next-supabase-turbo/content/cms) for managing legal content through a CMS
- [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) for customizing other marketing content
- [SEO Configuration](/docs/next-supabase-turbo/development/seo) for proper indexing of legal pages

View File

@@ -0,0 +1,231 @@
---
status: "published"
label: "Loading data from the DB"
order: 4
title: "Learn how to load data from the Supabase database"
description: "In this page we learn how to load data from the Supabase database and display it in our Next.js application."
---
Now that our database supports the data we need, we can start loading it into our application. We will use the `@makerkit/data-loader-supabase-nextjs` package to load data from the Supabase database.
Please check the [documentation](https://github.com/makerkit/makerkit/tree/main/packages/data-loader/supabase/nextjs) for the `@makerkit/data-loader-supabase-nextjs` package to learn more about how to use it.
This nifty package allows us to load data from the Supabase database and display it in our server components with support for pagination.
In the snippet below, we will:
1. Load the user's workspace data from the database. This allows us to get the user's account ID without further round-trips because the workspace is loaded by the user layout.
2. Load the user's tasks from the database.
3. Display the tasks in a table.
4. Use a search input to filter the tasks by title.
Let's take a look at the code:
```tsx
import { use } from 'react';
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { getTranslations } from 'next-intl/server';
import { TasksTable } from './_components/tasks-table';
import { UserAccountHeader } from './_components/user-account-header';
import { loadUserWorkspace } from './_lib/server/load-user-workspace';
interface SearchParams {
page?: string;
query?: string;
}
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('homePage');
return {
title,
};
};
async function UserHomePage(props: { searchParams: Promise<SearchParams> }) {
const client = getSupabaseServerClient();
const { user } = use(loadUserWorkspace());
const searchParams = await props.searchParams;
const page = parseInt(searchParams.page ?? '1', 10);
const query = searchParams.query ?? '';
return (
<>
<UserAccountHeader
title={<Trans i18nKey={'common.homeTabLabel'} />}
description={<Trans i18nKey={'common.homeTabDescription'} />}
/>
<PageBody className={'space-y-4'}>
<div className={'flex items-center justify-between'}>
<div>
<Heading level={4}>
<Trans i18nKey={'tasks.tasksTabLabel'} defaults={'Tasks'} />
</Heading>
</div>
<div className={'flex items-center space-x-2'}>
<form className={'w-full'}>
<Input
name={'query'}
defaultValue={query}
className={'w-full lg:w-[18rem]'}
placeholder={'Search tasks'}
/>
</form>
</div>
</div>
<ServerDataLoader
client={client}
table={'tasks'}
page={page}
where={{
account_id: {
eq: user.id,
},
title: {
textSearch: query ? `%${query}%` : undefined,
},
}}
>
{(props) => {
return (
<div className={'flex flex-col space-y-8'}>
<If condition={props.count === 0 && query}>
<div className={'flex flex-col space-y-2.5'}>
<p>
<Trans
i18nKey={'tasks.noTasksFound'}
values={{ query }}
/>
</p>
<form>
<input type="hidden" name={'query'} value={''} />
<Button variant={'outline'} size={'sm'}>
<Trans i18nKey={'tasks.clearSearch'} />
</Button>
</form>
</div>
</If>
<TasksTable {...props} />
</div>
);
}}
</ServerDataLoader>
</PageBody>
</>
);
}
export default UserHomePage;
```
Let's break this down a bit:
1. We import the necessary components and functions.
2. We define the `SearchParams` interface to type the search parameters.
3. We define the `generateMetadata` function to generate the page metadata.
4. We define the `UserHomePage` component that loads the user's workspace and tasks from the database.
5. We define the `ServerDataLoader` component that loads the tasks from the database.
6. We render the tasks in a table and provide a search input to filter the tasks by title.
7. We export the `UserHomePage` component.
### Displaying the tasks in a table
Now, let's show the tasks table component:
```tsx
'use client';
import Link from 'next/link';
import { ColumnDef } from '@tanstack/react-table';
import { Pencil } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@kit/ui/button';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { Database } from '~/lib/database.types';
type Task = Database['public']['Tables']['tasks']['Row'];
export function TasksTable(props: {
data: Task[];
page: number;
pageSize: number;
pageCount: number;
}) {
const columns = useGetColumns();
return (
<div>
<DataTable {...props} columns={columns} />
</div>
);
}
function useGetColumns(): ColumnDef<Task>[] {
const t = useTranslations('tasks');
return [
{
header: t('task'),
cell: ({ row }) => (
<Link
className={'hover:underline'}
href={`/home/tasks/${row.original.id}`}
>
{row.original.title}
</Link>
),
},
{
header: t('createdAt'),
accessorKey: 'created_at',
},
{
header: t('updatedAt'),
accessorKey: 'updated_at',
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const id = row.original.id;
return (
<div className={'flex justify-end space-x-2'}>
<Link href={`/home/tasks/${id}`}>
<Button variant={'ghost'} size={'icon'}>
<Pencil className={'h-4'} />
</Button>
</Link>
</div>
);
},
},
];
}
```
In this snippet, we define the `TasksTable` component that renders the tasks in a table. We use the `DataTable` component from the `@kit/ui/enhanced-data-table` package to render the table.
We also define the `useGetColumns` hook that returns the columns for the table. We use the `useTranslations` hook from `next-intl` to translate the column headers.

View File

@@ -0,0 +1,409 @@
---
status: "published"
label: "Marketing Pages"
title: "Customize Marketing Pages in the Next.js Supabase Turbo Starter Kit"
description: "Build and customize landing pages, pricing pages, FAQ, and other marketing content using Next.js App Router and Tailwind CSS."
order: 7
---
Marketing pages in Makerkit live at `apps/web/app/[locale]/(marketing)/` and include landing pages, pricing, FAQ, blog, documentation, and contact forms. These pages use Next.js App Router with React Server Components for fast initial loads and SEO optimization.
{% sequence title="Marketing Pages Development" description="Customize and extend your marketing pages" %}
[Understand the structure](#marketing-pages-structure)
[Customize existing pages](#customizing-existing-pages)
[Create new marketing pages](#creating-new-marketing-pages)
[Configure navigation and footer](#navigation-and-footer)
{% /sequence %}
## Marketing Pages Structure
The marketing pages follow Next.js App Router conventions with a route group:
```
apps/web/app/[locale]/(marketing)/
├── layout.tsx # Shared layout with header/footer
├── page.tsx # Home page (/)
├── (legal)/ # Legal pages group
│ ├── cookie-policy/
│ ├── privacy-policy/
│ └── terms-of-service/
├── blog/ # Blog listing and posts
├── changelog/ # Product changelog
├── contact/ # Contact form
├── docs/ # Documentation
├── faq/ # FAQ page
├── pricing/ # Pricing page
└── _components/ # Shared marketing components
├── header.tsx
├── footer.tsx
└── site-navigation.tsx
```
### Route Groups Explained
The `(marketing)` folder is a route group that shares a layout without affecting the URL structure. Pages inside render at the root level:
| File Path | URL |
|-----------|-----|
| `app/[locale]/(marketing)/page.tsx` | `/` |
| `app/[locale]/(marketing)/pricing/page.tsx` | `/pricing` |
| `app/[locale]/(marketing)/blog/page.tsx` | `/blog` |
## Customizing Existing Pages
### Home Page
The home page at `apps/web/app/[locale]/(marketing)/page.tsx` typically includes:
```tsx {% title="apps/web/app/[locale]/(marketing)/page.tsx" %}
import { Hero } from './_components/hero';
import { Features } from './_components/features';
import { Testimonials } from './_components/testimonials';
import { Pricing } from './_components/pricing-section';
import { CallToAction } from './_components/call-to-action';
export default function HomePage() {
return (
<>
<Hero />
<Features />
<Testimonials />
<Pricing />
<CallToAction />
</>
);
}
```
Each section is a separate component in `_components/` for easy customization.
### Pricing Page
The pricing page displays your billing plans. It reads configuration from `apps/web/config/billing.config.ts`:
```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %}
import { PricingTable } from '@kit/billing-gateway/marketing';
import billingConfig from '~/config/billing.config';
export default function PricingPage() {
return (
<div className="container py-16">
<h1 className="text-4xl font-bold text-center mb-4">
Simple, Transparent Pricing
</h1>
<p className="text-muted-foreground text-center mb-12">
Choose the plan that fits your needs
</p>
<PricingTable config={billingConfig} />
</div>
);
}
```
See [Billing Configuration](/docs/next-supabase-turbo/billing/overview) for customizing plans and pricing.
### FAQ Page
The FAQ page uses an accordion component with content from a configuration file or CMS:
```tsx {% title="apps/web/app/[locale]/(marketing)/faq/page.tsx" %}
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@kit/ui/accordion';
const faqs = [
{
question: 'How do I get started?',
answer: 'Sign up for a free account and follow our getting started guide.',
},
{
question: 'Can I cancel anytime?',
answer: 'Yes, you can cancel your subscription at any time with no penalties.',
},
// ... more FAQs
];
export default function FAQPage() {
return (
<div className="container max-w-3xl py-16">
<h1 className="text-4xl font-bold text-center mb-12">
Frequently Asked Questions
</h1>
<Accordion type="single" collapsible>
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger>{faq.question}</AccordionTrigger>
<AccordionContent>{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
}
```
### Contact Page
The contact page includes a form that sends emails via your configured mailer:
```tsx {% title="apps/web/app/[locale]/(marketing)/contact/page.tsx" %}
import { ContactForm } from './_components/contact-form';
export default function ContactPage() {
return (
<div className="container max-w-xl py-16">
<h1 className="text-4xl font-bold text-center mb-4">
Contact Us
</h1>
<p className="text-muted-foreground text-center mb-8">
Have a question? We'd love to hear from you.
</p>
<ContactForm />
</div>
);
}
```
#### Contact Form Configuration
Configure the recipient email address in your environment:
```bash {% title=".env.local" %}
CONTACT_EMAIL=support@yourdomain.com
```
The form submission uses your [email configuration](/docs/next-supabase-turbo/emails/email-configuration). Ensure your mailer is configured before the contact form will work.
## Creating New Marketing Pages
### Basic Page Structure
Create a new page with proper metadata:
```tsx {% title="apps/web/app/[locale]/(marketing)/about/page.tsx" %}
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us | Your SaaS Name',
description: 'Learn about our mission, team, and the story behind our product.',
};
export default function AboutPage() {
return (
<div className="container py-16">
<h1 className="text-4xl font-bold mb-8">About Us</h1>
<div className="prose prose-gray max-w-none">
<p>Your company story goes here...</p>
</div>
</div>
);
}
```
### MDX Pages for Content-Heavy Pages
For content-heavy pages, use MDX:
```bash
# Create an MDX page
mkdir -p apps/web/app/\(marketing\)/about
touch apps/web/app/\(marketing\)/about/page.mdx
```
```mdx {% title="apps/web/app/[locale]/(marketing)/about/page.mdx" %}
---
title: "About Us"
description: "Learn about our mission and team"
---
# About Us
We started this company because...
## Our Mission
To help developers ship faster...
## The Team
Meet the people behind the product...
```
### Dynamic Pages with Data
For pages that need dynamic data, combine Server Components with data fetching:
```tsx {% title="apps/web/app/[locale]/(marketing)/customers/page.tsx" %}
import { createCmsClient } from '@kit/cms';
export default async function CustomersPage() {
const cms = await createCmsClient();
const caseStudies = await cms.getContentItems({
collection: 'case-studies',
limit: 10,
});
return (
<div className="container py-16">
<h1 className="text-4xl font-bold mb-12">Customer Stories</h1>
<div className="grid md:grid-cols-2 gap-8">
{caseStudies.map((study) => (
<CaseStudyCard key={study.slug} {...study} />
))}
</div>
</div>
);
}
```
## Navigation and Footer
### Header Navigation
Configure navigation links in the header component:
```tsx {% title="apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx" %}
const navigationItems = [
{ label: 'Features', href: '/#features' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Blog', href: '/blog' },
{ label: 'Docs', href: '/docs' },
{ label: 'Contact', href: '/contact' },
];
```
### Footer Links
The footer typically includes multiple link sections:
```tsx {% title="apps/web/app/[locale]/(marketing)/_components/footer.tsx" %}
const footerSections = [
{
title: 'Product',
links: [
{ label: 'Features', href: '/#features' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Changelog', href: '/changelog' },
],
},
{
title: 'Resources',
links: [
{ label: 'Documentation', href: '/docs' },
{ label: 'Blog', href: '/blog' },
{ label: 'FAQ', href: '/faq' },
],
},
{
title: 'Legal',
links: [
{ label: 'Privacy Policy', href: '/privacy-policy' },
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Cookie Policy', href: '/cookie-policy' },
],
},
];
```
### Customizing the Layout
All marketing pages inherit from `apps/web/app/[locale]/(marketing)/layout.tsx`. This layout includes:
- Header with navigation
- Footer with links
- Common metadata
- Analytics scripts
Edit this file to change the shared structure across all marketing pages.
## SEO for Marketing Pages
### Metadata API
Use Next.js Metadata API for SEO:
```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %}
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Pricing | Your SaaS Name',
description: 'Choose from flexible pricing plans. Start free, upgrade when ready.',
openGraph: {
title: 'Pricing | Your SaaS Name',
description: 'Choose from flexible pricing plans.',
images: ['/images/og/pricing.png'],
},
};
```
### Structured Data
Add JSON-LD structured data for rich search results. See the [Next.js JSON-LD guide](https://nextjs.org/docs/app/guides/json-ld) for more details:
```tsx
// JSON-LD structured data using Next.js metadata
export default function PricingPage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: 'Your SaaS Name',
offers: {
'@type': 'AggregateOffer',
lowPrice: '0',
highPrice: '99',
priceCurrency: 'USD',
},
}),
}}
/>
{/* Page content */}
</>
);
}
```
### Sitemap
Add new marketing pages to your sitemap at `apps/web/app/sitemap.xml/route.ts`:
```typescript
function getPaths() {
return [
'/',
'/pricing',
'/faq',
'/blog',
'/docs',
'/contact',
'/about', // Add new pages here
];
}
```
## Related Resources
- [SEO Configuration](/docs/next-supabase-turbo/development/seo) for detailed SEO setup
- [Legal Pages](/docs/next-supabase-turbo/development/legal-pages) for privacy policy and terms
- [External Marketing Website](/docs/next-supabase-turbo/development/external-marketing-website) for using Framer or Webflow
- [CMS Setup](/docs/next-supabase-turbo/content/cms) for blog configuration
- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration) for contact form setup

View File

@@ -0,0 +1,309 @@
---
status: "published"
label: "Migrations"
order: 1
title: "Database Migrations in the Next.js Supabase Starter Kit"
description: "Create and manage database migrations using Supabase's declarative schema and diffing tools to evolve your PostgreSQL schema safely."
---
Database migrations in Makerkit use Supabase's declarative schema approach. Define your schema in SQL files at `apps/web/supabase/schemas/`, then generate migration files that track changes over time. This keeps your schema version-controlled and deployable across environments.
{% sequence title="Database Migration Workflow" description="Create and apply schema changes safely" %}
[Edit the declarative schema](#editing-the-declarative-schema)
[Generate a migration file](#generating-a-migration-file)
[Test locally](#testing-locally)
[Push to production](#pushing-to-production)
{% /sequence %}
## Why Declarative Schema?
Makerkit uses declarative schema files instead of incremental migrations for several reasons:
- **Readable**: See your entire schema in one place
- **Mergeable**: Schema changes are easier to review in PRs
- **Recoverable**: Always know the intended state of your database
- **Automated**: Supabase generates migration diffs for you
{% alert type="warning" title="Avoid Supabase Studio for Schema Changes" %}
Don't use the hosted Supabase Studio to modify your schema. Changes made there won't be tracked in your codebase. Use your local Supabase instance and generate migrations from schema files.
{% /alert %}
## Schema File Organization
Schema files live in `apps/web/supabase/schemas/`:
```
apps/web/supabase/
├── config.toml # Supabase configuration
├── seed.sql # Seed data for development
├── schemas/ # Declarative schema files
│ ├── 00-extensions.sql
│ ├── 01-enums.sql
│ ├── 02-accounts.sql
│ ├── 03-roles.sql
│ ├── 04-memberships.sql
│ ├── 05-subscriptions.sql
│ └── your-feature.sql # Your custom schema
└── migrations/ # Generated migration files
├── 20240101000000_initial.sql
└── 20240115000000_add_projects.sql
```
Files are loaded alphabetically, so prefix with numbers to control order.
## Editing the Declarative Schema
### Adding a New Table
Create a schema file for your feature:
```sql {% title="apps/web/supabase/schemas/20-projects.sql" %}
-- Projects table for team workspaces
create table if not exists public.projects (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
description text,
status text not null default 'active' check (status in ('active', 'archived')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Enable RLS
alter table public.projects enable row level security;
-- RLS policies
create policy "Users can view their account's projects"
on public.projects
for select
using (
account_id in (
select account_id from public.accounts_memberships
where user_id = auth.uid()
)
);
create policy "Users with write permission can insert projects"
on public.projects
for insert
with check (
public.has_permission(auth.uid(), account_id, 'projects.write'::app_permissions)
);
-- Updated at trigger
create trigger set_projects_updated_at
before update on public.projects
for each row execute function public.set_updated_at();
```
### Modifying an Existing Table
Edit the schema file directly. For example, to add a column:
```sql {% title="apps/web/supabase/schemas/20-projects.sql" %}
create table if not exists public.projects (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
description text,
status text not null default 'active' check (status in ('active', 'archived')),
priority integer not null default 0, -- New column
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
```
### Adding Indexes
Add indexes for frequently queried columns:
```sql
-- Add to your schema file
create index if not exists projects_account_id_idx
on public.projects(account_id);
create index if not exists projects_status_idx
on public.projects(status)
where status = 'active';
```
## Generating a Migration File
After editing schema files, generate a migration that captures the diff:
```bash
# Generate migration from schema changes
pnpm --filter web supabase:db:diff -f add_projects
```
This creates a timestamped migration file in `apps/web/supabase/migrations/`:
```sql {% title="apps/web/supabase/migrations/20260119000000_add_projects.sql" %}
-- Generated by Supabase CLI
create table public.projects (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
description text,
status text not null default 'active' check (status in ('active', 'archived')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.projects enable row level security;
-- ... policies and triggers
```
{% alert type="error" title="Always Review Generated Migrations" %}
The diffing tool has [known caveats](https://supabase.com/docs/guides/local-development/declarative-database-schemas#known-caveats). Always review generated migrations before applying them. Check for:
- Destructive operations (DROP statements)
- Missing or incorrect constraints
- Order of operations issues
{% /alert %}
## Testing Locally
Apply and test your migration locally before pushing to production:
```bash
# Stop Supabase if running
pnpm run supabase:web:stop
# Start with fresh database
pnpm run supabase:web:start
# Or reset to apply all migrations
pnpm run supabase:web:reset
```
### Verify the Schema
Check that your changes applied correctly:
```bash
# Open local Supabase Studio
open http://localhost:54323
```
Navigate to **Table Editor** and verify your table exists with the correct columns.
### Run Database Tests
If you have pgTAP tests, run them to verify RLS policies:
```bash
pnpm --filter web supabase:test
```
See [Database Tests](/docs/next-supabase-turbo/development/database-tests) for writing tests.
## Pushing to Production
After testing locally, push migrations to your remote Supabase instance:
```bash
# Link to your Supabase project (first time only)
pnpm --filter web supabase link --project-ref your-project-ref
# Push migrations
pnpm --filter web supabase db push
```
### Migration Commands Reference
| Command | Description |
|---------|-------------|
| `pnpm run supabase:web:start` | Start local Supabase |
| `pnpm run supabase:web:stop` | Stop local Supabase |
| `pnpm run supabase:web:reset` | Reset and apply all migrations |
| `pnpm --filter web supabase:db:diff -f <name>` | Generate migration from schema diff |
| `pnpm --filter web supabase db push` | Push migrations to remote |
| `pnpm --filter web supabase:typegen` | Regenerate TypeScript types |
## Regenerating TypeScript Types
After schema changes, regenerate the TypeScript types:
```bash
pnpm --filter web supabase:typegen
```
This updates `packages/supabase/src/database.types.ts` with your new tables and columns. Import types in your code:
```tsx
import type { Database } from '@kit/supabase/database';
type Project = Database['public']['Tables']['projects']['Row'];
type NewProject = Database['public']['Tables']['projects']['Insert'];
```
## Common Patterns
### Adding a Lookup Table
```sql
-- Status enum as lookup table
create table if not exists public.project_statuses (
id text primary key,
label text not null,
sort_order integer not null default 0
);
insert into public.project_statuses (id, label, sort_order) values
('active', 'Active', 1),
('archived', 'Archived', 2),
('deleted', 'Deleted', 3)
on conflict (id) do nothing;
```
### Adding a Junction Table
```sql
-- Many-to-many relationship
create table if not exists public.project_members (
project_id uuid not null references public.projects(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null default 'member',
created_at timestamptz not null default now(),
primary key (project_id, user_id)
);
```
### Data Migration
For data transformations, use a separate migration:
```sql {% title="apps/web/supabase/migrations/20260120000000_backfill_priority.sql" %}
-- Backfill priority based on status
update public.projects
set priority = case
when status = 'active' then 1
when status = 'archived' then 0
else 0
end
where priority is null;
```
## Troubleshooting
**Diff shows no changes**: Ensure your schema file is being loaded. Check file naming (alphabetical order matters).
**Migration fails on production**: The diff tool may generate invalid SQL. Review and manually fix the migration file.
**Type mismatch after migration**: Regenerate types with `pnpm --filter web supabase:typegen`.
**RLS policy errors**: Check that your policies reference valid columns and functions. Test with [database tests](/docs/next-supabase-turbo/development/database-tests).
## Related Resources
- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for detailed schema patterns
- [Database Architecture](/docs/next-supabase-turbo/development/database-architecture) for understanding the data model
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) for built-in SQL functions
- [Database Tests](/docs/next-supabase-turbo/development/database-tests) for testing migrations

View File

@@ -0,0 +1,526 @@
---
status: "published"
label: 'RBAC: Roles and Permissions'
title: 'Role-Based Access Control (RBAC) in Next.js Supabase'
description: 'Implement granular permissions with roles, hierarchy levels, and the app_permissions enum. Use has_permission in RLS policies and application code.'
order: 6
---
Makerkit implements RBAC through three components: the `roles` table (defines role names and hierarchy), the `role_permissions` table (maps roles to permissions), and the `app_permissions` enum (lists all available permissions). Use the `has_permission` function in RLS policies and application code for granular access control.
{% sequence title="RBAC Implementation" description="Set up and use roles and permissions" %}
[Understand the data model](#rbac-data-model)
[Add custom permissions](#adding-custom-permissions)
[Enforce in RLS policies](#using-permissions-in-rls)
[Check permissions in code](#checking-permissions-in-application-code)
[Show/hide UI elements](#client-side-permission-checks)
{% /sequence %}
## RBAC Data Model
### The roles Table
Defines available roles and their hierarchy:
```sql
create table public.roles (
name varchar(50) primary key,
hierarchy_level integer not null default 0
);
-- Default roles
insert into public.roles (name, hierarchy_level) values
('owner', 1),
('member', 2);
```
**Hierarchy levels** determine which roles can manage others. Lower numbers indicate higher privilege. Owners (level 1) can manage members (level 2), but members cannot manage owners.
### The role_permissions Table
Maps roles to their permissions:
```sql
create table public.role_permissions (
id serial primary key,
role varchar(50) references public.roles(name) on delete cascade,
permission app_permissions not null,
unique (role, permission)
);
```
### The app_permissions Enum
Lists all available permissions:
```sql
create type public.app_permissions as enum(
'roles.manage',
'billing.manage',
'settings.manage',
'members.manage',
'invites.manage'
);
```
### Default Permission Assignments
| Role | Permissions |
|------|-------------|
| `owner` | All permissions |
| `member` | `settings.manage`, `invites.manage` |
## Adding Custom Permissions
### Step 1: Add to the Enum
Create a migration to add new permissions:
```sql {% title="apps/web/supabase/migrations/add_task_permissions.sql" %}
-- Add new permissions to the enum
alter type public.app_permissions add value 'tasks.read';
alter type public.app_permissions add value 'tasks.write';
alter type public.app_permissions add value 'tasks.delete';
commit;
```
{% alert type="warning" title="Enum Values Cannot Be Removed" %}
PostgreSQL enum values cannot be removed once added. Plan your permission names carefully. Use a consistent naming pattern like `resource.action`.
{% /alert %}
### Step 2: Assign to Roles
```sql
-- Owners get all task permissions
insert into public.role_permissions (role, permission) values
('owner', 'tasks.read'),
('owner', 'tasks.write'),
('owner', 'tasks.delete');
-- Members can read and write but not delete
insert into public.role_permissions (role, permission) values
('member', 'tasks.read'),
('member', 'tasks.write');
```
### Step 3: Add Custom Roles (Optional)
```sql
-- Add a new role
insert into public.roles (name, hierarchy_level) values
('admin', 1); -- Between owner (0) and member (2)
-- Assign permissions to the new role
insert into public.role_permissions (role, permission) values
('admin', 'tasks.read'),
('admin', 'tasks.write'),
('admin', 'tasks.delete'),
('admin', 'members.manage'),
('admin', 'invites.manage');
```
## Using Permissions in RLS
The `has_permission` function checks if a user has a specific permission on an account.
### Function Signature
```sql
public.has_permission(
user_id uuid,
account_id uuid,
permission_name app_permissions
) returns boolean
```
### Read Access Policy
```sql
create policy "Users with tasks.read can view tasks"
on public.tasks
for select
to authenticated
using (
public.has_permission(auth.uid(), account_id, 'tasks.read'::app_permissions)
);
```
### Write Access Policy
```sql
create policy "Users with tasks.write can create tasks"
on public.tasks
for insert
to authenticated
with check (
public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)
);
```
### Update Policy
```sql
create policy "Users with tasks.write can update tasks"
on public.tasks
for update
to authenticated
using (
public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)
)
with check (
public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)
);
```
### Delete Policy
```sql
create policy "Users with tasks.delete can delete tasks"
on public.tasks
for delete
to authenticated
using (
public.has_permission(auth.uid(), account_id, 'tasks.delete'::app_permissions)
);
```
### Complete Example
Here's a full schema with RLS:
```sql {% title="apps/web/supabase/schemas/20-tasks.sql" %}
-- Tasks table
create table if not exists public.tasks (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
title text not null,
description text,
status text not null default 'pending',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Enable RLS
alter table public.tasks enable row level security;
-- RLS policies
create policy "tasks_select" on public.tasks
for select to authenticated
using (public.has_permission(auth.uid(), account_id, 'tasks.read'::app_permissions));
create policy "tasks_insert" on public.tasks
for insert to authenticated
with check (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions));
create policy "tasks_update" on public.tasks
for update to authenticated
using (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions))
with check (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions));
create policy "tasks_delete" on public.tasks
for delete to authenticated
using (public.has_permission(auth.uid(), account_id, 'tasks.delete'::app_permissions));
```
## Checking Permissions in Application Code
### Server-Side Check (Server Actions)
```tsx {% title="apps/web/lib/server/tasks/create-task.action.ts" %}
'use server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import * as z from 'zod';
const schema = z.object({
accountId: z.string().uuid(),
title: z.string().min(1),
});
export async function createTask(data: z.infer<typeof schema>) {
const supabase = getSupabaseServerClient();
// Get current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Not authenticated');
}
// Check permission via RPC
const { data: hasPermission } = await supabase.rpc('has_permission', {
user_id: user.id,
account_id: data.accountId,
permission: 'tasks.write',
});
if (!hasPermission) {
throw new Error('You do not have permission to create tasks');
}
// Create the task (RLS will also enforce this)
const { data: task, error } = await supabase
.from('tasks')
.insert({
account_id: data.accountId,
title: data.title,
})
.select()
.single();
if (error) {
throw error;
}
return task;
}
```
### Permission Check Helper
Create a reusable helper:
```tsx {% title="apps/web/lib/server/permissions.ts" %}
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function checkPermission(
accountId: string,
permission: string,
): Promise<boolean> {
const supabase = getSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return false;
}
const { data: hasPermission } = await supabase.rpc('has_permission', {
user_id: user.id,
account_id: accountId,
permission,
});
return hasPermission ?? false;
}
export async function requirePermission(
accountId: string,
permission: string,
): Promise<void> {
const hasPermission = await checkPermission(accountId, permission);
if (!hasPermission) {
throw new Error(`Permission denied: ${permission}`);
}
}
```
Usage:
```tsx
import { requirePermission } from '~/lib/server/permissions';
export async function deleteTask(taskId: string, accountId: string) {
await requirePermission(accountId, 'tasks.delete');
// Proceed with deletion
}
```
## Client-Side Permission Checks
The Team Account Workspace loader provides permissions for UI rendering.
### Loading Permissions
```tsx {% title="apps/web/app/[locale]/home/[account]/tasks/page.tsx" %}
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
interface Props {
params: Promise<{ account: string }>;
}
export default async function TasksPage({ params }: Props) {
const { account } = await params;
const workspace = await loadTeamWorkspace(account);
const permissions = workspace.account.permissions;
// permissions is string[] of permission names the user has
return (
<TasksPageClient permissions={permissions} />
);
}
```
### Conditional UI Rendering
```tsx {% title="apps/web/app/[locale]/home/[account]/tasks/_components/tasks-page-client.tsx" %}
'use client';
interface TasksPageClientProps {
permissions: string[];
}
export function TasksPageClient({ permissions }: TasksPageClientProps) {
const canWrite = permissions.includes('tasks.write');
const canDelete = permissions.includes('tasks.delete');
return (
<div>
<h1>Tasks</h1>
{canWrite && (
<Button onClick={openCreateDialog}>
Create Task
</Button>
)}
<TaskList
onDelete={canDelete ? handleDelete : undefined}
/>
</div>
);
}
```
### Permission Gate Component
Create a reusable component:
```tsx {% title="apps/web/components/permission-gate.tsx" %}
'use client';
interface PermissionGateProps {
permissions: string[];
required: string | string[];
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function PermissionGate({
permissions,
required,
children,
fallback = null,
}: PermissionGateProps) {
const requiredArray = Array.isArray(required) ? required : [required];
const hasPermission = requiredArray.every((p) => permissions.includes(p));
if (!hasPermission) {
return fallback;
}
return children;
}
```
Usage:
```tsx
<PermissionGate permissions={permissions} required="tasks.delete">
<DeleteButton onClick={handleDelete} />
</PermissionGate>
<PermissionGate
permissions={permissions}
required={['tasks.write', 'tasks.delete']}
fallback={<span>Read-only access</span>}
>
<EditControls />
</PermissionGate>
```
### Page-Level Access Control
```tsx {% title="apps/web/app/[locale]/home/[account]/admin/page.tsx" %}
import { redirect } from 'next/navigation';
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';
interface Props {
params: Promise<{ account: string }>;
}
export default async function AdminPage({ params }: Props) {
const { account } = await params;
const workspace = await loadTeamWorkspace(account);
const permissions = workspace.account.permissions;
if (!permissions.includes('settings.manage')) {
redirect('/home');
}
return <AdminDashboard />;
}
```
## Permission Naming Conventions
Use a consistent `resource.action` pattern:
| Pattern | Examples |
|---------|----------|
| `resource.read` | `tasks.read`, `reports.read` |
| `resource.write` | `tasks.write`, `settings.write` |
| `resource.delete` | `tasks.delete`, `members.delete` |
| `resource.manage` | `billing.manage`, `roles.manage` |
The `.manage` suffix typically implies all actions on that resource.
## Testing Permissions
Test RLS policies with pgTAP:
```sql {% title="apps/web/supabase/tests/tasks-permissions.test.sql" %}
begin;
select plan(3);
-- Create test user and account
select tests.create_supabase_user('test-user');
select tests.authenticate_as('test-user');
-- Get the user's personal account
select set_config('test.account_id',
(select id::text from accounts where primary_owner_user_id = tests.get_supabase_uid('test-user')),
true
);
-- Test: User with tasks.write can insert
select lives_ok(
$$
insert into tasks (account_id, title)
values (current_setting('test.account_id')::uuid, 'Test Task')
$$,
'User with tasks.write permission can create tasks'
);
-- Test: User without tasks.delete cannot delete
select throws_ok(
$$
delete from tasks
where account_id = current_setting('test.account_id')::uuid
$$,
'User without tasks.delete permission cannot delete tasks'
);
select * from finish();
rollback;
```
See [Database Tests](/docs/next-supabase-turbo/development/database-tests) for more testing patterns.
## Related Resources
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) for the `has_permission` function
- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for creating tables with RLS
- [Database Tests](/docs/next-supabase-turbo/development/database-tests) for testing permissions
- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) for RLS patterns

432
docs/development/seo.mdoc Normal file
View File

@@ -0,0 +1,432 @@
---
status: "published"
label: "SEO"
title: "SEO Configuration for the Next.js Supabase Starter Kit"
description: "Configure sitemaps, metadata, structured data, and search engine optimization for your Makerkit SaaS application."
order: 10
---
SEO in Makerkit starts with Next.js Metadata API for page-level optimization, an auto-generated sitemap at `/sitemap.xml`, and proper robots.txt configuration. The kit handles technical SEO out of the box, so you can focus on content quality and backlink strategy.
{% sequence title="SEO Configuration" description="Set up search engine optimization for your SaaS" %}
[Configure page metadata](#page-metadata)
[Customize the sitemap](#sitemap-configuration)
[Add structured data](#structured-data)
[Submit to Google Search Console](#google-search-console)
{% /sequence %}
## Page Metadata
### Next.js Metadata API
Use the Next.js Metadata API to set page-level SEO:
```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %}
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Pricing | Your SaaS Name',
description: 'Simple, transparent pricing. Start free, upgrade when you need more.',
openGraph: {
title: 'Pricing | Your SaaS Name',
description: 'Simple, transparent pricing for teams of all sizes.',
images: ['/images/og/pricing.png'],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Pricing | Your SaaS Name',
description: 'Simple, transparent pricing for teams of all sizes.',
images: ['/images/og/pricing.png'],
},
};
export default function PricingPage() {
// ...
}
```
### Dynamic Metadata
For pages with dynamic content, use `generateMetadata`:
```tsx {% title="apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx" %}
import type { Metadata } from 'next';
import { createCmsClient } from '@kit/cms';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const cms = await createCmsClient();
const post = await cms.getContentBySlug({ slug, collection: 'posts' });
return {
title: `${post.title} | Your SaaS Blog`,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [post.image],
type: 'article',
publishedTime: post.publishedAt,
},
};
}
```
### Global Metadata
Set default metadata in your root layout at `apps/web/app/layout.tsx`:
```tsx {% title="apps/web/app/layout.tsx" %}
import type { Metadata } from 'next';
import appConfig from '~/config/app.config';
export const metadata: Metadata = {
title: {
default: appConfig.name,
template: `%s | ${appConfig.name}`,
},
description: appConfig.description,
metadataBase: new URL(appConfig.url),
openGraph: {
type: 'website',
locale: 'en_US',
siteName: appConfig.name,
},
robots: {
index: true,
follow: true,
},
};
```
## Sitemap Configuration
Makerkit auto-generates a sitemap at `/sitemap.xml`. The configuration lives in `apps/web/app/sitemap.xml/route.ts`.
### Adding Static Pages
Add new pages to the `getPaths` function:
```tsx {% title="apps/web/app/sitemap.xml/route.ts" %}
import appConfig from '~/config/app.config';
function getPaths() {
const paths = [
'/',
'/pricing',
'/faq',
'/blog',
'/docs',
'/contact',
'/about', // Add new pages
'/features',
'/privacy-policy',
'/terms-of-service',
'/cookie-policy',
];
return paths.map((path) => ({
loc: new URL(path, appConfig.url).href,
lastmod: new Date().toISOString(),
}));
}
```
### Dynamic Content
Blog posts and documentation pages are automatically added to the sitemap. The CMS integration handles this:
```tsx
// Blog posts are added automatically
const posts = await cms.getContentItems({ collection: 'posts' });
posts.forEach((post) => {
sitemap.push({
loc: new URL(`/blog/${post.slug}`, appConfig.url).href,
lastmod: post.updatedAt || post.publishedAt,
});
});
```
### Excluding Pages
Exclude pages from the sitemap by not including them in `getPaths()`. For pages that should not be indexed at all, use the `robots` metadata:
```tsx
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};
```
## Structured Data
Add JSON-LD structured data for rich search results. See the [Next.js JSON-LD guide](https://nextjs.org/docs/app/guides/json-ld) for the recommended approach.
### Organization Schema
Add to your home page or layout:
```tsx {% title="apps/web/app/[locale]/(marketing)/page.tsx" %}
// JSON-LD structured data using a script tag
export default function HomePage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Your SaaS Name',
url: 'https://yoursaas.com',
logo: 'https://yoursaas.com/logo.png',
sameAs: [
'https://twitter.com/yoursaas',
'https://github.com/yoursaas',
],
}),
}}
/>
{/* Page content */}
</>
);
}
```
### Product Schema
Add to your pricing page:
```tsx {% title="apps/web/app/[locale]/(marketing)/pricing/page.tsx" %}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Your SaaS Name',
applicationCategory: 'BusinessApplication',
offers: {
'@type': 'AggregateOffer',
lowPrice: '0',
highPrice: '99',
priceCurrency: 'USD',
offerCount: 3,
},
}),
}}
/>
```
### FAQ Schema
Use the Markdoc FAQ node for automatic FAQ schema:
```markdown
{% faq
title="Frequently Asked Questions"
items=[
{"question": "How do I get started?", "answer": "Sign up for a free account..."},
{"question": "Can I cancel anytime?", "answer": "Yes, you can cancel..."}
]
/%}
```
### Article Schema
Add to blog posts:
```tsx
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.description,
image: post.image,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author,
},
}),
}}
/>
```
## Robots.txt
The robots.txt is generated dynamically at `apps/web/app/robots.ts`:
```typescript {% title="apps/web/app/robots.ts" %}
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/home/', '/admin/', '/api/'],
},
sitemap: 'https://yoursaas.com/sitemap.xml',
};
}
```
Update the sitemap URL to your production domain.
## Google Search Console
### Verification
1. Go to [Google Search Console](https://search.google.com/search-console)
2. Add your property (URL prefix method)
3. Choose verification method:
- **HTML tag**: Add to your root layout's metadata
- **HTML file**: Upload to `public/`
```tsx
// HTML tag verification
export const metadata: Metadata = {
verification: {
google: 'your-verification-code',
},
};
```
### Submit Sitemap
After verification:
1. Navigate to **Sitemaps** in Search Console
2. Enter `sitemap.xml` in the input field
3. Click **Submit**
Google will crawl and index your sitemap within a few days.
### Monitor Indexing
Check Search Console regularly for:
- **Coverage**: Pages indexed vs. excluded
- **Enhancements**: Structured data validation
- **Core Web Vitals**: Performance metrics
- **Mobile Usability**: Mobile-friendly issues
## SEO Best Practices
### Content Quality
Content quality matters more than technical SEO. Focus on:
- **Helpful content**: Solve problems your customers search for
- **Unique value**: Offer insights competitors don't have
- **Regular updates**: Keep content fresh and accurate
- **Comprehensive coverage**: Answer related questions
### Keyword Strategy
| Element | Recommendation |
|---------|----------------|
| Title | Primary keyword near the beginning |
| Description | Include keyword naturally, focus on click-through |
| H1 | One per page, include primary keyword |
| URL | Short, descriptive, include keyword |
| Content | Use variations naturally, don't stuff |
### Image Optimization
```tsx
import Image from 'next/image';
<Image
src="/images/feature-screenshot.webp"
alt="Dashboard showing project analytics with team activity"
width={1200}
height={630}
priority={isAboveFold}
/>
```
- Use WebP format for better compression
- Include descriptive alt text with keywords
- Use descriptive filenames (`project-dashboard.webp` not `img1.webp`)
- Size images appropriately for their display size
### Internal Linking
Link between related content:
```tsx
// In your blog post about authentication
<p>
Learn more about{' '}
<Link href="/docs/authentication/setup">
setting up authentication
</Link>{' '}
in our documentation.
</p>
```
### Page Speed
Makerkit is optimized for performance out of the box:
- Next.js automatic code splitting
- Image optimization with `next/image`
- Font optimization with `next/font`
- Static generation for marketing pages
Check your scores with [PageSpeed Insights](https://pagespeed.web.dev/).
## Backlinks
Backlinks remain the strongest ranking factor. Strategies that work:
| Strategy | Effort | Impact |
|----------|--------|--------|
| Create linkable content (guides, tools, research) | High | High |
| Guest posting on relevant blogs | Medium | Medium |
| Product directories (Product Hunt, etc.) | Low | Medium |
| Open source contributions | Medium | Medium |
| Podcast appearances | Medium | Medium |
Focus on quality over quantity. One link from a high-authority site beats dozens of low-quality links.
## Timeline Expectations
SEO takes time. Typical timelines:
| Milestone | Timeline |
|-----------|----------|
| Initial indexing | 1-2 weeks |
| Rankings for low-competition terms | 1-3 months |
| Rankings for medium-competition terms | 3-6 months |
| Rankings for high-competition terms | 6-12+ months |
Keep creating content and building backlinks. Results compound over time.
## Related Resources
- [Marketing Pages](/docs/next-supabase-turbo/development/marketing-pages) for building optimized landing pages
- [CMS Setup](/docs/next-supabase-turbo/content/cms) for content marketing
- [App Configuration](/docs/next-supabase-turbo/configuration/application-configuration) for base URL and metadata settings

View File

@@ -0,0 +1,294 @@
---
status: "published"
label: "Writing data to Database"
order: 5
title: "Learn how to write data to the Supabase database in your Next.js app"
description: "In this page we learn how to write data to the Supabase database in your Next.js app"
---
In this page, we will learn how to write data to the Supabase database in your Next.js app.
{% sequence title="How to write data to the Supabase database" description="In this page we learn how to write data to the Supabase database in your Next.js app" %}
[Writing a Server Action to Add a Task](#writing-a-server-action-to-add-a-task)
[Defining a Schema for the Task](#defining-a-schema-for-the-task)
[Writing the Server Action to Add a Task](#writing-the-server-action-to-add-a-task)
[Creating a Form to Add a Task](#creating-a-form-to-add-a-task)
[Using a Dialog component to display the form](#using-a-dialog-component-to-display-the-form)
{% /sequence %}
## Writing a Server Action to Add a Task
Server Actions are defined by adding `use server` at the top of the function or file. When we define a function as a Server Action, it will be executed on the server-side.
This is useful for various reasons:
1. By using Server Actions, we can revalidate data fetched through Server Components
2. We can execute server side code just by calling the function from the client side
In this example, we will write a Server Action to add a task to the database.
### Defining a Schema for the Task
We use Zod to validate the data that is passed to the Server Action. This ensures that the data is in the correct format before it is written to the database.
The convention in Makerkit is to define the schema in a separate file and import it where needed. We use the convention `file.schema.ts` to define the schema.
```tsx
import * as z from 'zod';
export const WriteTaskSchema = z.object({
title: z.string().min(1),
description: z.string().nullable(),
});
```
### Writing the Server Action to Add a Task
In this example, we write a Server Action to add a task to the database. We use the `revalidatePath` function to revalidate the `/home` page after the task is added.
```tsx
'use server';
import { revalidatePath } from 'next/cache';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { authActionClient } from '@kit/next/safe-action';
import { WriteTaskSchema } from '~/home/(user)/_lib/schema/write-task.schema';
export const addTaskAction = authActionClient
.inputSchema(WriteTaskSchema)
.action(async ({ parsedInput: task, ctx: { user } }) => {
const logger = await getLogger();
const client = getSupabaseServerClient();
logger.info(task, `Adding task...`);
const { data, error } = await client
.from('tasks')
.insert({ ...task, account_id: user.id });
if (error) {
logger.error(error, `Failed to add task`);
throw new Error(`Failed to add task`);
}
logger.info(data, `Task added successfully`);
revalidatePath('/home', 'page');
});
```
Let's focus on this bit for a second:
```tsx
const { data, error } = await client
.from('tasks')
.insert({ ...task, account_id: auth.data.id });
```
Do you see the `account_id` field? This is a foreign key that links the task to the user who created it. This is a common pattern in database design.
Now that we have written the Server Action to add a task, we can call this function from the client side. But we need a form, which we define in the next section.
### Creating a Form to Add a Task
We create a form to add a task. The form is a React component that accepts a `SubmitButton` prop and an `onSubmit` prop.
```tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { Trans } from '@kit/ui/trans';
import { WriteTaskSchema } from '../_lib/schema/write-task.schema';
export function TaskForm(props: {
task?: z.infer<typeof WriteTaskSchema>;
onSubmit: (task: z.infer<typeof WriteTaskSchema>) => void;
SubmitButton: React.ComponentType;
}) {
const form = useForm({
resolver: zodResolver(WriteTaskSchema),
defaultValues: props.task,
});
return (
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(props.onSubmit)}
>
<FormField
render={(item) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'tasks:taskTitle'} />
</FormLabel>
<FormControl>
<Input required {...item.field} />
</FormControl>
<FormDescription>
<Trans i18nKey={'tasks:taskTitleDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
name={'title'}
/>
<FormField
render={(item) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'tasks:taskDescription'} />
</FormLabel>
<FormControl>
<Textarea {...item.field} />
</FormControl>
<FormDescription>
<Trans i18nKey={'tasks:taskDescriptionDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
name={'description'}
/>
<props.SubmitButton />
</form>
</Form>
);
}
```
### Using a Dialog component to display the form
We use the Dialog component from the `@kit/ui/dialog` package to display the form in a dialog. The dialog is opened when the user clicks on a button.
```tsx
'use client';
import { useState, useTransition } from 'react';
import { PlusCircle } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Trans } from '@kit/ui/trans';
import { TaskForm } from '../_components/task-form';
import { addTaskAction } from '../_lib/server/server-actions';
export function NewTaskDialog() {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
<PlusCircle className={'mr-1 h-4'} />
<span>
<Trans i18nKey={'tasks:addNewTask'} />
</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'tasks:addNewTask'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'tasks:addNewTaskDescription'} />
</DialogDescription>
</DialogHeader>
<TaskForm
SubmitButton={() => (
<Button>
{pending ? (
<Trans i18nKey={'tasks:addingTask'} />
) : (
<Trans i18nKey={'tasks:addTask'} />
)}
</Button>
)}
onSubmit={(data) => {
startTransition(async () => {
await addTaskAction(data);
setIsOpen(false);
});
}}
/>
</DialogContent>
</Dialog>
);
}
```
We can now import `NewTaskDialog` in the `/home` page and display the dialog when the user clicks on a button.
Let's go back to the home page and add the component right next to the input filter:
```tsx {18}
<div className={'flex items-center justify-between'}>
<div>
<Heading level={4}>
<Trans i18nKey={'tasks:tasksTabLabel'} defaults={'Tasks'} />
</Heading>
</div>
<div className={'flex items-center space-x-2'}>
<form className={'w-full'}>
<Input
name={'query'}
defaultValue={query}
className={'w-full lg:w-[18rem]'}
placeholder={'Search tasks'}
/>
</form>
<NewTaskDialog />
</div>
</div>
```

View File

@@ -0,0 +1,242 @@
---
status: "published"
title: "Supabase Authentication Email Templates"
label: "Authentication Emails"
description: "Configure Supabase Auth email templates for email verification, password reset, and magic links. Learn about PKCE flow and token hash strategy for cross-browser compatibility."
order: 3
---
Supabase Auth sends emails for authentication flows like email verification, password reset, and magic links. MakerKit provides custom templates that use the token hash strategy, which is required for PKCE (Proof Key for Code Exchange) authentication to work correctly across different browsers and devices.
## Why Custom Templates Matter
Supabase's default email templates use a redirect-based flow that can break when users open email links in a different browser than where they started authentication. This happens because:
1. User starts sign-up in Chrome
2. Clicks verification link in their email client (which may open in Safari)
3. Safari doesn't have the PKCE code verifier stored
4. Authentication fails
MakerKit's templates solve this by using the **token hash strategy**, which passes the verification token directly to a server-side endpoint that exchanges it for a session.
{% alert type="warning" title="Required for Production" %}
You must replace Supabase's default email templates with MakerKit's templates. Without this change, users may experience authentication failures when clicking email links from different browsers or email clients.
{% /alert %}
## Template Types
Supabase Auth uses six email templates:
| Template | Trigger | Purpose |
|----------|---------|---------|
| Confirm signup | New user registration | Verify email address |
| Magic link | Passwordless login | One-click sign in |
| Change email | Email update request | Verify new email |
| Reset password | Password reset request | Secure password change |
| Invite user | Admin invites user | Join the platform |
| OTP | Code-based verification | Numeric verification code |
## Template Location
MakerKit's pre-built templates are in your project:
```
apps/web/supabase/templates/
├── change-email-address.html
├── confirm-email.html
├── invite-user.html
├── magic-link.html
├── otp.html
└── reset-password.html
```
## How the Token Hash Strategy Works
The templates use this URL format:
```
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }}
```
This flow:
1. Supabase generates a `token_hash` for the email action
2. User clicks the link in their email
3. Request goes to `/auth/confirm` (a server-side route)
4. Server exchanges the token hash for a session using Supabase Admin API
5. User is authenticated and redirected to the callback URL
This works regardless of which browser opens the link because the token hash is self-contained.
## Configuring Templates in Supabase
### Option 1: Using Supabase Dashboard
1. Go to your Supabase project dashboard
2. Navigate to **Authentication** → **Email Templates**
3. For each template type, replace the content with the corresponding MakerKit template
4. Save changes
### Option 2: Using Supabase CLI (Recommended)
The templates in `apps/web/supabase/templates/` are automatically applied when you run migrations:
```bash
supabase db push
```
Or link and push:
```bash
supabase link --project-ref your-project-ref
supabase db push
```
## Customizing Templates
To customize the templates with your branding:
### 1. Clone the Email Starter Repository
MakerKit provides a separate repository for generating email templates:
```bash
git clone https://github.com/makerkit/makerkit-emails-starter
cd makerkit-emails-starter
pnpm install
```
### 2. Customize the Templates
The starter uses React Email. Edit the templates in `src/emails/`:
- Update colors to match your brand
- Add your logo
- Modify copy and messaging
- Adjust layout as needed
### 3. Export the Templates
Generate the HTML templates:
```bash
pnpm build
```
### 4. Replace Templates in Your Project
Copy the generated HTML files to your MakerKit project:
```bash
cp dist/*.html /path/to/your-app/apps/web/supabase/templates/
```
### 5. Push to Supabase
```bash
cd /path/to/your-app
supabase db push
```
## Template Variables
Supabase provides these variables in email templates:
| Variable | Description | Available In |
|----------|-------------|--------------|
| `{{ .SiteURL }}` | Your application URL | All templates |
| `{{ .TokenHash }}` | Verification token | All templates |
| `{{ .RedirectTo }}` | Callback URL after auth | All templates |
| `{{ .Email }}` | User's email address | All templates |
| `{{ .Token }}` | OTP code (6 digits) | OTP template only |
| `{{ .ConfirmationURL }}` | Legacy redirect URL | All templates (not recommended) |
## Example: Confirm Email Template
Here's the structure of a confirmation email template:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>Confirm your email</h1>
<p>Click the button below to confirm your email address.</p>
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }}">
Confirm Email
</a>
<p>If you didn't create an account, you can safely ignore this email.</p>
</body>
</html>
```
The key is the URL structure: `/auth/confirm?token_hash={{ .TokenHash }}&type=email&callback={{ .RedirectTo }}`
## Configuring the Auth Confirm Route
MakerKit includes the server-side route that handles token exchange at `apps/web/app/[locale]/auth/confirm/route.ts` in your project. This route:
1. Receives the token hash from the email link
2. Verifies the token with Supabase
3. Creates a session
4. Redirects the user to their destination
You don't need to modify this route unless you have custom requirements.
## Testing Authentication Emails
### Local Development
In local development, emails are captured by Mailpit:
1. Start your development server: `pnpm dev`
2. Trigger an auth action (sign up, password reset, etc.)
3. Open Mailpit at [http://localhost:54324](http://localhost:54324)
4. Find and click the verification link
### Production Testing
Before going live:
1. Create a test account with a real email address
2. Verify the email arrives and looks correct
3. Click the verification link and confirm it works
4. Test in multiple email clients (Gmail, Outlook, Apple Mail)
5. Test opening links in different browsers
## Troubleshooting
### Links Not Working
If email links fail to authenticate:
- Verify templates use `token_hash` not `ConfirmationURL`
- Check that `{{ .SiteURL }}` matches your production URL
- Ensure the `/auth/confirm` route is deployed
### Emails Not Arriving
If emails don't arrive:
- Check Supabase's email logs in the dashboard
- Verify your email provider (Supabase's built-in or custom SMTP) is configured
- Check spam folders
- For production, configure a custom SMTP provider in Supabase settings
### Wrong Redirect URL
If users are redirected incorrectly:
- Check the `callback` parameter in your auth calls
- Verify `NEXT_PUBLIC_SITE_URL` is set correctly
- Ensure redirect URLs are in Supabase's allowed list
## Next Steps
- [Configure your email provider](/docs/next-supabase-turbo/email-configuration) for transactional emails
- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email
- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit

View File

@@ -0,0 +1,377 @@
---
status: "published"
title: "Creating a Custom Mailer"
label: "Custom Mailer"
description: "Integrate third-party email providers like SendGrid, Postmark, or AWS SES into your Next.js Supabase application by creating a custom mailer implementation."
order: 5
---
MakerKit's mailer system is designed to be extensible. While Nodemailer and Resend cover most use cases, you may need to integrate a different email provider like SendGrid, Postmark, Mailchimp Transactional, or AWS SES.
This guide shows you how to create a custom mailer that plugs into MakerKit's email system.
## Mailer Architecture
The mailer system uses a registry pattern with lazy loading:
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Application │────▶│ Mailer Registry │────▶│ Your Provider │
│ getMailer() │ │ (lazy loading) │ │ (SendGrid etc) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
```
1. Your code calls `getMailer()`
2. The registry checks `MAILER_PROVIDER` environment variable
3. The matching mailer implementation is loaded and returned
4. You call `sendEmail()` on the returned instance
## Creating a Custom Mailer
Let's create a mailer for SendGrid as an example. The same pattern works for any provider.
### Step 1: Implement the Mailer Class
Create a new file in the mailers package:
```tsx {% title="packages/mailers/sendgrid/src/index.ts" %}
import 'server-only';
import * as z from 'zod';
import { Mailer, MailerSchema } from '@kit/mailers-shared';
type Config = z.infer<typeof MailerSchema>;
const SENDGRID_API_KEY = z
.string({
description: 'SendGrid API key',
required_error: 'SENDGRID_API_KEY environment variable is required',
})
.parse(process.env.SENDGRID_API_KEY);
export function createSendGridMailer() {
return new SendGridMailer();
}
class SendGridMailer implements Mailer {
async sendEmail(config: Config) {
const body = {
personalizations: [
{
to: [{ email: config.to }],
},
],
from: { email: config.from },
subject: config.subject,
content: [
{
type: 'text' in config ? 'text/plain' : 'text/html',
value: 'text' in config ? config.text : config.html,
},
],
};
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${SENDGRID_API_KEY}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`SendGrid error: ${response.status} - ${error}`);
}
return { success: true };
}
}
```
### Step 2: Create Package Structure
If creating a separate package (recommended), set up the structure:
```
packages/mailers/sendgrid/
├── src/
│ └── index.ts
├── package.json
└── tsconfig.json
```
**package.json:**
```json {% title="packages/mailers/sendgrid/package.json" %}
{
"name": "@kit/sendgrid",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"@kit/mailers-shared": "workspace:*",
"server-only": "^0.0.1",
"zod": "catalog:"
}
}
```
**tsconfig.json:**
```json {% title="packages/mailers/sendgrid/tsconfig.json" %}
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
```
### Step 3: Install the Package
Add the new package as a dependency to the mailers core package:
```bash
pnpm i "@kit/sendgrid:workspace:*" --filter "@kit/mailers"
```
### Step 4: Register the Mailer
Add your mailer to the registry:
```tsx {% title="packages/mailers/core/src/registry.ts" %}
import { Mailer } from '@kit/mailers-shared';
import { createRegistry } from '@kit/shared/registry';
import { MailerProvider } from './provider-enum';
const mailerRegistry = createRegistry<Mailer, MailerProvider>();
// Existing mailers
mailerRegistry.register('nodemailer', async () => {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { createNodemailerService } = await import('@kit/nodemailer');
return createNodemailerService();
} else {
throw new Error(
'Nodemailer is not available on the edge runtime. Please use another mailer.',
);
}
});
mailerRegistry.register('resend', async () => {
const { createResendMailer } = await import('@kit/resend');
return createResendMailer();
});
// Add your custom mailer
mailerRegistry.register('sendgrid', async () => {
const { createSendGridMailer } = await import('@kit/sendgrid');
return createSendGridMailer();
});
export { mailerRegistry };
```
### Step 5: Update Provider Enum
Add your provider to the providers array:
```tsx {% title="packages/mailers/core/src/provider-enum.ts" %}
import * as z from 'zod';
const MAILER_PROVIDERS = [
'nodemailer',
'resend',
'sendgrid', // Add this
] as const;
const MAILER_PROVIDER = z
.enum(MAILER_PROVIDERS)
.default('nodemailer')
.parse(process.env.MAILER_PROVIDER);
export { MAILER_PROVIDER };
export type MailerProvider = (typeof MAILER_PROVIDERS)[number];
```
### Step 6: Configure Environment Variables
Set the environment variable to use your mailer:
```bash
MAILER_PROVIDER=sendgrid
SENDGRID_API_KEY=SG.your-api-key-here
EMAIL_SENDER=YourApp <noreply@yourdomain.com>
```
## Edge Runtime Compatibility
If your mailer uses HTTP APIs (not SMTP), it can run on edge runtimes. The key requirements:
1. **No Node.js-specific APIs**: Avoid `fs`, `net`, `crypto` (use Web Crypto instead)
2. **Use fetch**: HTTP requests via `fetch` work everywhere
3. **Import server-only**: Add `import 'server-only'` to prevent client-side usage
### Checking Runtime Compatibility
```tsx
mailerRegistry.register('my-mailer', async () => {
// This check is optional but recommended for documentation
if (process.env.NEXT_RUNTIME === 'edge') {
// Edge-compatible path
const { createMyMailer } = await import('@kit/my-mailer');
return createMyMailer();
} else {
// Node.js path (can use SMTP, etc.)
const { createMyMailer } = await import('@kit/my-mailer');
return createMyMailer();
}
});
```
For Nodemailer (SMTP-based), edge runtime is not supported. For HTTP-based providers like Resend, SendGrid, or Postmark, edge runtime works fine.
## Example Implementations
### Postmark
```tsx
import 'server-only';
import * as z from 'zod';
import { Mailer, MailerSchema } from '@kit/mailers-shared';
const POSTMARK_API_KEY = z.string().parse(process.env.POSTMARK_API_KEY);
export function createPostmarkMailer() {
return new PostmarkMailer();
}
class PostmarkMailer implements Mailer {
async sendEmail(config: z.infer<typeof MailerSchema>) {
const body = {
From: config.from,
To: config.to,
Subject: config.subject,
...'text' in config
? { TextBody: config.text }
: { HtmlBody: config.html },
};
const response = await fetch('https://api.postmarkapp.com/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Postmark-Server-Token': POSTMARK_API_KEY,
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Postmark error: ${response.statusText}`);
}
return response.json();
}
}
```
### AWS SES (HTTP API)
```tsx
import 'server-only';
import * as z from 'zod';
import { Mailer, MailerSchema } from '@kit/mailers-shared';
// Using AWS SDK v3 (modular)
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
const sesClient = new SESClient({
region: process.env.AWS_REGION ?? 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export function createSESMailer() {
return new SESMailer();
}
class SESMailer implements Mailer {
async sendEmail(config: z.infer<typeof MailerSchema>) {
const command = new SendEmailCommand({
Source: config.from,
Destination: {
ToAddresses: [config.to],
},
Message: {
Subject: { Data: config.subject },
Body: 'text' in config
? { Text: { Data: config.text } }
: { Html: { Data: config.html } },
},
});
return sesClient.send(command);
}
}
```
## Testing Your Custom Mailer
Test your mailer in isolation before integrating:
```tsx
// test/sendgrid-mailer.test.ts
import { createSendGridMailer } from '@kit/sendgrid';
describe('SendGrid Mailer', () => {
it('sends an email', async () => {
const mailer = createSendGridMailer();
// Use a test email or mock the API
await mailer.sendEmail({
to: 'test@example.com',
from: 'noreply@yourdomain.com',
subject: 'Test Email',
text: 'This is a test email',
});
});
});
```
## Quick Integration (Without Separate Package)
For a faster setup without creating a separate package, add your mailer directly to the core package:
```tsx {% title="packages/mailers/core/src/sendgrid.ts" %}
import 'server-only';
import * as z from 'zod';
import { Mailer, MailerSchema } from '@kit/mailers-shared';
// ... implementation
```
Then register it:
```tsx {% title="packages/mailers/core/src/registry.ts" %}
mailerRegistry.register('sendgrid', async () => {
const { createSendGridMailer } = await import('./sendgrid');
return createSendGridMailer();
});
```
This approach is faster but puts all mailer code in one package. Use separate packages for cleaner separation.
## Next Steps
- [Configure your email provider](/docs/next-supabase-turbo/email-configuration) with environment variables
- [Create email templates](/docs/next-supabase-turbo/email-templates) with React Email
- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit

View File

@@ -0,0 +1,184 @@
---
status: "published"
label: "Email Configuration"
description: "Configure Nodemailer (SMTP) or Resend to send transactional emails from your Next.js Supabase application. Learn the difference between MakerKit emails and Supabase Auth emails."
title: "Email Configuration in the Next.js Supabase SaaS Starter Kit"
order: 0
---
MakerKit uses the `@kit/mailers` package to send transactional emails like team invitations and account notifications. You can choose between Nodemailer (any SMTP provider) or Resend (HTTP API).
## MakerKit vs Supabase Auth Emails
Before configuring your email provider, understand the two email systems in your application:
| Email Type | Purpose | Configuration | Examples |
|------------|---------|---------------|----------|
| **MakerKit Emails** | Application transactional emails | Environment variables | Team invitations, account deletion, OTP codes |
| **Supabase Auth Emails** | Authentication flows | Supabase Dashboard | Email verification, password reset, magic links |
This guide covers MakerKit email configuration. For Supabase Auth emails, see the [Authentication Emails](/docs/next-supabase-turbo/authentication-emails) guide.
## Choosing a Mailer Provider
MakerKit supports two mailer implementations:
### Nodemailer (Default)
Best for: Most production deployments using any SMTP provider (SendGrid, Mailgun, Amazon SES, etc.)
- Works with any SMTP server
- Requires Node.js runtime (not compatible with Edge)
- Full control over SMTP configuration
### Resend
Best for: Edge runtime deployments, simpler setup, or if you're already using Resend
- HTTP-based API (Edge compatible)
- No SMTP configuration needed
- Requires Resend account and API key
## Configuring Nodemailer
Nodemailer is the default provider. Set these environment variables in `apps/web/.env`:
```bash
MAILER_PROVIDER=nodemailer
# SMTP Configuration
EMAIL_HOST=smtp.your-provider.com
EMAIL_PORT=587
EMAIL_USER=your-smtp-username
EMAIL_PASSWORD=your-smtp-password
EMAIL_TLS=true
EMAIL_SENDER=YourApp <hello@yourapp.com>
```
### Environment Variables Explained
| Variable | Description | Example |
|----------|-------------|---------|
| `EMAIL_HOST` | SMTP server hostname | `smtp.sendgrid.net`, `email-smtp.us-east-1.amazonaws.com` |
| `EMAIL_PORT` | SMTP port (587 for STARTTLS, 465 for SSL) | `587` |
| `EMAIL_USER` | SMTP authentication username | Varies by provider |
| `EMAIL_PASSWORD` | SMTP authentication password or API key | Varies by provider |
| `EMAIL_TLS` | Use secure connection (`true` for SSL on port 465, `false` for STARTTLS on port 587) | `true` |
| `EMAIL_SENDER` | Sender name and email address | `MyApp <noreply@myapp.com>` |
**Note**: `EMAIL_TLS` maps to Nodemailer's `secure` option. When `true`, the connection uses SSL/TLS from the start (typically port 465). When `false`, the connection starts unencrypted and upgrades via STARTTLS (typically port 587). Most modern providers use port 587 with `EMAIL_TLS=false`.
### Provider-Specific Configuration
#### SendGrid
```bash
EMAIL_HOST=smtp.sendgrid.net
EMAIL_PORT=587
EMAIL_USER=apikey
EMAIL_PASSWORD=SG.your-api-key-here
EMAIL_TLS=true
EMAIL_SENDER=YourApp <verified-sender@yourdomain.com>
```
#### Amazon SES
```bash
EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
EMAIL_PORT=587
EMAIL_USER=your-ses-smtp-username
EMAIL_PASSWORD=your-ses-smtp-password
EMAIL_TLS=true
EMAIL_SENDER=YourApp <verified@yourdomain.com>
```
#### Mailgun
```bash
EMAIL_HOST=smtp.mailgun.org
EMAIL_PORT=587
EMAIL_USER=postmaster@your-domain.mailgun.org
EMAIL_PASSWORD=your-mailgun-password
EMAIL_TLS=true
EMAIL_SENDER=YourApp <noreply@your-domain.mailgun.org>
```
### EMAIL_SENDER Format
The `EMAIL_SENDER` variable accepts two formats:
```bash
# With display name (recommended)
EMAIL_SENDER=YourApp <hello@yourapp.com>
# Email only
EMAIL_SENDER=hello@yourapp.com
```
{% alert type="warn" title="Verify Your Sender Domain" %}
Most email providers require you to verify your sending domain before emails will be delivered. Check your provider's documentation for domain verification instructions.
{% /alert %}
## Configuring Resend
To use Resend instead of Nodemailer:
```bash
MAILER_PROVIDER=resend
RESEND_API_KEY=re_your-api-key
EMAIL_SENDER=YourApp <hello@yourapp.com>
```
### Getting a Resend API Key
1. Create an account at [resend.com](https://resend.com)
2. Add and verify your sending domain
3. Generate an API key from the dashboard
4. Add the key to your environment variables
{% alert type="default" title="Edge Runtime Compatible" %}
Resend uses HTTP requests instead of SMTP, making it compatible with Vercel Edge Functions and Cloudflare Workers. If you deploy to edge runtimes, Resend is the recommended choice.
{% /alert %}
## Verifying Your Configuration
After configuring your email provider, test it by triggering an email in your application:
1. Start your development server: `pnpm dev`
2. Sign up a new user and invite them to a team
3. Check that the invitation email arrives
For local development, emails are captured by Mailpit at [http://localhost:54324](http://localhost:54324). See the [Local Development](/docs/next-supabase-turbo/emails/inbucket) guide for details.
## Common Configuration Errors
### Connection Timeout
If emails fail with connection timeout errors:
- Verify `EMAIL_HOST` and `EMAIL_PORT` are correct
- Check if your hosting provider blocks outbound SMTP (port 25, 465, or 587)
- Some providers like Vercel block raw SMTP; use Resend instead
### Authentication Failed
If you see authentication errors:
- Double-check `EMAIL_USER` and `EMAIL_PASSWORD`
- Some providers use API keys as passwords (e.g., SendGrid uses `apikey` as username)
- Ensure your credentials have sending permissions
### Emails Not Delivered
If emails send but don't arrive:
- Verify your sender domain is authenticated (SPF, DKIM, DMARC)
- Check the spam folder
- Review your email provider's delivery logs
- Ensure `EMAIL_SENDER` uses a verified email address
## Next Steps
- [Send your first email](/docs/next-supabase-turbo/sending-emails) using the mailer API
- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email
- [Configure Supabase Auth emails](/docs/next-supabase-turbo/authentication-emails) for verification flows

View File

@@ -0,0 +1,350 @@
---
status: "published"
label: "Email Templates"
description: "Create branded email templates with React Email in your Next.js Supabase application. Learn the template architecture, i18n support, and how to build custom templates."
title: "Email Templates in the Next.js Supabase SaaS Starter Kit"
order: 2
---
MakerKit uses [React Email](https://react.email) to create type-safe, responsive email templates. Templates are stored in the `@kit/email-templates` package and support internationalization out of the box.
## Template Architecture
The email templates package is organized as follows:
```
packages/email-templates/
├── src/
│ ├── components/ # Reusable email components
│ │ ├── body-style.tsx
│ │ ├── content.tsx
│ │ ├── cta-button.tsx
│ │ ├── footer.tsx
│ │ ├── header.tsx
│ │ ├── heading.tsx
│ │ └── wrapper.tsx
│ ├── emails/ # Email templates
│ │ ├── account-delete.email.tsx
│ │ ├── invite.email.tsx
│ │ └── otp.email.tsx
│ ├── lib/
│ │ └── i18n.ts # i18n initialization
│ └── locales/ # Translation files
│ └── en/
│ ├── account-delete-email.json
│ ├── invite-email.json
│ └── otp-email.json
```
## Built-in Templates
MakerKit includes three email templates:
| Template | Function | Purpose |
|----------|----------|---------|
| Team Invitation | `renderInviteEmail` | Invite users to join a team |
| Account Deletion | `renderAccountDeleteEmail` | Confirm account deletion |
| OTP Code | `renderOtpEmail` | Send one-time password codes |
## Using Templates
Each template exports an async render function that returns HTML and a subject line:
```tsx
import { getMailer } from '@kit/mailers';
import { renderInviteEmail } from '@kit/email-templates';
async function sendInvitation() {
const { html, subject } = await renderInviteEmail({
teamName: 'Acme Corp',
teamLogo: 'https://example.com/logo.png', // optional
inviter: 'John Doe', // can be undefined if inviter is unknown
invitedUserEmail: 'jane@example.com',
link: 'https://app.example.com/invite/abc123',
productName: 'Your App',
language: 'en', // optional, defaults to NEXT_PUBLIC_DEFAULT_LOCALE
});
const mailer = await getMailer();
await mailer.sendEmail({
to: 'jane@example.com',
from: process.env.EMAIL_SENDER!,
subject,
html,
});
}
```
## Creating Custom Templates
To create a new email template:
### 1. Create the Template File
Create a new file in `packages/email-templates/src/emails/`:
```tsx {% title="packages/email-templates/src/emails/welcome.email.tsx" %}
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import { EmailContent } from '../components/content';
import { CtaButton } from '../components/cta-button';
import { EmailFooter } from '../components/footer';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
interface Props {
userName: string;
productName: string;
dashboardUrl: string;
language?: string;
}
export async function renderWelcomeEmail(props: Props) {
const namespace = 'welcome-email';
const { t } = await initializeEmailI18n({
language: props.language,
namespace,
});
const previewText = t('previewText', {
productName: props.productName,
});
const subject = t('subject', {
productName: props.productName,
});
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>
{t('heading', { productName: props.productName })}
</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t('hello', { userName: props.userName })}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t('mainText')}
</Text>
<CtaButton href={props.dashboardUrl}>
{t('ctaButton')}
</CtaButton>
</EmailContent>
<EmailFooter>{props.productName}</EmailFooter>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}
```
### 2. Create Translation File
Add a translation file in `packages/email-templates/src/locales/en/`:
```json {% title="packages/email-templates/src/locales/en/welcome-email.json" %}
{
"subject": "Welcome to {productName}",
"previewText": "Welcome to {productName} - Let's get started",
"heading": "Welcome to {productName}",
"hello": "Hi {userName},",
"mainText": "Thanks for signing up! We're excited to have you on board. Click the button below to access your dashboard and get started.",
"ctaButton": "Go to Dashboard"
}
```
### 3. Export the Template
Add the export to the package's index file:
```tsx {% title="packages/email-templates/src/index.ts" %}
export { renderInviteEmail } from './emails/invite.email';
export { renderAccountDeleteEmail } from './emails/account-delete.email';
export { renderOtpEmail } from './emails/otp.email';
export { renderWelcomeEmail } from './emails/welcome.email'; // Add this
```
### 4. Use the Template
```tsx
import { getMailer } from '@kit/mailers';
import { renderWelcomeEmail } from '@kit/email-templates';
async function sendWelcome(user: { email: string; name: string }) {
const { html, subject } = await renderWelcomeEmail({
userName: user.name,
productName: 'Your App',
dashboardUrl: 'https://app.example.com/dashboard',
});
const mailer = await getMailer();
await mailer.sendEmail({
to: user.email,
from: process.env.EMAIL_SENDER!,
subject,
html,
});
}
```
## Reusable Components
MakerKit provides styled components for consistent email design:
### EmailWrapper
The outer container with proper styling:
```tsx
<EmailWrapper>
{/* Email content */}
</EmailWrapper>
```
### EmailHeader and EmailHeading
Header section with title:
```tsx
<EmailHeader>
<EmailHeading>Your Email Title</EmailHeading>
</EmailHeader>
```
### EmailContent
Main content area with white background:
```tsx
<EmailContent>
<Text>Your email body text here.</Text>
</EmailContent>
```
### CtaButton
Call-to-action button:
```tsx
<CtaButton href="https://example.com/action">
Click Here
</CtaButton>
```
### EmailFooter
Footer with product name:
```tsx
<EmailFooter>Your Product Name</EmailFooter>
```
## Internationalization
Templates support multiple languages through the i18n system. The language is determined by:
1. The `language` prop passed to the render function
2. Falls back to `'en'` if no language specified
### Adding a New Language
Create a new locale folder:
```
packages/email-templates/src/locales/es/
```
Copy and translate the JSON files:
```json {% title="packages/email-templates/src/locales/es/invite-email.json" %}
{
"subject": "Te han invitado a unirte a {teamName}",
"heading": "Únete a {teamName} en {productName}",
"hello": "Hola {invitedUserEmail},",
"mainText": "<strong>{inviter}</strong> te ha invitado a unirte al equipo <strong>{teamName}</strong> en <strong>{productName}</strong>.",
"joinTeam": "Unirse a {teamName}",
"copyPasteLink": "O copia y pega este enlace en tu navegador:",
"invitationIntendedFor": "Esta invitación fue enviada a {invitedUserEmail}."
}
```
Pass the language when rendering:
```tsx
const { html, subject } = await renderInviteEmail({
// ... other props
language: 'es',
});
```
## Styling with Tailwind
React Email supports Tailwind CSS classes. The `<Tailwind>` wrapper enables Tailwind styling:
```tsx
<Tailwind>
<Body>
<Text className="text-[16px] font-bold text-blue-600">
Styled text
</Text>
</Body>
</Tailwind>
```
Note that email clients have limited CSS support. Stick to basic styles:
- Font sizes and weights
- Colors
- Padding and margins
- Basic flexbox (limited support)
Avoid:
- CSS Grid
- Complex transforms
- CSS variables
- Advanced selectors
## Testing Templates
Please use the Dev Tool to see how emails look like.
## Next Steps
- [Send emails](/docs/next-supabase-turbo/sending-emails) using your templates
- [Configure Supabase Auth emails](/docs/next-supabase-turbo/authentication-emails) with custom templates
- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit

188
docs/emails/inbucket.mdoc Normal file
View File

@@ -0,0 +1,188 @@
---
status: "published"
title: "Local Email Development with Mailpit"
label: "Local Development"
description: "Test email functionality locally using Mailpit. Capture authentication emails, team invitations, and transactional emails without sending real messages."
order: 4
---
When developing locally, Supabase automatically runs [Mailpit](https://mailpit.axllent.org) (or InBucket in older versions) to capture all emails. This lets you test email flows without configuring an email provider or sending real emails.
## Accessing Mailpit
With Supabase running locally, open Mailpit at:
```
http://localhost:54324
```
All emails sent during development appear here, including:
- Supabase Auth emails (verification, password reset, magic links)
- MakerKit transactional emails (team invitations, account notifications)
- Any custom emails you send via the mailer
## How It Works
The local Supabase setup configures both Supabase Auth and MakerKit to use the local SMTP server:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Your App │────▶│ Local SMTP │────▶│ Mailpit UI │
│ (Auth + Mailer)│ │ (port 54325) │ │ (port 54324) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
The default development environment variables in `apps/web/.env.development` point to this local SMTP:
```bash
EMAIL_HOST=127.0.0.1
EMAIL_PORT=54325
EMAIL_USER=
EMAIL_PASSWORD=
EMAIL_TLS=false
EMAIL_SENDER=test@makerkit.dev
```
## Testing Common Flows
### Email Verification
1. Start your dev server: `pnpm dev`
2. Sign up with any email address (e.g., `test@example.com`)
3. Open [http://localhost:54324](http://localhost:54324)
4. Find the verification email
5. Click the verification link
### Password Reset
1. Go to the sign-in page
2. Click "Forgot password"
3. Enter any email address
4. Check Mailpit for the reset email
5. Click the reset link to set a new password
### Magic Link Login
1. Go to the sign-in page
2. Enter an email and request a magic link
3. Check Mailpit for the magic link email
4. Click the link to sign in
### Team Invitations
1. Sign in and navigate to team settings
2. Invite a new member by email
3. Check Mailpit for the invitation email
4. Use the invitation link to accept
## Using a Real Email Provider Locally
If you need to test with a real email provider during development (e.g., to test email rendering in actual clients), override the development environment variables:
```bash {% title="apps/web/.env.development.local" %}
# Override to use Resend locally
MAILER_PROVIDER=resend
RESEND_API_KEY=re_your-test-key
EMAIL_SENDER=YourApp <test@yourdomain.com>
```
Or for SMTP:
```bash {% title="apps/web/.env.development.local" %}
# Override to use real SMTP locally
EMAIL_HOST=smtp.your-provider.com
EMAIL_PORT=587
EMAIL_USER=your-username
EMAIL_PASSWORD=your-password
EMAIL_TLS=true
EMAIL_SENDER=YourApp <test@yourdomain.com>
```
{% alert type="default" title="Supabase Auth Emails" %}
Even with a custom email provider, Supabase Auth emails (verification, password reset) still go through Mailpit. To test Supabase Auth emails with a real provider, you need to configure SMTP in the Supabase dashboard.
{% /alert %}
## Debugging Email Issues
### Emails Not Appearing in Mailpit
If emails don't show up:
1. **Verify Supabase is running**: Check `supabase status`
2. **Check the port**: Mailpit runs on port 54324, SMTP on 54325
3. **Check environment variables**: Ensure `EMAIL_HOST=127.0.0.1` and `EMAIL_PORT=54325`
4. **Check server logs**: Look for SMTP connection errors in your terminal
### Wrong Email Content
If emails appear but content is wrong:
1. **Check template rendering**: Templates are rendered at send time
2. **Verify template data**: Log the data passed to `renderXxxEmail()` functions
3. **Check i18n**: Ensure translation files exist for your locale
### Links Not Working
If email links don't work when clicked:
1. **Check `NEXT_PUBLIC_SITE_URL`**: Should be `http://localhost:3000` for local dev
2. **Verify route exists**: The link destination route must be implemented
3. **Check token handling**: For auth emails, ensure `/auth/confirm` route works
## Mailpit Features
Mailpit provides useful features for testing:
### Search and Filter
Filter emails by:
- Sender address
- Recipient address
- Subject line
- Date range
### View HTML and Plain Text
Toggle between:
- HTML rendered view
- Plain text view
- Raw source
### Check Headers
Inspect email headers for:
- Content-Type
- MIME structure
- Custom headers
### API Access
Mailpit has an API for programmatic access:
```bash
# List all messages
curl http://localhost:54324/api/v1/messages
# Get specific message
curl http://localhost:54324/api/v1/message/{id}
# Delete all messages
curl -X DELETE http://localhost:54324/api/v1/messages
```
## Production Checklist
Before deploying, ensure you've:
1. **Configured a production email provider**: Set `MAILER_PROVIDER`, API keys, and SMTP credentials
2. **Verified sender domain**: Set up SPF, DKIM, and DMARC records
3. **Updated Supabase Auth templates**: Replace default templates with token-hash versions
4. **Tested real email delivery**: Send test emails to verify deliverability
5. **Set up monitoring**: Configure alerts for email delivery failures
## Next Steps
- [Configure your production email provider](/docs/next-supabase-turbo/email-configuration)
- [Set up Supabase Auth email templates](/docs/next-supabase-turbo/authentication-emails)
- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email

View File

@@ -0,0 +1,273 @@
---
status: "published"
label: "Sending Emails"
description: "Send transactional emails from your Next.js Supabase application using the MakerKit mailer API. Learn the email schema, error handling, and best practices."
title: "Sending Emails in the Next.js Supabase SaaS Starter Kit"
order: 1
---
The `@kit/mailers` package provides a simple, provider-agnostic API for sending emails. Use it in Server Actions, API routes, or any server-side code to send transactional emails.
## Basic Usage
Import `getMailer` and call `sendEmail` with your email data:
```tsx
import { getMailer } from '@kit/mailers';
async function sendWelcomeEmail(userEmail: string) {
const mailer = await getMailer();
await mailer.sendEmail({
to: userEmail,
from: process.env.EMAIL_SENDER!,
subject: 'Welcome to our platform',
text: 'Thanks for signing up! We are excited to have you.',
});
}
```
The `getMailer` function returns the configured mailer instance (Nodemailer or Resend) based on your `MAILER_PROVIDER` environment variable.
## Email Schema
The `sendEmail` method accepts an object validated by this Zod schema:
```tsx
// Simplified representation of the schema
type EmailData = {
to: string; // Recipient email (must be valid email format)
from: string; // Sender (e.g., "App Name <noreply@app.com>")
subject: string; // Email subject line
} & (
| { text: string } // Plain text body
| { html: string } // HTML body
);
```
You must provide **exactly one** of `text` or `html`. This is a discriminated union, not optional fields. Providing both properties or neither will cause a validation error at runtime.
## Sending HTML Emails
For rich email content, use the `html` property:
```tsx
import { getMailer } from '@kit/mailers';
async function sendHtmlEmail(to: string) {
const mailer = await getMailer();
await mailer.sendEmail({
to,
from: process.env.EMAIL_SENDER!,
subject: 'Your weekly summary',
html: `
<h1>Weekly Summary</h1>
<p>Here's what happened this week:</p>
<ul>
<li>5 new team members joined</li>
<li>12 tasks completed</li>
</ul>
`,
});
}
```
For complex HTML emails, use [React Email templates](/docs/next-supabase-turbo/email-templates) instead of inline HTML strings.
## Using Email Templates
MakerKit includes pre-built email templates in the `@kit/email-templates` package. These templates use React Email and support internationalization:
```tsx
import { getMailer } from '@kit/mailers';
import { renderInviteEmail } from '@kit/email-templates';
async function sendTeamInvitation(params: {
invitedEmail: string;
teamName: string;
inviterName: string;
inviteLink: string;
}) {
const mailer = await getMailer();
// Render the React Email template to HTML
const { html, subject } = await renderInviteEmail({
teamName: params.teamName,
inviter: params.inviterName,
invitedUserEmail: params.invitedEmail,
link: params.inviteLink,
productName: 'Your App Name',
});
await mailer.sendEmail({
to: params.invitedEmail,
from: process.env.EMAIL_SENDER!,
subject,
html,
});
}
```
See the [Email Templates guide](/docs/next-supabase-turbo/email-templates) for creating custom templates.
## Error Handling
The `sendEmail` method returns a Promise that rejects on failure. Always wrap email sending in try-catch:
```tsx
import { getMailer } from '@kit/mailers';
async function sendEmailSafely(to: string, subject: string, text: string) {
try {
const mailer = await getMailer();
await mailer.sendEmail({
to,
from: process.env.EMAIL_SENDER!,
subject,
text,
});
return { success: true };
} catch (error) {
console.error('Failed to send email:', error);
// Log to your error tracking service
// Sentry.captureException(error);
return { success: false, error: 'Failed to send email' };
}
}
```
### Common Error Causes
| Error | Cause | Solution |
|-------|-------|----------|
| Validation error | Invalid email format or missing fields | Check `to` is a valid email, ensure `text` or `html` is provided |
| Authentication failed | Wrong SMTP credentials | Verify `EMAIL_USER` and `EMAIL_PASSWORD` |
| Connection refused | SMTP server unreachable | Check `EMAIL_HOST` and `EMAIL_PORT`, verify network access |
| Rate limited | Too many emails sent | Implement rate limiting, use a queue for bulk sends |
## Using in Server Actions
Email sending works in Next.js Server Actions:
```tsx {% title="app/actions/send-notification.ts" %}
'use server';
import { getMailer } from '@kit/mailers';
export async function sendNotificationAction(formData: FormData) {
const email = formData.get('email') as string;
const message = formData.get('message') as string;
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER!,
subject: 'New notification',
text: message,
});
return { success: true };
}
```
## Using in API Routes
For webhook handlers or external integrations:
```tsx {% title="app/api/webhooks/send-email/route.ts" %}
import { NextResponse } from 'next/server';
import { getMailer } from '@kit/mailers';
export async function POST(request: Request) {
const { to, subject, message } = await request.json();
try {
const mailer = await getMailer();
await mailer.sendEmail({
to,
from: process.env.EMAIL_SENDER!,
subject,
text: message,
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to send email' },
{ status: 500 }
);
}
}
```
## Best Practices
### Use Environment Variables for Sender
Never hardcode the sender email:
```tsx
// Good
from: process.env.EMAIL_SENDER!
// Bad
from: 'noreply@example.com'
```
### Validate Recipient Emails
Before sending, validate that the recipient email exists in your system:
```tsx
import { getMailer } from '@kit/mailers';
async function sendToUser(userId: string, subject: string, text: string) {
// Fetch user from database first
const user = await getUserById(userId);
if (!user?.email) {
throw new Error('User has no email address');
}
const mailer = await getMailer();
await mailer.sendEmail({
to: user.email,
from: process.env.EMAIL_SENDER!,
subject,
text,
});
}
```
### Queue Bulk Emails
For sending many emails, use a background job queue to avoid timeouts and handle retries:
```tsx
// Instead of this:
for (const user of users) {
await sendEmail(user.email); // Slow, no retry handling
}
// Use a queue like Trigger.dev, Inngest, or BullMQ
await emailQueue.addBulk(
users.map(user => ({
name: 'send-email',
data: { email: user.email, template: 'weekly-digest' },
}))
);
```
## Next Steps
- [Create custom email templates](/docs/next-supabase-turbo/email-templates) with React Email
- [Build a custom mailer](/docs/next-supabase-turbo/custom-mailer) for other providers
- [Test emails locally](/docs/next-supabase-turbo/emails/inbucket) with Mailpit

View File

@@ -0,0 +1,309 @@
---
status: "published"
title: "Configure Supabase Authentication Email Templates"
label: "Authentication Emails"
description: "Configure custom authentication email templates for your MakerKit application. Fix PKCE issues and customize branding for confirmation, magic link, and password reset emails."
order: 4
---
MakerKit's email templates use token_hash URLs instead of Supabase's default PKCE flow, fixing the common issue where users can't authenticate when clicking email links on different devices. This is required for reliable production authentication since many users check email on mobile but sign up on desktop.
Copy MakerKit's templates from `apps/web/supabase/templates/` to your Supabase Dashboard to enable cross-browser authentication.
## Why Custom Email Templates Matter
### The PKCE Problem
Supabase uses PKCE (Proof Key for Code Exchange) for secure authentication. Here's how it works:
1. User signs up in Chrome on their laptop
2. Supabase stores a PKCE verifier in Chrome's session
3. User receives confirmation email on their phone
4. User clicks link, opens in Safari
5. **Authentication fails** because Safari doesn't have the PKCE verifier
This affects:
- Email confirmations
- Magic link login
- Password reset flows
- Email change confirmations
### The Solution
MakerKit's email templates use **token hash URLs** instead of PKCE. This approach:
1. Includes authentication tokens directly in the URL
2. Works regardless of which browser/device opens the link
3. Maintains security through server-side verification
---
## Step 1: Locate MakerKit Templates
MakerKit provides pre-designed email templates in your project:
```
apps/web/supabase/templates/
├── confirm-email.html # Email confirmation
├── magic-link.html # Magic link login
├── reset-password.html # Password reset
├── change-email-address.html # Email change confirmation
├── invite-user.html # Team invitation
└── otp.html # One-time password
```
These templates:
- Use the token hash URL strategy
- Have modern, clean designs
- Are customizable with your branding
---
## Step 2: Update Templates in Supabase
### Navigate to Email Templates
1. Open your [Supabase Dashboard](https://supabase.com/dashboard)
2. Select your project
3. Go to **Authentication > Email Templates**
### Update Each Template
For each email type, copy the corresponding template from your MakerKit project and paste it into Supabase.
#### Confirm Signup Email
The confirmation email uses this URL format:
```html
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">
Confirm your email
</a>
```
Key elements:
- `{{ .SiteURL }}`: Your application URL
- `{{ .TokenHash }}`: Secure token for verification
- `type=email`: Specifies the confirmation type
#### Magic Link Email
```html
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink">
Sign in to your account
</a>
```
#### Password Recovery Email
```html
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery">
Reset your password
</a>
```
#### Email Change Email
```html
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change">
Confirm email change
</a>
```
---
## Step 3: Customize Templates
### Branding Elements
Update these elements in each template:
```html
<!-- Logo -->
<img src="https://yourdomain.com/logo.png" alt="Your App" />
<!-- Company name -->
<p>Thanks for signing up for <strong>Your App Name</strong>!</p>
<!-- Footer -->
<p>&copy; 2026 Your Company Name. All rights reserved.</p>
```
### Email Styling
MakerKit templates use inline CSS for email client compatibility:
```html
<div style="
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
">
<!-- Content -->
</div>
```
### Template Variables
Supabase provides these variables:
| Variable | Description |
|----------|-------------|
| `{{ .SiteURL }}` | Your Site URL from Supabase settings |
| `{{ .TokenHash }}` | Secure authentication token |
| `{{ .Email }}` | User's email address |
| `{{ .ConfirmationURL }}` | Full confirmation URL (PKCE-based, avoid) |
{% alert type="warning" title="Avoid ConfirmationURL" %}
Don't use `{{ .ConfirmationURL }}` as it uses the PKCE flow that causes cross-browser issues. Use the token hash approach instead.
{% /alert %}
---
## Step 4: Advanced Customization
### Using the Email Starter Repository
For more advanced customization, use MakerKit's email starter:
1. Clone the repository:
- Run the following command: `git clone https://github.com/makerkit/makerkit-emails-starter`
2. Install dependencies:
- Run the following command: `cd makerkit-emails-starter && npm install`
3. Customize templates using [React Email](https://react.email/)
4. Export templates:
- Run the following command: `npm run export`
5. Copy exported HTML to Supabase Dashboard
- Copy the exported HTML files to the `apps/web/supabase/templates/` folder in your Supabase Dashboard.
### Benefits of React Email
- **Component-based**: Reuse header, footer, and button components
- **Preview**: Live preview while developing
- **TypeScript**: Type-safe email templates
- **Responsive**: Built-in responsive design utilities
---
## Template Reference
### Minimal Confirmation Template
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">Confirm your email</h1>
<p style="color: #666; line-height: 1.6;">
Thanks for signing up! Click the button below to confirm your email address.
</p>
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"
style="display: inline-block; background: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
Confirm Email
</a>
<p style="color: #999; font-size: 14px;">
If you didn't sign up, you can ignore this email.
</p>
</div>
</body>
</html>
```
### Minimal Magic Link Template
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">Sign in to Your App</h1>
<p style="color: #666; line-height: 1.6;">
Click the button below to sign in. This link expires in 1 hour.
</p>
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink"
style="display: inline-block; background: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
Sign In
</a>
<p style="color: #999; font-size: 14px;">
If you didn't request this, you can ignore this email.
</p>
</div>
</body>
</html>
```
---
## Troubleshooting
### "Invalid token" errors
1. Verify you're using `token_hash` not `confirmation_url`
2. Check the URL includes the correct `type` parameter
3. Ensure `{{ .SiteURL }}` matches your Supabase Site URL setting
### Emails going to spam
1. Configure custom SMTP in Supabase
2. Set up SPF, DKIM, and DMARC records for your domain
3. Use a reputable email provider (Resend, SendGrid, Postmark)
### Template not updating
1. Clear browser cache and try again
2. Wait a few minutes for changes to propagate
3. Test with a fresh email address
### Links not working
1. Verify Site URL is correctly set in Supabase
2. Check your application has the `/auth/confirm` route
3. Ensure your app is deployed and accessible
---
## Testing Email Templates
### Test in Development
1. Use [Inbucket](http://localhost:54324) locally (included with Supabase local development)
2. Sign up with a test email
3. Check Inbucket for the email
4. Verify the link works correctly
### Test in Production
1. Sign up with a real email address
2. Check the email formatting
3. Click the link from a different device/browser
4. Verify successful authentication
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Which email templates do I need to update?", "answer": "Update all authentication-related templates: Confirm signup, Magic Link, Reset Password, and Change Email Address. MakerKit provides all of these in apps/web/supabase/templates/. Copy each one to the corresponding template in Supabase Dashboard."},
{"question": "How do I test email templates?", "answer": "Use Supabase's local development with Inbucket (http://localhost:54324). Sign up with a test email, check Inbucket for the email, verify formatting, and click links to test the full flow. For production, test with a real email address before launch."},
{"question": "Can I customize the email design?", "answer": "Yes. The templates in apps/web/supabase/templates/ are starting points. Update colors, logos, copy, and layout. Use inline CSS for email client compatibility. For advanced customization, use the MakerKit emails starter repo with React Email for component-based templates."},
{"question": "Do I need to update templates for both local and production?", "answer": "Templates in your codebase are for reference and local development. For production, copy the templates to Supabase Dashboard > Authentication > Email Templates. Dashboard templates override any local configuration."}
]
/%}
---
## Next Steps
- [Authentication Configuration](/docs/next-supabase-turbo/going-to-production/authentication): Configure OAuth providers and SMTP
- [Supabase Deployment](/docs/next-supabase-turbo/going-to-production/supabase): Full Supabase configuration
- [Email Configuration](/docs/next-supabase-turbo/emails/email-configuration): Application transactional emails setup

View File

@@ -0,0 +1,326 @@
---
status: "published"
title: "Configure Supabase Authentication for Production"
label: "Authentication"
description: "Configure Supabase authentication settings for production deployment. Covers URL configuration, SMTP setup, and third-party OAuth providers."
order: 3
---
Configure Supabase authentication for production with proper redirect URLs, SMTP email delivery, and OAuth providers like Google. MakerKit automatically detects enabled providers and displays the appropriate login buttons.
{% alert type="warning" title="Required for login to work" %}
Skipping these steps will cause authentication failures. Users will not be able to sign up, log in, or reset passwords without proper configuration.
{% /alert %}
## Overview
| Configuration | Purpose | Where |
|--------------|---------|-------|
| Site URL | Base URL for your application | Supabase Dashboard |
| Redirect URLs | Allowed callback URLs after auth | Supabase Dashboard |
| Custom SMTP | Reliable email delivery | Supabase Dashboard |
| OAuth Providers | Google, GitHub, etc. login | Provider + Supabase |
---
## Authentication URLs
Configure redirect URLs so Supabase knows where to send users after authentication.
### Navigate to Settings
1. Open your [Supabase Dashboard](https://app.supabase.io/)
2. Select your project
3. Go to **Authentication > URL Configuration**
### Site URL
Set this to your production domain:
```
https://yourdomain.com
```
This is the base URL Supabase uses for all authentication-related redirects.
### Redirect URLs
Add your callback URL with a wildcard pattern:
```
https://yourdomain.com/auth/callback**
```
The `**` wildcard allows MakerKit's various authentication flows to work:
- `/auth/callback` - Standard OAuth callback
- `/auth/callback/confirm` - Email confirmation
- `/auth/callback/password-reset` - Password reset flow
{% alert type="default" title="Multiple environments" %}
Add redirect URLs for each environment:
- Production: `https://yourdomain.com/auth/callback**`
- Staging: `https://staging.yourdomain.com/auth/callback**`
- Vercel previews: `https://*-yourproject.vercel.app/auth/callback**`
{% /alert %}
---
## Domain Matching
A common authentication issue occurs when domains don't match exactly.
### The Rule
Your Site URL, Redirect URLs, and actual application URL must match exactly, including:
- Protocol (`https://`)
- Subdomain (`www.` or no `www`)
- Domain name
- No trailing slash
### Examples
| Site URL | Application URL | Result |
|----------|-----------------|--------|
| `https://example.com` | `https://example.com` | Works |
| `https://example.com` | `https://www.example.com` | Fails |
| `https://www.example.com` | `https://www.example.com` | Works |
| `https://example.com/` | `https://example.com` | May fail |
### Fix Domain Mismatches
If users report login issues:
1. Check what URL appears in the browser when users visit your app
2. Ensure Site URL in Supabase matches exactly
3. Update Redirect URLs to match
4. Configure your hosting provider to redirect to a canonical URL
---
## Custom SMTP
Supabase's default email service has severe limitations:
- **Rate limit**: 4 emails per hour
- **Deliverability**: Low (often lands in spam)
- **Branding**: Generic Supabase branding
Configure a real SMTP provider for production.
### Navigate to SMTP Settings
1. Go to **Project Settings > Authentication**
2. Scroll to **SMTP Settings**
3. Toggle **Enable Custom SMTP**
### Configuration
| Field | Description |
|-------|-------------|
| Host | Your SMTP server hostname |
| Port | Usually 465 (SSL) or 587 (TLS) |
| Username | SMTP authentication username |
| Password | SMTP authentication password or API key |
| Sender email | The "from" address for emails |
| Sender name | Display name for the sender |
### Provider Examples
#### Resend
```
Host: smtp.resend.com
Port: 465
Username: resend
Password: re_your_api_key
```
#### SendGrid
```
Host: smtp.sendgrid.net
Port: 587
Username: apikey
Password: SG.your_api_key
```
#### Mailgun
```
Host: smtp.mailgun.org
Port: 587
Username: postmaster@your-domain.mailgun.org
Password: your_api_key
```
### Verify Configuration
After saving SMTP settings:
1. Create a test user with a real email address
2. Check that the confirmation email arrives
3. Verify the email doesn't land in spam
4. Confirm links in the email work correctly
---
## Third-Party Providers
MakerKit supports OAuth login through Supabase. Configure providers in both the provider's developer console and Supabase.
### How It Works
1. You enable a provider in Supabase Dashboard
2. MakerKit automatically shows the login button in the UI
3. No code changes required
### Supported Providers
MakerKit displays login buttons for any provider you enable in Supabase:
- Google
- GitHub
- Apple
- Microsoft
- Discord
- Twitter/X
- And others supported by Supabase
### General Setup Process
For each provider:
1. **Create OAuth App**: Register an application in the provider's developer console
2. **Get Credentials**: Copy the Client ID and Client Secret
3. **Set Callback URL**: Add the Supabase callback URL to your OAuth app
4. **Configure Supabase**: Enter credentials in **Authentication > Providers**
### Google Setup
Google is the most common OAuth provider. Here's the setup:
#### In Google Cloud Console
1. Go to [console.cloud.google.com](https://console.cloud.google.com)
2. Create a new project or select existing
3. Navigate to **APIs & Services > Credentials**
4. Click **Create Credentials > OAuth client ID**
5. Select **Web application**
6. Add authorized redirect URI (from Supabase)
#### In Supabase Dashboard
1. Go to **Authentication > Providers**
2. Click **Google**
3. Toggle **Enable Sign in with Google**
4. Enter your Client ID and Client Secret
5. Copy the **Callback URL** to Google Cloud
For detailed instructions, see [Supabase Google Auth documentation](https://supabase.com/docs/guides/auth/social-login/auth-google).
---
## Email Templates
MakerKit provides custom email templates that fix a common authentication issue with Supabase.
### The PKCE Problem
Supabase uses PKCE (Proof Key for Code Exchange) for secure authentication. The PKCE verifier is stored in the browser that initiated the authentication.
When a user:
1. Signs up on their laptop
2. Receives confirmation email on their phone
3. Clicks the link on their phone
Authentication fails because the phone doesn't have the PKCE verifier.
### The Solution
MakerKit's email templates use token hash URLs instead of PKCE, which work regardless of which device opens the link.
See the [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails) guide for setup instructions.
---
## Troubleshooting
### "Redirect URL not allowed" error
The callback URL doesn't match any configured Redirect URLs in Supabase.
1. Check the exact URL in the error message
2. Add it to Redirect URLs in Supabase
3. Include the `**` wildcard for flexibility
### Users can't log in after email confirmation
Usually a domain mismatch issue. Verify:
1. Site URL matches your application URL exactly
2. Redirect URLs match your domain
3. No trailing slashes causing mismatches
### OAuth login fails silently
Check browser console for errors. Common issues:
1. Callback URL in provider doesn't match Supabase
2. OAuth credentials incorrect
3. Provider not properly enabled in Supabase
### Emails not received
1. Check spam/junk folders
2. Verify SMTP settings in Supabase
3. Check your email provider's dashboard for delivery logs
4. Ensure sender domain has proper DNS records (SPF, DKIM)
### "Invalid PKCE verifier" error
Users clicking email links from different browsers/devices. Update to MakerKit's email templates. See [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails).
---
## Security Considerations
### Protect Your Credentials
- Never expose the Supabase Service Role Key in client code
- Store OAuth credentials securely (use environment variables)
- Rotate credentials if they're ever exposed
### Configure Rate Limiting
Supabase has built-in rate limiting for authentication. Review settings in **Project Settings > Auth** to prevent abuse.
### Monitor Authentication Events
Enable audit logging in Supabase to track:
- Failed login attempts
- Unusual activity patterns
- Password reset requests
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Why does authentication fail when users click email links?", "answer": "Most likely a PKCE issue. Supabase's default email templates use PKCE which fails if the user opens the link in a different browser. Use MakerKit's custom email templates with token_hash URLs instead. See the Authentication Emails guide."},
{"question": "How do I add Google login?", "answer": "Enable Google in Supabase Dashboard > Authentication > Providers. Enter your Google Cloud OAuth credentials (Client ID and Secret). Copy the Supabase callback URL to your Google Cloud OAuth app. MakerKit automatically shows the Google login button."},
{"question": "Can I use multiple OAuth providers?", "answer": "Yes. Enable as many providers as you want in Supabase. MakerKit displays login buttons for all enabled providers automatically. Each provider needs its own OAuth app configured with the Supabase callback URL."},
{"question": "Why are my authentication emails going to spam?", "answer": "Supabase's default email service has poor deliverability. Configure a real SMTP provider like Resend, SendGrid, or Postmark. Also set up SPF, DKIM, and DMARC DNS records for your sending domain."},
{"question": "What's the redirect URL wildcard for?", "answer": "The ** wildcard in redirect URLs matches any path. MakerKit uses different callback paths for different flows: /auth/callback for OAuth, /auth/callback/confirm for email confirmation, /auth/callback/password-reset for password recovery. The wildcard covers all of them."},
{"question": "How do I test authentication locally?", "answer": "Supabase local development uses Inbucket for emails at http://localhost:54324. Sign up with any email, check Inbucket for the confirmation link, and click it. For OAuth, you need to configure providers with localhost callback URLs."}
]
/%}
---
## Next Steps
- [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails): Configure email templates with token_hash URLs
- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference
- [Supabase Configuration](/docs/next-supabase-turbo/going-to-production/supabase): Full Supabase setup guide

View File

@@ -0,0 +1,338 @@
---
status: "published"
title: "Production Deployment Checklist for Next.js Supabase SaaS"
label: "Checklist"
description: "Complete checklist for deploying your MakerKit Next.js Supabase application to production. Follow these steps in order to ensure a successful deployment."
order: 0
---
Deploy your MakerKit Next.js Supabase Turbo application to production by completing this checklist in order. This guide covers Supabase configuration, authentication setup, billing webhooks, and hosting deployment for Next.js 16 with React 19.
{% alert type="warning" title="Complete all steps before testing" %}
Your application will not function correctly if you skip steps. Budget 2-3 hours for your first deployment. The most common failure we see is missing environment variables, which MakerKit catches at build time with clear error messages.
{% /alert %}
## Quick Reference
| Step | What | Where | Time |
|------|------|-------|------|
| 1 | Create Supabase project | [supabase.com](https://supabase.com) | 5 min |
| 2 | Push database migrations | Terminal | 2 min |
| 3 | Configure auth URLs | Supabase Dashboard | 5 min |
| 4 | Set up OAuth providers | Supabase + Provider | 15-30 min |
| 5 | Update auth email templates | Supabase Dashboard | 10 min |
| 6 | Deploy to hosting provider | Vercel/Cloudflare/VPS | 10-20 min |
| 7 | Set environment variables | Hosting provider | 10 min |
| 8 | Configure database webhooks | Supabase Dashboard | 10 min |
| 9 | Set up SMTP for emails | Supabase + Email provider | 15 min |
| 10 | Configure billing provider | Stripe/Lemon Squeezy | 20-30 min |
---
## Pre-Deployment Requirements
Before starting, ensure you have accounts and API keys for:
- **Supabase**: Database, authentication, and storage
- **Billing provider**: Stripe or Lemon Squeezy with API keys and webhook secrets
- **Email provider**: Resend, SendGrid, Mailgun, or another SMTP service
- **Hosting provider**: Vercel, Cloudflare, or a VPS
---
## Step 1: Create and Link Supabase Project
Create a new project in the [Supabase Dashboard](https://supabase.com/dashboard). Save your database password securely as you will need it for the CLI.
Link your local project to the remote Supabase instance:
```bash
pnpm --filter web supabase login
pnpm --filter web supabase link
```
The CLI will prompt you to select your project and enter the database password.
**Verification**: Run `supabase projects list` to confirm the link.
---
## Step 2: Push Database Migrations
Push MakerKit's database schema to your production Supabase instance:
```bash
pnpm --filter web supabase db push
```
Review the migrations when prompted. You should see the core MakerKit tables (accounts, subscriptions, invitations, etc.).
**Verification**: Check the Supabase Dashboard Table Editor to confirm tables exist.
---
## Step 3: Configure Authentication URLs
In the Supabase Dashboard, navigate to **Authentication > URL Configuration**.
Set these values:
| Field | Value |
|-------|-------|
| Site URL | `https://yourdomain.com` |
| Redirect URLs | `https://yourdomain.com/auth/callback**` |
{% alert type="default" title="No URL yet?" %}
If you haven't deployed yet, skip this step and return after deployment. Your first deploy will fail without these URLs, which is expected.
{% /alert %}
**Important**: The redirect URL must include the `**` wildcard to handle all callback paths.
For detailed instructions, see the [Authentication Configuration](/docs/next-supabase-turbo/going-to-production/authentication) guide.
---
## Step 4: Set Up OAuth Providers (Optional)
If you want social login (Google, GitHub, etc.), configure providers in **Authentication > Providers** in the Supabase Dashboard.
Each provider requires:
1. Creating an OAuth app in the provider's developer console
2. Copying the Client ID and Secret to Supabase
3. Setting the callback URL from Supabase in the provider's console
MakerKit automatically displays configured providers in the login UI. No code changes needed.
For Google Auth setup, see the [Supabase Google Auth guide](https://supabase.com/docs/guides/auth/social-login/auth-google).
---
## Step 5: Update Authentication Email Templates
MakerKit provides custom email templates that fix PKCE flow issues when users click email links from different browsers or devices.
{% alert type="warning" title="Required for reliable authentication" %}
Using Supabase's default email templates will cause authentication failures when users open email links in a different browser.
{% /alert %}
Update your email templates in **Authentication > Email Templates** in the Supabase Dashboard. Use the templates from `apps/web/supabase/templates/` as your starting point.
For detailed instructions, see the [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails) guide.
---
## Step 6: Deploy Your Application
Choose your deployment platform:
| Platform | Best For | Guide |
|----------|----------|-------|
| [Vercel](/docs/next-supabase-turbo/going-to-production/vercel) | Easiest setup, automatic deployments | Recommended |
| [Cloudflare](/docs/next-supabase-turbo/going-to-production/cloudflare) | Edge runtime, lower costs | Requires config changes |
| [sherpa.sh](/docs/next-supabase-turbo/going-to-production/sherpa) | Cost-effective alternative | Good support |
| [Docker/VPS](/docs/next-supabase-turbo/going-to-production/docker) | Full control, self-hosted | More setup required |
### Which platform should I choose?
**Use Vercel when**: You want the simplest setup with preview deployments, need commercial use rights (Pro tier), or are new to deployment. Works out of the box with MakerKit.
**Use Cloudflare when**: You need zero cold starts for global users, want lower costs at scale, or are comfortable making Edge runtime configuration changes.
**Use VPS when**: You need full infrastructure control, want predictable costs regardless of traffic, or have compliance requirements for data location.
**If unsure**: Start with Vercel. You can migrate later since MakerKit supports all platforms.
**Expected**: Your first deployment will likely fail if you haven't set all environment variables. This is normal. Continue to the next step.
---
## Step 7: Set Environment Variables
Generate your production environment variables using the built-in tool:
```bash
pnpm turbo gen env
```
This interactive command creates a `.env.local` file at `turbo/generators/templates/env/.env.local` with all required variables.
Copy these variables to your hosting provider's environment settings.
**Required variables include**:
- `NEXT_PUBLIC_SITE_URL`: Your production URL
- `NEXT_PUBLIC_SUPABASE_URL`: From Supabase Dashboard > Settings > API
- `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`: Supabase anon key
- `SUPABASE_SECRET_KEY`: Supabase service role key
- Billing provider keys (Stripe or Lemon Squeezy)
- Email provider configuration
For the complete list, see [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables).
After setting variables, redeploy your application.
---
## Step 8: Configure Database Webhooks
MakerKit uses database webhooks to handle events like subscription cancellations when accounts are deleted.
### Generate a Webhook Secret
Create a strong, random secret for `SUPABASE_DB_WEBHOOK_SECRET`:
```bash
openssl rand -base64 32
```
Add this to your environment variables on your hosting provider.
### Create the Webhook in Supabase
In Supabase Dashboard, go to **Database > Webhooks**:
1. Click **Enable Webhooks** if not already enabled
2. Click **Create a new hook**
3. Configure:
- **Table**: `public.subscriptions`
- **Events**: `DELETE`
- **URL**: `https://yourdomain.com/api/db/webhook`
- **Headers**: Add `X-Supabase-Event-Signature` with your webhook secret value
- **Timeout**: `5000`
{% alert type="warning" title="Use a public URL" %}
The webhook URL must be publicly accessible. Vercel preview URLs are private and will not receive webhooks.
{% /alert %}
For detailed webhook setup, see the [Supabase Deployment](/docs/next-supabase-turbo/going-to-production/supabase) guide.
---
## Step 9: Configure Email Service (SMTP)
Supabase's built-in email service has low rate limits and poor deliverability. Configure a real SMTP provider for production.
### In Your Environment Variables
Set the mailer configuration:
```bash
MAILER_PROVIDER=resend # or nodemailer
EMAIL_SENDER=noreply@yourdomain.com
RESEND_API_KEY=re_xxxxx # if using Resend
```
### In Supabase Dashboard
Navigate to **Project Settings > Authentication > SMTP Settings** and configure your provider's SMTP credentials.
Recommended providers: [Resend](https://resend.com), [SendGrid](https://sendgrid.com), [Mailgun](https://mailgun.com)
---
## Step 10: Configure Billing Provider
### Stripe Setup
1. Create products and prices in the [Stripe Dashboard](https://dashboard.stripe.com)
2. Set environment variables:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
```
3. Create a webhook endpoint in Stripe pointing to `https://yourdomain.com/api/billing/webhook`
4. Select events: `checkout.session.completed`, `customer.subscription.*`, `invoice.*`
### Lemon Squeezy Setup
1. Create products in [Lemon Squeezy](https://lemonsqueezy.com)
2. Set environment variables:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy
LEMON_SQUEEZY_SECRET_KEY=xxx
LEMON_SQUEEZY_STORE_ID=xxx
LEMON_SQUEEZY_SIGNING_SECRET=xxx
```
3. Configure webhooks in Lemon Squeezy pointing to `https://yourdomain.com/api/billing/webhook`
---
## Post-Deployment Tasks
After completing the main deployment:
{% sequence title="Final Setup Tasks" description="Complete these tasks to finalize your production deployment." %}
Update legal pages (Privacy Policy, Terms of Service) with your company information at `apps/web/app/[locale]/(marketing)/(legal)/`.
Replace placeholder blog and documentation content in `apps/web/content/` with your own.
Update favicon and logo in `apps/web/public/` with your branding.
Review and update FAQ content on marketing pages.
Set up monitoring with [Sentry](/docs/next-supabase-turbo/monitoring/sentry) or another error tracking service.
Configure analytics with [PostHog](/docs/next-supabase-turbo/analytics/posthog-analytics-provider) or your preferred provider.
{% /sequence %}
---
## Optional: Clear Expired OTPs
MakerKit stores one-time passwords for various flows. Clean these up periodically by running:
```sql
SELECT kit.cleanup_expired_nonces();
```
You can run this manually from the Supabase SQL Editor or set up a [pg_cron](https://supabase.com/docs/guides/database/extensions/pg_cron) job to run it automatically.
---
## Troubleshooting
### Build fails with missing environment variables
MakerKit validates environment variables at build time using Zod. Check the build logs to see which variables are missing, then add them to your hosting provider.
### Authentication redirects fail
Ensure your Site URL and Redirect URLs in Supabase exactly match your domain, including `www` if you use it.
### Webhooks not received
1. Verify the URL is publicly accessible (test in incognito mode)
2. Check the `X-Supabase-Event-Signature` header matches your secret
3. Review webhook logs in Supabase Dashboard > Database > Webhooks
### Emails not sending
1. Confirm SMTP settings in both environment variables and Supabase Dashboard
2. Check your email provider's logs for delivery issues
3. Verify your domain's DNS records (SPF, DKIM) are configured
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "How long does the full deployment process take?", "answer": "Plan for 2-3 hours for your first deployment. This includes setting up Supabase, configuring authentication, setting environment variables, and deploying to your hosting provider. Subsequent deployments are much faster since most configuration is one-time."},
{"question": "Can I deploy to multiple environments (staging, production)?", "answer": "Yes. Create separate Supabase projects for each environment, generate environment variables for each, and configure your hosting provider with environment-specific settings. Most providers like Vercel support automatic preview deployments for pull requests."},
{"question": "What if my first deployment fails?", "answer": "First deployments commonly fail due to missing environment variables. Check the build logs for specific error messages from Zod validation, add the missing variables in your hosting provider, and redeploy. MakerKit validates all required variables at build time."},
{"question": "Do I need to configure webhooks before the first deployment?", "answer": "Database webhooks require a publicly accessible URL, so you need to deploy first, then configure webhooks pointing to your production URL. Your app will work without webhooks initially, but subscription cancellation on account deletion won't function until webhooks are set up."},
{"question": "Can I use a different billing provider later?", "answer": "Yes. MakerKit supports Stripe and Lemon Squeezy. Switching requires updating environment variables and reconfiguring webhooks. Existing subscription data won't migrate automatically between providers."}
]
/%}
---
## Next Steps
- [Supabase Production Setup](/docs/next-supabase-turbo/going-to-production/supabase): Configure your Supabase project with migrations, RLS policies, and webhooks
- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Deploy to Vercel with automatic CI/CD
- [Cloudflare Deployment](/docs/next-supabase-turbo/going-to-production/cloudflare): Deploy to Cloudflare Pages with Edge runtime
- [Docker Deployment](/docs/next-supabase-turbo/going-to-production/docker): Self-host with Docker containers
- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference with Zod validation

View File

@@ -0,0 +1,342 @@
---
status: "published"
title: "Deploy Next.js Supabase to Cloudflare"
label: "Deploy to Cloudflare"
order: 6
description: "Deploy your MakerKit Next.js Supabase application to Cloudflare Pages using the Edge runtime. Covers configuration changes, OpenNext setup, and deployment."
---
Deploy your MakerKit Next.js 16 application to Cloudflare Pages using OpenNext for Edge runtime deployment. Cloudflare offers zero cold starts, global distribution, and cost-effective pricing for high-traffic applications.
## Prerequisites
Before deploying to Cloudflare:
1. **Cloudflare Workers Paid Plan**: Required due to bundle size limits on the free tier (starts at $5/month)
2. [Set up Supabase](/docs/next-supabase-turbo/going-to-production/supabase) project
3. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables)
---
## Edge Runtime Considerations
Cloudflare uses the Edge runtime, which differs from Node.js. Before proceeding, understand these limitations:
### What Works Differently
| Feature | Node.js | Edge Runtime | Solution |
|---------|---------|--------------|----------|
| File system | Full access | No access | Use remote CMS |
| Nodemailer | Supported | Not supported | Use Resend or HTTP mailer |
| Pino logger | Supported | Not supported | Use console logger |
| Stripe SDK | Default config | Needs fetch client | Add `httpClient` option |
| Database latency | Low | Potentially higher | Choose region wisely |
### Benefits
- Zero cold starts
- Global edge deployment (runs close to users)
- Lower costs for high-traffic applications
- Excellent caching capabilities
---
## Step 1: Run the Cloudflare Generator
MakerKit provides a generator that scaffolds all required Cloudflare configuration:
```bash
pnpm run turbo gen cloudflare
```
This command:
1. Creates `wrangler.jsonc` configuration file
2. Creates `open-next.config.ts` for OpenNext
3. Creates `.dev.vars` for local development variables
4. Adds OpenNext and Wrangler dependencies
5. Updates `next.config.mjs` with OpenNext initialization
6. Adds deployment scripts to `package.json`
---
## Step 2: Switch to Console Logger
Pino logger uses Node.js APIs unavailable in Edge runtime. Switch to console logging:
```bash
LOGGER=console
```
Add this to both your `.env` file and Cloudflare environment variables.
---
## Step 3: Update Stripe Client
The default Stripe SDK configuration uses Node.js HTTP which doesn't work in Edge runtime. You need to modify the Stripe client to use the fetch-based HTTP client instead.
Open `packages/billing/stripe/src/services/stripe-sdk.ts` and add the `httpClient` option to the Stripe constructor:
```typescript
return new Stripe(stripeServerEnv.secretKey, {
apiVersion: STRIPE_API_VERSION,
httpClient: Stripe.createFetchHttpClient(), // ADD THIS LINE
});
```
{% alert type="warning" title="Manual change required" %}
This modification is not included in MakerKit by default. You must add the `httpClient: Stripe.createFetchHttpClient()` line yourself when deploying to Edge runtime (Cloudflare or Vercel Edge Functions).
{% /alert %}
The `httpClient` option tells Stripe to use the Fetch API instead of Node.js HTTP, making it compatible with Edge runtime.
---
## Step 4: Rename proxy.ts to middleware.ts
Rename `apps/web/proxy.ts` to `apps/web/middleware.ts`.
This is required until OpenNext supports the new `proxy.ts` convention. See [this Github issue](https://github.com/opennextjs/opennextjs-cloudflare/issues/1082) for more details.
## Step 5: Switch to HTTP-Based Mailer
Nodemailer relies on Node.js networking APIs. Use Resend instead, which uses the Fetch API:
```bash
MAILER_PROVIDER=resend
RESEND_API_KEY=re_your_api_key
EMAIL_SENDER=noreply@yourdomain.com
```
If you need a different email provider, implement a custom mailer using the abstract class in `packages/mailers`. Ensure your implementation uses only Fetch API for HTTP requests.
---
## Step 6: Switch CMS Provider
Keystatic's local mode reads from the file system, which isn't available in Edge runtime. Choose one of these alternatives:
### Option A: WordPress
Set your CMS to WordPress:
```bash
CMS_CLIENT=wordpress
```
Configure your WordPress instance as the content source. See the [WordPress CMS documentation](/docs/next-supabase-turbo/content/wordpress) for setup.
### Option B: Keystatic GitHub Mode
Keep Keystatic but use GitHub as the storage backend instead of local files:
1. Configure Keystatic for GitHub mode in your `keystatic.config.ts`
2. Set up GitHub App or Personal Access Token
3. Content is stored in your GitHub repository
See the [Keystatic documentation](/docs/next-supabase-turbo/content/keystatic) for GitHub mode setup.
---
## Step 7: Configure Environment Variables
### For Local Development
Add variables to `apps/web/.dev.vars`:
```bash
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJ...
SUPABASE_SECRET_KEY=eyJ...
LOGGER=console
MAILER_PROVIDER=resend
RESEND_API_KEY=re_...
# ... other variables
```
### For Production
You'll set these during deployment or via the Cloudflare Dashboard.
---
## Step 8: Preview Locally
Test your application in a Cloudflare-like environment before deploying:
```bash
pnpm --filter web run preview
```
This builds the application with OpenNext and runs it in Wrangler's local development server. Test all critical paths:
- Authentication flows
- Billing checkout
- Email sending
- Database operations
{% alert type="warning" title="Test thoroughly" %}
Edge runtime differences may cause unexpected issues. Test your entire application flow before deploying to production.
{% /alert %}
---
## Step 9: Deploy to Cloudflare
Deploy your application:
```bash
pnpm --filter web run deploy
```
This command:
1. Builds your Next.js application with OpenNext
2. Uploads to Cloudflare Pages
3. Deploys to the edge network
{% alert type="default" title="Dashboard deployment not supported" %}
At the time of writing, Cloudflare's Dashboard doesn't support OpenNext deployments. Use the CLI command instead.
{% /alert %}
---
## Additional Commands
### Generate TypeScript Types
Generate types for Cloudflare environment bindings:
```bash
pnpm --filter web run cf-typegen
```
This creates `cloudflare-env.d.ts` with type definitions for your Cloudflare environment.
### View Deployment Logs
Check your Cloudflare Dashboard under **Workers & Pages** for deployment logs and analytics.
---
## Production Configuration
### Custom Domain
1. Go to Cloudflare Dashboard > **Workers & Pages**
2. Select your project
3. Go to **Custom Domains**
4. Add your domain
Cloudflare automatically provisions SSL certificates.
### Environment Variables in Dashboard
Add production secrets via the Cloudflare Dashboard:
1. Go to **Workers & Pages** > Your Project > **Settings**
2. Click **Variables**
3. Add each secret variable
Or use Wrangler CLI:
```bash
wrangler secret put STRIPE_SECRET_KEY
```
### Caching Strategy
Cloudflare's edge caching works well with Next.js ISR. Configure cache headers in your `next.config.mjs` for optimal performance.
---
## Troubleshooting
### "Script size exceeds limit"
Your bundle exceeds Cloudflare's free tier limit. You need the Workers Paid plan ($5/month).
### "Cannot find module 'fs'"
You're using a library that requires Node.js file system APIs. Options:
1. Find an Edge-compatible alternative
2. Use dynamic imports with fallbacks
3. Move the functionality to an external API
### "fetch is not defined"
Ensure you're using the Fetch API correctly. In Edge runtime, `fetch` is globally available without importing.
### Stripe errors
Verify you've added `httpClient: Stripe.createFetchHttpClient()` to your Stripe configuration.
### Email sending fails
Confirm:
1. `MAILER_PROVIDER=resend` is set
2. `RESEND_API_KEY` is configured
3. You're not accidentally importing nodemailer
### Database timeouts
Edge functions may have higher latency to your database. Consider:
1. Placing your Supabase project in a region close to your edge deployment
2. Using connection pooling
3. Optimizing query performance
### Build fails with OpenNext errors
1. Ensure all dependencies are installed: `pnpm install`
2. Clear build caches: `rm -rf .next .open-next`
3. Check for Node.js-specific code in your pages
---
## Performance Optimization
### Regional Deployment
By default, Cloudflare deploys globally. If your users are concentrated in a region, consider:
1. Deploying Supabase in the same region
2. Using Cloudflare's Smart Placement feature
### Cache Optimization
Leverage Cloudflare's caching:
```typescript
// In your API routes
export const runtime = 'edge';
export const revalidate = 3600; // Cache for 1 hour
```
### Bundle Size
Keep your bundle small for faster cold starts:
1. Use dynamic imports for large components
2. Avoid importing entire libraries when you only need specific functions
3. Check your bundle with `next build --analyze`
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Why do I need the Workers Paid plan?", "answer": "The free tier has a 1MB script size limit, which MakerKit exceeds after bundling. The Workers Paid plan ($5/month) increases this limit and includes more requests. Most production apps need the paid tier regardless."},
{"question": "Can I use Keystatic with Cloudflare?", "answer": "Not in local file mode. Keystatic's local mode requires file system access, which Edge runtime doesn't support. Use Keystatic's GitHub mode (stores content in your repo) or switch to WordPress as your CMS provider."},
{"question": "Why isn't nodemailer working?", "answer": "Nodemailer uses Node.js networking APIs unavailable in Edge runtime. Switch to Resend (MAILER_PROVIDER=resend) which uses the Fetch API. This is a one-line environment variable change plus adding your Resend API key."},
{"question": "How do I debug Edge runtime issues?", "answer": "Run 'pnpm --filter web run preview' locally to test in a Cloudflare-like environment before deploying. Check the Wrangler logs for errors. Common issues are importing Node.js-only modules or using file system APIs."}
]
/%}
---
## Next Steps
- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Alternative with full Node.js support
- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference
- [CMS Configuration](/docs/next-supabase-turbo/content/cms): Set up WordPress or Keystatic GitHub mode

View File

@@ -0,0 +1,388 @@
---
status: "published"
title: "Deploy Next.js Supabase with Docker"
label: "Deploy with Docker"
order: 10
description: "Deploy your MakerKit Next.js Supabase application using Docker. Covers Dockerfile generation, image building, container registry, and production deployment."
---
Deploy your MakerKit Next.js 16 application using Docker containers for full infrastructure control. This guide covers the standalone build output, multi-stage Dockerfiles, container registries, and production deployment with Docker Compose.
## Overview
| Step | Purpose |
|------|---------|
| Generate Dockerfile | Create optimized Docker configuration |
| Configure environment | Set up production variables |
| Build image | Create the container image |
| Push to registry | Upload to DockerHub or GitHub Container Registry |
| Deploy | Run on your server or cloud platform |
---
## Prerequisites
Before starting:
1. [Docker](https://docs.docker.com/get-docker/) installed locally
2. [Set up Supabase](/docs/next-supabase-turbo/going-to-production/supabase) project
3. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables)
---
## Step 1: Generate the Dockerfile
MakerKit provides a generator that creates an optimized Dockerfile and configures Next.js for standalone output:
```bash
pnpm run turbo gen docker
```
This command:
1. Creates a `Dockerfile` in the project root
2. Sets `output: "standalone"` in `next.config.mjs`
3. Installs platform-specific dependencies for Tailwind CSS
{% alert type="default" title="Architecture-specific dependencies" %}
The generator detects your CPU architecture (ARM64 or x64) and installs the correct Tailwind CSS and LightningCSS binaries for Linux builds.
{% /alert %}
---
## Step 2: Configure Environment Variables
### Create the Production Environment File
Generate your environment variables:
```bash
pnpm turbo gen env
```
Copy the generated file to `apps/web/.env.production.local`.
### Separate Build-Time and Runtime Secrets
Docker images should not contain secrets. Separate your variables into two groups:
**Build-time variables** (safe to include in image):
```bash
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
NEXT_PUBLIC_PRODUCT_NAME=MyApp
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJ...
NEXT_PUBLIC_BILLING_PROVIDER=stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
```
**Runtime secrets** (add only when running container):
```bash
SUPABASE_SECRET_KEY=eyJ...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
SUPABASE_DB_WEBHOOK_SECRET=your-secret
RESEND_API_KEY=re_...
CAPTCHA_SECRET_TOKEN=...
```
### Prepare for Build
Before building, temporarily remove secrets from your env file to avoid baking them into the image:
1. Open `apps/web/.env.production.local`
2. Comment out or remove these lines:
```bash
# SUPABASE_SECRET_KEY=...
# STRIPE_SECRET_KEY=...
# STRIPE_WEBHOOK_SECRET=...
# SUPABASE_DB_WEBHOOK_SECRET=...
# RESEND_API_KEY=...
# CAPTCHA_SECRET_TOKEN=...
```
3. Save the file
4. Keep the secrets somewhere safe for later
---
## Step 3: Build the Docker Image
Build the image for your target architecture:
### For AMD64 (most cloud servers)
```bash
docker buildx build --platform linux/amd64 -t myapp:latest .
```
### For ARM64 (Apple Silicon, AWS Graviton)
```bash
docker buildx build --platform linux/arm64 -t myapp:latest .
```
### Build Options
| Flag | Purpose |
|------|---------|
| `--platform` | Target architecture |
| `-t` | Image name and tag |
| `--no-cache` | Force fresh build |
| `--progress=plain` | Show detailed build output |
Build typically completes in 3-10 minutes depending on your machine.
---
## Step 4: Add Runtime Secrets
After building, restore the secrets to your environment file:
```bash
SUPABASE_SECRET_KEY=eyJ...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
SUPABASE_DB_WEBHOOK_SECRET=your-secret
RESEND_API_KEY=re_...
CAPTCHA_SECRET_TOKEN=...
```
---
## Step 5: Run the Container
### Local Testing
Test the image locally:
```bash
docker run -d \
-p 3000:3000 \
--env-file apps/web/.env.production.local \
myapp:latest
```
Access your app at `http://localhost:3000`.
### Run Options
| Flag | Purpose |
|------|---------|
| `-d` | Run in background (detached) |
| `-p 3000:3000` | Map port 3000 |
| `--env-file` | Load environment variables from file |
| `--name myapp` | Name the container |
| `--restart unless-stopped` | Auto-restart on failure |
---
## Step 6: Push to Container Registry
### GitHub Container Registry
1. Create a Personal Access Token with `write:packages` scope
2. Login:
```bash
docker login ghcr.io -u YOUR_USERNAME
```
3. Tag your image:
```bash
docker tag myapp:latest ghcr.io/YOUR_USERNAME/myapp:latest
```
4. Push:
```bash
docker push ghcr.io/YOUR_USERNAME/myapp:latest
```
### DockerHub
1. Login:
```bash
docker login
```
2. Tag your image:
```bash
docker tag myapp:latest YOUR_USERNAME/myapp:latest
```
3. Push:
```bash
docker push YOUR_USERNAME/myapp:latest
```
---
## Step 7: Deploy to Production
### Pull and Run on Your Server
SSH into your server and run:
```bash
# Login to registry (GitHub example)
docker login ghcr.io
# Pull the image
docker pull ghcr.io/YOUR_USERNAME/myapp:latest
# Run the container
docker run -d \
-p 3000:3000 \
--env-file .env.production.local \
--name myapp \
--restart unless-stopped \
ghcr.io/YOUR_USERNAME/myapp:latest
```
### Using Docker Compose
Create `docker-compose.yml`:
```yaml
services:
web:
image: ghcr.io/YOUR_USERNAME/myapp:latest
ports:
- "3000:3000"
env_file:
- .env.production.local
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthcheck"]
interval: 30s
timeout: 10s
retries: 3
```
Run with:
```bash
docker compose up -d
```
---
## CI/CD with GitHub Actions
Automate builds and deployments with GitHub Actions:
```yaml
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
```
---
## Production Considerations
### Health Checks
MakerKit includes a health check endpoint. Use it for monitoring:
```bash
curl http://localhost:3000/api/healthcheck
```
### Resource Limits
Set memory and CPU limits in production:
```bash
docker run -d \
-p 3000:3000 \
--memory="512m" \
--cpus="1.0" \
--env-file .env.production.local \
myapp:latest
```
### Logging
View container logs:
```bash
# Follow logs
docker logs -f myapp
# Last 100 lines
docker logs --tail 100 myapp
```
---
## Troubleshooting
### Build fails with memory error
Increase Docker's memory allocation or use a more powerful build machine:
```bash
docker build --memory=4g -t myapp:latest .
```
### Container exits immediately
Check logs for errors:
```bash
docker logs myapp
```
Common causes:
- Missing environment variables
- Port already in use
- Invalid configuration
### Image too large
The standalone output mode creates smaller images. If still too large:
1. Ensure you're using the generated Dockerfile (not a custom one)
2. Check for unnecessary files in your project
3. Use `.dockerignore` to exclude development files
### Environment variables not working
1. Verify the env file path is correct
2. Check file permissions
3. Ensure no syntax errors in the env file
4. For `NEXT_PUBLIC_` variables, rebuild the image (they're embedded at build time)
---
## Next Steps
- [VPS Deployment](/docs/next-supabase-turbo/going-to-production/vps): Deploy Docker containers to Digital Ocean, Hetzner, or Linode
- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference with secrets management
- [Monitoring Setup](/docs/next-supabase-turbo/monitoring/overview): Add Sentry or PostHog for error tracking

View File

@@ -0,0 +1,369 @@
---
status: "published"
title: "Production Environment Variables for Next.js Supabase SaaS"
label: "Environment Variables"
description: "Complete reference for generating and configuring production environment variables in your MakerKit Next.js Supabase application."
order: 2
---
Generate and configure environment variables for your MakerKit Next.js Supabase Turbo production deployment. MakerKit uses Zod schemas to validate all variables at build time, catching configuration errors before deployment.
## Generate Environment Variables
MakerKit provides an interactive generator that walks you through each required variable:
```bash
pnpm turbo gen env
```
This command:
1. Prompts you for each variable value
2. Uses defaults from your existing `.env` files when available
3. Creates a file at `turbo/generators/templates/env/.env.local`
Copy the contents of this file to your hosting provider's environment variable settings.
{% alert type="warning" title="Never commit this file" %}
The generated `.env.local` contains secrets. It's git-ignored by default, but verify it's not being tracked.
{% /alert %}
---
## Validate Environment Variables
After generating or manually setting variables, validate them:
```bash
turbo gen validate-env
```
This checks that all required variables are present and correctly formatted.
---
## Required Variables Reference
### Application Settings
| Variable | Description | Example |
|----------|-------------|---------|
| `NEXT_PUBLIC_SITE_URL` | Your production URL (no trailing slash) | `https://yourdomain.com` |
| `NEXT_PUBLIC_PRODUCT_NAME` | Product name shown in UI | `MyApp` |
| `NEXT_PUBLIC_SITE_TITLE` | Browser tab title | `MyApp - Build faster` |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | Meta description for SEO | `The fastest way to build SaaS` |
| `NEXT_PUBLIC_DEFAULT_THEME_MODE` | Default theme | `light`, `dark`, or `system` |
| `NEXT_PUBLIC_DEFAULT_LOCALE` | Default language | `en` |
### Supabase Configuration
| Variable | Description | Where to Find |
|----------|-------------|---------------|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL | Dashboard > Settings > API |
| `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Supabase anon key | Dashboard > Settings > API |
| `SUPABASE_SECRET_KEY` | Supabase service role key | Dashboard > Settings > API |
| `SUPABASE_DB_WEBHOOK_SECRET` | Secret for webhook authentication | Generate with `openssl rand -base64 32` |
{% alert type="warning" title="Keep secrets private" %}
`SUPABASE_SECRET_KEY` bypasses Row Level Security. Never expose it in client-side code.
{% /alert %}
### Authentication Settings
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_AUTH_PASSWORD` | Enable email/password login | `true` |
| `NEXT_PUBLIC_AUTH_MAGIC_LINK` | Enable magic link login | `false` |
### Feature Flags
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_ENABLE_THEME_TOGGLE` | Show theme switcher in UI | `true` |
| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION` | Allow users to delete their account | `false` |
| `NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING` | Enable billing for personal accounts | `false` |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS` | Enable team/organization accounts | `true` |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` | Allow team deletion | `false` |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING` | Enable billing for teams | `false` |
| `NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` | Allow creating new teams | `true` |
| `NEXT_PUBLIC_ENABLE_NOTIFICATIONS` | Show notification bell in UI | `true` |
| `NEXT_PUBLIC_REALTIME_NOTIFICATIONS` | Use Supabase realtime for notifications | `false` |
| `NEXT_PUBLIC_ENABLE_VERSION_UPDATER` | Show version update popup | `false` |
### Billing Configuration
#### Stripe
| Variable | Description | Where to Find |
|----------|-------------|---------------|
| `NEXT_PUBLIC_BILLING_PROVIDER` | Set to `stripe` | - |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe publishable key | Stripe Dashboard > API Keys |
| `STRIPE_SECRET_KEY` | Stripe secret key | Stripe Dashboard > API Keys |
| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret | Stripe Dashboard > Webhooks |
#### Lemon Squeezy
| Variable | Description | Where to Find |
|----------|-------------|---------------|
| `NEXT_PUBLIC_BILLING_PROVIDER` | Set to `lemon-squeezy` | - |
| `LEMON_SQUEEZY_SECRET_KEY` | API key | Lemon Squeezy > Settings > API |
| `LEMON_SQUEEZY_STORE_ID` | Your store ID | Lemon Squeezy > Settings > Store |
| `LEMON_SQUEEZY_SIGNING_SECRET` | Webhook signing secret | Lemon Squeezy > Settings > Webhooks |
### Email Configuration
#### Using Resend
| Variable | Value |
|----------|-------|
| `MAILER_PROVIDER` | `resend` |
| `EMAIL_SENDER` | `noreply@yourdomain.com` |
| `RESEND_API_KEY` | Your Resend API key |
#### Using Nodemailer (SMTP)
| Variable | Description |
|----------|-------------|
| `MAILER_PROVIDER` | `nodemailer` |
| `EMAIL_SENDER` | `noreply@yourdomain.com` |
| `EMAIL_HOST` | SMTP host (e.g., `smtp.sendgrid.net`) |
| `EMAIL_PORT` | SMTP port (usually `587` or `465`) |
| `EMAIL_USER` | SMTP username |
| `EMAIL_PASSWORD` | SMTP password or API key |
| `EMAIL_TLS` | Enable TLS (`true` or `false`) |
### CMS Configuration
| Variable | Description |
|----------|-------------|
| `CMS_CLIENT` | CMS provider: `keystatic` or `wordpress` |
### Captcha Protection (Optional)
| Variable | Description |
|----------|-------------|
| `NEXT_PUBLIC_CAPTCHA_SITE_KEY` | Cloudflare Turnstile site key |
| `CAPTCHA_SECRET_TOKEN` | Cloudflare Turnstile secret key |
### Monitoring (Optional)
| Variable | Description |
|----------|-------------|
| `CONTACT_EMAIL` | Email for receiving contact form submissions |
| `LOGGER` | Logger type: `pino` (default) or `console` |
---
## Environment Variable Groups
### Minimum Required for Deployment
These are the absolute minimum variables needed for a working deployment:
```bash
# Application
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
NEXT_PUBLIC_PRODUCT_NAME=MyApp
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJ...
SUPABASE_SECRET_KEY=eyJ...
# Billing (Stripe example)
NEXT_PUBLIC_BILLING_PROVIDER=stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Email
MAILER_PROVIDER=resend
EMAIL_SENDER=noreply@yourdomain.com
RESEND_API_KEY=re_...
# Webhooks
SUPABASE_DB_WEBHOOK_SECRET=your-secret
```
### Full Production Configuration
Here's a complete example with all common variables:
```bash
# ============================================
# APPLICATION
# ============================================
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
NEXT_PUBLIC_PRODUCT_NAME=MyApp
NEXT_PUBLIC_SITE_TITLE=MyApp - Build SaaS Faster
NEXT_PUBLIC_SITE_DESCRIPTION=The complete SaaS starter kit
NEXT_PUBLIC_DEFAULT_THEME_MODE=light
NEXT_PUBLIC_DEFAULT_LOCALE=en
# ============================================
# AUTHENTICATION
# ============================================
NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
# ============================================
# FEATURES
# ============================================
NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=false
# ============================================
# SUPABASE
# ============================================
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJhbGciOiJI...
SUPABASE_SECRET_KEY=eyJhbGciOiJI...
SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret
# ============================================
# BILLING (Stripe)
# ============================================
NEXT_PUBLIC_BILLING_PROVIDER=stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# ============================================
# EMAIL
# ============================================
MAILER_PROVIDER=resend
EMAIL_SENDER=noreply@yourdomain.com
RESEND_API_KEY=re_...
# ============================================
# CMS
# ============================================
CMS_CLIENT=keystatic
# ============================================
# OPTIONAL
# ============================================
CONTACT_EMAIL=support@yourdomain.com
LOGGER=pino
```
---
## Build-Time vs Runtime Variables
MakerKit uses Zod schemas to validate environment variables. Understanding when validation occurs helps debug issues:
### Build-Time Validation
Variables prefixed with `NEXT_PUBLIC_` are embedded at build time. If missing:
- Build fails with a clear error message
- You must add the variable and rebuild
### Runtime Validation
Server-only variables (without `NEXT_PUBLIC_` prefix) are validated when the server starts. If missing:
- The application may start but fail when accessing certain features
- Check server logs for validation errors
---
## Secrets Management
### What Counts as a Secret
These variables contain sensitive data and must be protected:
- `SUPABASE_SECRET_KEY`
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `LEMON_SQUEEZY_SECRET_KEY`
- `LEMON_SQUEEZY_SIGNING_SECRET`
- `SUPABASE_DB_WEBHOOK_SECRET`
- `RESEND_API_KEY`
- `EMAIL_PASSWORD`
- `CAPTCHA_SECRET_TOKEN`
### Best Practices
1. **Never commit secrets**: Use `.gitignore` to exclude `.env*.local` files
2. **Use hosting provider secrets**: Vercel, Cloudflare, etc. have secure environment variable storage
3. **Rotate compromised secrets**: If a secret is exposed, regenerate it immediately
4. **Limit access**: Only give team members access to secrets they need
---
## Platform-Specific Notes
### Vercel
- Add variables in **Project Settings > Environment Variables**
- Separate variables by environment (Production, Preview, Development)
- Use Vercel's sensitive variable feature for secrets
### Cloudflare
- Add variables to `.dev.vars` for local development
- Use Wrangler secrets for production: `wrangler secret put VARIABLE_NAME`
- Some variables may need to be in `wrangler.toml` for build-time access
### Docker
- Pass variables via `--env-file` flag
- Never bake secrets into Docker images
- Use Docker secrets or external secret managers for production
---
## Troubleshooting
### "Required environment variable X is missing"
The variable isn't set in your hosting provider. Add it and redeploy.
### "Invalid value for environment variable X"
The variable value doesn't match the expected format. Check:
- URLs should start with `https://` and have no trailing slash
- Boolean values should be `true` or `false` (not `"true"`)
- Provider names must match exactly (`stripe`, not `Stripe`)
### Variables work locally but not in production
1. Verify variables are set in your hosting provider (not just `.env.local`)
2. Check for typos in variable names
3. Ensure `NEXT_PUBLIC_` prefix is correct for client-side variables
4. Redeploy after adding variables (Vercel caches builds)
### Secrets appearing in client-side code
Only variables with `NEXT_PUBLIC_` prefix should be in client code. If server-only secrets appear:
1. Check you're not importing server modules in client components
2. Verify the variable name doesn't have `NEXT_PUBLIC_` prefix
3. Review your bundle with `next build --analyze`
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Why does MakerKit validate environment variables at build time?", "answer": "Build-time validation catches configuration errors before deployment. Missing a critical variable like STRIPE_SECRET_KEY would otherwise cause runtime errors that are harder to debug. Zod schemas ensure all required variables are present and correctly formatted."},
{"question": "What's the difference between NEXT_PUBLIC_ and regular variables?", "answer": "Variables prefixed with NEXT_PUBLIC_ are embedded in the client-side JavaScript bundle and visible to users. Never use this prefix for secrets. Regular variables are only available server-side and stay secure. This is a Next.js convention."},
{"question": "How do I add a new environment variable?", "answer": "Add the variable to your hosting provider's settings, then redeploy. For client-side variables (NEXT_PUBLIC_), you must rebuild since they're embedded at build time. For server-only variables, a restart is usually sufficient."},
{"question": "Can I use different variables for staging and production?", "answer": "Yes. Most hosting providers support environment-specific variables. Create separate Supabase projects for each environment, generate variables for each, and configure your hosting provider to use the right set based on the deployment environment."},
{"question": "What if I accidentally commit secrets to Git?", "answer": "Immediately rotate all exposed credentials. Generate new API keys for Supabase, Stripe, and any other affected services. Consider using git-secrets or similar tools to prevent future accidental commits. Review Git history and consider rewriting it if the repo is private."}
]
/%}
---
## Next Steps
- [Deployment Checklist](/docs/next-supabase-turbo/going-to-production/checklist): Complete deployment steps
- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Deploy to Vercel with CI/CD
- [Supabase Configuration](/docs/next-supabase-turbo/going-to-production/supabase): Set up Supabase with migrations and RLS

View File

@@ -0,0 +1,348 @@
---
status: "published"
title: "Deploy Supabase to Production"
label: "Deploy Supabase"
order: 1
description: "Complete guide to configuring your Supabase project for production deployment with MakerKit. Covers project setup, migrations, authentication, SMTP, and database webhooks."
---
Configure your Supabase project for production with Postgres database, Row Level Security (RLS) policies, authentication, and webhooks. This guide covers the complete setup for your MakerKit Next.js Supabase Turbo application.
{% alert type="warning" title="Complete all steps" %}
Skipping steps will cause authentication failures, missing data, or broken webhooks. Follow this guide completely before testing your application.
{% /alert %}
## Overview
| Task | Purpose |
|------|---------|
| Create project | Set up cloud database and auth |
| Push migrations | Create MakerKit database schema |
| Configure auth URLs | Enable OAuth redirects |
| Set up SMTP | Reliable email delivery |
| Update email templates | Fix PKCE authentication issues |
| Link project locally | Enable CLI deployments |
| Configure webhooks | Handle database events |
---
## Create a Supabase Project
If you're not self-hosting Supabase, create a project at [supabase.com](https://supabase.com).
1. Sign in to the [Supabase Dashboard](https://supabase.com/dashboard)
2. Click **New Project**
3. Choose your organization
4. Enter a project name and generate a database password
5. Select a region close to your users
{% alert type="default" title="Save your database password" %}
Copy the database password immediately. You cannot retrieve it later and will need it to link your local project.
{% /alert %}
---
## Retrieve API Credentials
Navigate to **Project Settings > API** to find your credentials:
| Credential | Environment Variable | Usage |
|------------|---------------------|-------|
| Project URL | `NEXT_PUBLIC_SUPABASE_URL` | Client and server connections |
| Anon (public) key | `NEXT_PUBLIC_SUPABASE_PUBLIC_KEY` | Client-side requests |
| Service role key | `SUPABASE_SECRET_KEY` | Server-side admin operations |
{% img src="/assets/courses/next-turbo/supabase-api-settings.webp" width="2500" height="1262" /%}
{% alert type="warning" title="Keep the service role key secret" %}
The service role key bypasses Row Level Security. Never expose it in client-side code or commit it to version control.
{% /alert %}
---
## Configure Authentication URLs
Set up redirect URLs so authentication flows work correctly.
Navigate to **Authentication > URL Configuration** and configure:
### Site URL
Your production domain:
```
https://yourdomain.com
```
### Redirect URLs
Add this pattern to allow all auth callbacks:
```
https://yourdomain.com/auth/callback**
```
The `**` wildcard matches any path after `/auth/callback`, which MakerKit uses for different auth flows.
{% alert type="default" title="Domain matching" %}
If your production URL includes `www`, use `www` in both the Site URL and Redirect URLs. Mismatched domains cause authentication failures.
{% /alert %}
---
## Configure SMTP
Supabase's built-in email service has strict rate limits (4 emails per hour) and low deliverability. Configure a real SMTP provider for production.
Navigate to **Project Settings > Authentication > SMTP Settings**:
1. Toggle **Enable Custom SMTP**
2. Enter your provider's credentials:
| Field | Example (Resend) |
|-------|------------------|
| Host | `smtp.resend.com` |
| Port | `465` |
| Username | `resend` |
| Password | Your API key |
| Sender email | `noreply@yourdomain.com` |
| Sender name | Your App Name |
Recommended SMTP providers:
- [Resend](https://resend.com): MakerKit has native integration, simple setup
- [SendGrid](https://sendgrid.com): High volume, good deliverability
- [Mailgun](https://mailgun.com): Developer-friendly, detailed analytics
- [Postmark](https://postmarkapp.com): Excellent deliverability, transactional focus
---
## Update Email Templates
MakerKit provides custom email templates that solve a common Supabase authentication issue.
### The Problem
Supabase uses PKCE (Proof Key for Code Exchange) for authentication. When a user clicks a confirmation link in their email and opens it in a different browser than where they signed up, authentication fails because the PKCE verifier is stored in the original browser.
### The Solution
MakerKit's templates use token hash URLs instead of PKCE, which work regardless of which browser opens the link.
### How to Update
1. Find the templates in your project at `apps/web/supabase/templates/`
2. In Supabase Dashboard, go to **Authentication > Email Templates**
3. For each email type (Confirm signup, Magic Link, etc.), replace the default template with MakerKit's version
4. Customize the templates with your branding
For detailed instructions, see the [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails) guide.
---
## Link Your Local Project
Connect your local development environment to your Supabase project using the CLI.
### Login to Supabase
```bash
pnpm --filter web supabase login
```
Follow the browser prompts to authenticate.
### Link the Project
```bash
pnpm --filter web supabase link
```
Select your project from the list and enter your database password when prompted.
**Verification**: Run `supabase projects list` to confirm the connection.
---
## Push Database Migrations
Deploy MakerKit's database schema to your production Supabase instance:
```bash
pnpm --filter web supabase db push
```
The CLI displays a list of migrations to apply. Review them and confirm.
**Expected tables**: After pushing, you should see these tables in your Supabase Dashboard Table Editor:
- `accounts`: Team and personal accounts
- `accounts_memberships`: User-account relationships
- `subscriptions`: Billing subscriptions
- `subscription_items`: Line items for subscriptions
- `invitations`: Team invitations
- `roles`: Custom role definitions
- `role_permissions`: Permission assignments
{% img src="/assets/courses/next-turbo/supabase-webhooks.webp" width="2062" height="876" /%}
---
## Configure Database Webhooks
MakerKit uses database webhooks to respond to data changes. The primary webhook handles subscription cleanup when accounts are deleted.
### Generate a Webhook Secret
Create a strong secret for authenticating webhook requests:
```bash
openssl rand -base64 32
```
Save this as `SUPABASE_DB_WEBHOOK_SECRET` in your hosting provider's environment variables.
### Why Webhooks Matter
When a user deletes their account, MakerKit needs to:
1. Cancel their subscription with the billing provider
2. Clean up related data
The webhook triggers this cleanup automatically by calling your application's `/api/db/webhook` endpoint.
### Create the Webhook
In Supabase Dashboard, navigate to **Database > Webhooks**:
1. Click **Enable Webhooks** if prompted
2. Click **Create a new hook**
3. Configure the webhook:
| Setting | Value |
|---------|-------|
| Name | `subscriptions_delete` |
| Table | `public.subscriptions` |
| Events | `DELETE` |
| Type | `HTTP Request` |
| Method | `POST` |
| URL | `https://yourdomain.com/api/db/webhook` |
| Timeout | `5000` |
4. Add a header for authentication:
- **Name**: `X-Supabase-Event-Signature`
- **Value**: Your `SUPABASE_DB_WEBHOOK_SECRET` value
{% alert type="warning" title="Use your production URL" %}
The webhook URL must be publicly accessible. Do not use:
- `localhost` or `127.0.0.1`
- Vercel preview URLs (they require authentication)
- Private network addresses
Test accessibility by visiting the URL in an incognito browser window.
{% /alert %}
### Webhook Configuration Reference
For reference, this is equivalent to the SQL trigger used in local development (from `seed.sql`):
```sql
create trigger "subscriptions_delete"
after delete
on "public"."subscriptions"
for each row
execute function "supabase_functions"."http_request"(
'https://yourdomain.com/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"YOUR_SECRET"}',
'{}',
'5000'
);
```
### Webhooks for Older Versions
If you're using MakerKit version 2.17.1 or earlier, you need additional webhooks:
| Table | Event | Purpose |
|-------|-------|---------|
| `public.accounts` | `DELETE` | Clean up account data |
| `public.subscriptions` | `DELETE` | Cancel billing subscription |
| `public.invitations` | `INSERT` | Send invitation emails |
Version 2.17.2+ handles invitations through server actions, so only the subscriptions webhook is required.
---
## Set Up Google Auth (Optional)
If you want Google login, configure it in both Google Cloud and Supabase.
### In Google Cloud Console
1. Create a project at [console.cloud.google.com](https://console.cloud.google.com)
2. Navigate to **APIs & Services > Credentials**
3. Click **Create Credentials > OAuth client ID**
4. Select **Web application**
5. Add authorized redirect URI from Supabase (found in **Authentication > Providers > Google**)
### In Supabase Dashboard
1. Go to **Authentication > Providers**
2. Enable **Google**
3. Enter your Client ID and Client Secret from Google Cloud
For detailed setup, see the [Supabase Google Auth documentation](https://supabase.com/docs/guides/auth/social-login/auth-google).
MakerKit automatically shows Google login when you enable it in Supabase. No code changes needed.
---
## Troubleshooting
### "Invalid PKCE verifier" error
Users see this when clicking email links from a different browser. Update your email templates to use MakerKit's token hash approach. See [Authentication Emails](/docs/next-supabase-turbo/going-to-production/authentication-emails).
### Webhooks not triggering
1. Verify the URL is publicly accessible
2. Check the `X-Supabase-Event-Signature` header matches your environment variable
3. Review logs in **Database > Webhooks** for error messages
4. Ensure your application is deployed and running
### Authentication redirect fails
1. Confirm Site URL matches your exact domain (including `www` if used)
2. Verify Redirect URL includes the `**` wildcard
3. Check browser console for specific error messages
### Emails not delivered
1. Verify SMTP settings in Supabase Dashboard
2. Check your email provider's dashboard for delivery logs
3. Confirm your sending domain has proper DNS records (SPF, DKIM, DMARC)
### Database password lost
If you forgot your database password, reset it in **Project Settings > Database > Database Password**. You'll need to re-link your local project after resetting.
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Can I use Supabase's free tier for production?", "answer": "The free tier works for early-stage apps with low traffic. It includes 500MB database storage, 1GB bandwidth, and 2GB file storage. For production apps expecting traffic, upgrade to the Pro plan ($25/month) for better performance, daily backups, and no pausing after inactivity."},
{"question": "How do I migrate from local development to production Supabase?", "answer": "Run 'pnpm --filter web supabase link' to connect your local project to the production instance, then 'pnpm --filter web supabase db push' to apply migrations. The CLI handles schema differences and shows you exactly what will change before applying."},
{"question": "Do I need to manually create RLS policies?", "answer": "No. MakerKit's migrations include all necessary RLS policies for the core tables (accounts, subscriptions, invitations, etc.). The policies are applied automatically when you push migrations. You only need to add policies for custom tables you create."},
{"question": "Why do I need database webhooks?", "answer": "Webhooks notify your application when database events occur. MakerKit uses them to cancel billing subscriptions when accounts are deleted."},
{"question": "Can I self-host Supabase instead of using their cloud?", "answer": "Yes. Supabase is open source and can be self-hosted. See the Supabase self-hosting documentation for Docker and Kubernetes options. You'll need to manage backups, updates, and infrastructure yourself."}
]
/%}
---
## Next Steps
- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference with Zod schemas
- [Vercel Deployment](/docs/next-supabase-turbo/going-to-production/vercel): Deploy your Next.js application to Vercel
- [Authentication Configuration](/docs/next-supabase-turbo/going-to-production/authentication): Configure OAuth providers and SMTP

View File

@@ -0,0 +1,309 @@
---
status: "published"
title: "Deploy Next.js Supabase to Vercel"
label: "Deploy to Vercel"
order: 5
description: "Deploy your MakerKit Next.js Supabase application to Vercel. Covers project setup, environment variables, monorepo configuration, and Edge Functions deployment."
---
Deploy your MakerKit Next.js 16 application to Vercel with automatic CI/CD, preview deployments, and serverless functions. Vercel is the recommended hosting platform for Next.js apps due to its native support for App Router, Server Actions, and ISR caching.
## Prerequisites
Before deploying, complete these steps:
1. [Set up your Supabase project](/docs/next-supabase-turbo/going-to-production/supabase)
2. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables)
3. Push your code to a Git repository (GitHub, GitLab, or Bitbucket)
---
## Connect Your Repository
1. Sign in to [Vercel](https://vercel.com)
2. Click **Add New Project**
3. Import your Git repository
4. Configure the project settings:
{% img src="/assets/images/docs/vercel-turbo-preset.webp" width="1744" height="854" /%}
### Required Settings
| Setting | Value |
|---------|-------|
| Framework Preset | Next.js |
| Root Directory | `apps/web` |
| Build Command | (leave default) |
| Output Directory | (leave default) |
{% alert type="warning" title="Set the root directory" %}
MakerKit uses a monorepo structure. You must set the root directory to `apps/web` or the build will fail.
{% /alert %}
---
## Configure Environment Variables
Add your production environment variables in the Vercel project settings.
### Required Variables
Generate these using `pnpm turbo gen env` in your local project:
```bash
# Application
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
NEXT_PUBLIC_PRODUCT_NAME=Your App Name
NEXT_PUBLIC_SITE_TITLE=Your App Title
NEXT_PUBLIC_SITE_DESCRIPTION=Your app description
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJhbGciOiJI...
SUPABASE_SECRET_KEY=eyJhbGciOiJI...
# Billing (Stripe example)
NEXT_PUBLIC_BILLING_PROVIDER=stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Email
MAILER_PROVIDER=resend
EMAIL_SENDER=noreply@yourdomain.com
RESEND_API_KEY=re_...
# Webhooks
SUPABASE_DB_WEBHOOK_SECRET=your-webhook-secret
```
{% img src="/assets/images/docs/vercel-env-variables-turbo.webp" width="1694" height="1874" /%}
### First Deployment Note
Your first deployment will likely fail if you set `NEXT_PUBLIC_SITE_URL` to a custom domain you haven't configured yet. This is expected. Options:
1. **Use the Vercel URL first**: Set `NEXT_PUBLIC_SITE_URL` to `https://your-project.vercel.app`, deploy, then update to your custom domain later
2. **Accept the failure**: Deploy once to get the Vercel URL, then update the environment variable and redeploy
---
## Deploy
Click **Deploy** in Vercel. The build process:
1. Installs dependencies with pnpm
2. Builds the Next.js application
3. Validates environment variables (using Zod schemas)
4. Deploys to Vercel's edge network
### Build Validation
MakerKit validates environment variables at build time. If variables are missing, the build fails with specific error messages:
```
Error: Required environment variable STRIPE_SECRET_KEY is missing
```
Check the build logs to identify missing variables, add them in Vercel settings, and redeploy.
---
## Post-Deployment Setup
After successful deployment:
### 1. Update Supabase URLs
In your Supabase Dashboard (**Authentication > URL Configuration**):
| Field | Value |
|-------|-------|
| Site URL | `https://yourdomain.com` |
| Redirect URLs | `https://yourdomain.com/auth/callback**` |
### 2. Configure Billing Webhooks
Point your billing provider's webhooks to your Vercel deployment:
- **Stripe**: `https://yourdomain.com/api/billing/webhook`
- **Lemon Squeezy**: `https://yourdomain.com/api/billing/webhook`
### 3. Set Up Database Webhooks
Configure the Supabase database webhook to point to:
```
https://yourdomain.com/api/db/webhook
```
See the [Supabase deployment guide](/docs/next-supabase-turbo/going-to-production/supabase#configure-database-webhooks) for details.
---
## Custom Domain
To use a custom domain:
1. Go to your Vercel project **Settings > Domains**
2. Add your domain
3. Configure DNS records as instructed by Vercel
4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain
5. Update Supabase Site URL and Redirect URLs
---
## Edge Functions Deployment (Optional)
Vercel supports Edge Functions for faster cold starts and lower latency. This requires some configuration changes.
### When to Use Edge Functions
**Benefits**:
- Zero cold starts
- Lower latency (runs closer to users)
- Lower costs for high-traffic applications
**Trade-offs**:
- Limited Node.js API support
- Potentially higher database latency (depends on region setup)
- Requires HTTP-based mailer (nodemailer not supported)
- Requires remote CMS (local Keystatic not supported)
### Configuration Changes
Apply the same changes as [Cloudflare deployment](/docs/next-supabase-turbo/going-to-production/cloudflare):
#### 1. Switch to HTTP-Based Mailer
The default nodemailer uses Node.js APIs unavailable in Edge runtime. Use Resend instead:
```bash
MAILER_PROVIDER=resend
RESEND_API_KEY=re_...
```
#### 2. Switch CMS Mode
Keystatic's local mode uses the file system, which isn't available in Edge runtime. Options:
- **WordPress**: Set `CMS_CLIENT=wordpress`
- **Keystatic GitHub mode**: Configure Keystatic to use GitHub as the data source
See the [CMS documentation](/docs/next-supabase-turbo/content/cms) for setup instructions.
#### 3. Update Stripe Client (if using Stripe)
Open `packages/billing/stripe/src/services/stripe-sdk.ts` and add the `httpClient` option to the Stripe constructor:
```typescript
return new Stripe(stripeServerEnv.secretKey, {
apiVersion: STRIPE_API_VERSION,
httpClient: Stripe.createFetchHttpClient(), // ADD THIS LINE
});
```
{% alert type="warning" title="Manual change required" %}
This modification is not included in MakerKit by default. Add the `httpClient` line when deploying to Vercel Edge Functions.
{% /alert %}
#### 4. Use Console Logger
Pino logger isn't compatible with Edge runtime:
```bash
LOGGER=console
```
---
## Multiple Apps Deployment
If you have multiple apps in your monorepo, Vercel automatically deploys the `web` app.
For additional apps, customize the build command in Vercel project settings:
```bash
cd ../.. && turbo run build --filter=<app-name>
```
Replace `<app-name>` with your app's package name (from its `package.json`).
Set the root directory to your app's path (e.g., `apps/admin`).
For more details, see the [Vercel Turborepo documentation](https://vercel.com/docs/monorepos/turborepo).
---
## Troubleshooting
### Build fails with "command not found: pnpm"
Vercel may default to npm. In your project settings, explicitly set:
- **Install Command**: `pnpm i`
- **Build Command**: `pnpm run build`
### Build fails with missing dependencies
Ensure your `package.json` dependencies are correctly listed. Turborepo should handle monorepo dependencies automatically.
### Environment variable validation fails
MakerKit uses Zod to validate environment variables. The error message shows which variable is missing or invalid. Add or correct it in Vercel settings.
### Preview deployments have authentication issues
Vercel preview deployments use unique URLs that may not match your Supabase Redirect URLs. Options:
1. Add a wildcard pattern to Supabase Redirect URLs: `https://*-your-project.vercel.app/auth/callback**`
2. Disable authentication features in preview environments
### Webhooks not received on preview deployments
Preview deployment URLs are not publicly accessible by default. Database and billing webhooks will only work on production deployments with public URLs.
---
## Performance Optimization
### Enable ISR Caching
MakerKit supports Incremental Static Regeneration for marketing pages. This is configured by default in the `next.config.mjs`.
### Configure Regions
In `vercel.json` (create in project root if needed):
```json
{
"regions": ["iad1"]
}
```
Choose a region close to your Supabase database for lower latency.
### Monitor with Vercel Analytics
Enable Vercel Analytics in your project settings for performance monitoring. MakerKit is compatible with Vercel's built-in analytics.
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "What's the cost of hosting on Vercel?", "answer": "Vercel's Hobby tier is free and works for personal projects and low-traffic apps. The Pro tier ($20/month) adds team features, more bandwidth, and commercial use rights. Most MakerKit apps start on Hobby and upgrade when they get traction."},
{"question": "How do I handle preview deployments with Supabase?", "answer": "Preview deployments get unique URLs that won't match your Supabase redirect URLs. Add a wildcard pattern like 'https://*-yourproject.vercel.app/auth/callback**' to your Supabase Redirect URLs, or create a separate Supabase project for staging."},
{"question": "Why is my build failing with environment variable errors?", "answer": "MakerKit validates environment variables at build time using Zod schemas. Check the build logs for the specific variable name, add it in Vercel's Environment Variables settings, and redeploy. Variables prefixed with NEXT_PUBLIC_ must be set before building."},
{"question": "How do I deploy multiple apps from the monorepo?", "answer": "Create separate Vercel projects for each app. Set the Root Directory to the app's path (e.g., 'apps/admin') and customize the build command to target that specific app with Turborepo's filter flag."}
]
/%}
---
## Next Steps
- [Environment Variables Reference](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable list with Zod validation
- [Supabase Configuration](/docs/next-supabase-turbo/going-to-production/supabase): Database, RLS policies, and webhook setup
- [Cloudflare Deployment](/docs/next-supabase-turbo/going-to-production/cloudflare): Alternative deployment with Edge runtime
- [Monitoring Setup](/docs/next-supabase-turbo/monitoring/overview): Error tracking with Sentry or PostHog

View File

@@ -0,0 +1,418 @@
---
status: "published"
title: "Deploy Next.js Supabase to a VPS"
label: "Deploy to VPS"
order: 9
description: "Deploy your MakerKit Next.js Supabase application to a VPS like Digital Ocean, Hetzner, or Linode. Covers server setup, Docker deployment, Nginx, and SSL configuration."
---
Deploy your MakerKit Next.js 16 application to a Virtual Private Server (VPS) for full infrastructure control and predictable costs. This guide covers Ubuntu server setup, Docker deployment, Nginx reverse proxy, and Let's Encrypt SSL. The steps work with Digital Ocean, Hetzner, Linode, Vultr, and other VPS providers.
## Overview
| Step | Purpose |
|------|---------|
| Create VPS | Provision your server |
| Install dependencies | Docker, Node.js, Nginx |
| Deploy application | Using Docker or direct build |
| Configure Nginx | Reverse proxy and SSL |
| Set up SSL | HTTPS with Let's Encrypt |
---
## Prerequisites
Before starting:
1. [Set up Supabase](/docs/next-supabase-turbo/going-to-production/supabase) project
2. [Generate environment variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables)
3. Domain name pointing to your VPS IP address
---
## Step 1: Create Your VPS
### Digital Ocean
1. Go to [Digital Ocean](https://www.digitalocean.com/)
2. Click **Create Droplet**
3. Choose:
- **OS**: Ubuntu 24.04 LTS
- **Plan**: Basic ($12/month minimum recommended for building)
- **Region**: Close to your users and Supabase instance
4. Add your SSH key for secure access
5. Create the Droplet
### Recommended Specifications
| Use Case | RAM | CPU | Storage |
|----------|-----|-----|---------|
| Building on VPS | 4GB+ | 2 vCPU | 50GB |
| Running only (pre-built image) | 2GB | 1 vCPU | 25GB |
| Production with traffic | 4GB+ | 2 vCPU | 50GB |
---
## Step 2: Initial Server Setup
SSH into your server:
```bash
ssh root@your-server-ip
```
### Update System
```bash
apt update && apt upgrade -y
```
### Install Docker
Follow the [official Docker installation guide](https://docs.docker.com/engine/install/ubuntu/) or run:
```bash
# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Set up repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
```
### Configure Firewall
```bash
# Allow SSH
ufw allow 22
# Allow HTTP and HTTPS
ufw allow 80
ufw allow 443
# Allow app port (if not using Nginx)
ufw allow 3000
# Enable firewall
ufw enable
```
---
## Step 3: Deploy Your Application
Choose one of two approaches:
### Option A: Pull Pre-Built Docker Image (Recommended)
If you built and pushed your image to a container registry (see [Docker guide](/docs/next-supabase-turbo/going-to-production/docker)):
```bash
# Login to registry
docker login ghcr.io
# Pull your image
docker pull ghcr.io/YOUR_USERNAME/myapp:latest
# Create env file
nano .env.production.local
# Paste your environment variables
# Run container
docker run -d \
-p 3000:3000 \
--env-file .env.production.local \
--name myapp \
--restart unless-stopped \
ghcr.io/YOUR_USERNAME/myapp:latest
```
### Option B: Build on VPS
For VPS with enough resources (4GB+ RAM):
#### Install Node.js and pnpm
```bash
# Install nvm (check https://github.com/nvm-sh/nvm for latest version)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
# Load nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Install Node.js (LTS version)
nvm install --lts
# Install pnpm
npm install -g pnpm
```
#### Clone and Build
```bash
# Create Personal Access Token on GitHub with repo access
# Clone repository
git clone https://<YOUR_GITHUB_PAT>@github.com/YOUR_USERNAME/your-repo.git
cd your-repo
# Install dependencies
pnpm install
# Generate Dockerfile
pnpm run turbo gen docker
# Create env file
cp turbo/generators/templates/env/.env.local apps/web/.env.production.local
nano apps/web/.env.production.local
# Edit with your production values
# Build Docker image
docker build -t myapp:latest .
# Run container
docker run -d \
-p 3000:3000 \
--env-file apps/web/.env.production.local \
--name myapp \
--restart unless-stopped \
myapp:latest
```
{% alert type="warning" title="Memory during build" %}
If the build fails with memory errors, increase your VPS size temporarily or build locally and push to a registry.
{% /alert %}
---
## Step 4: Configure Nginx
Install Nginx as a reverse proxy:
```bash
apt install -y nginx
```
### Create Nginx Configuration
```bash
nano /etc/nginx/sites-available/myapp
```
Add:
```
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
}
```
### Enable the Site
```bash
# Create symlink
ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
# Remove default site
rm /etc/nginx/sites-enabled/default
# Test configuration
nginx -t
# Restart Nginx
systemctl restart nginx
```
---
## Step 5: Set Up SSL with Let's Encrypt
Install Certbot:
```bash
apt install -y certbot python3-certbot-nginx
```
Obtain SSL certificate:
```bash
certbot --nginx -d yourdomain.com -d www.yourdomain.com
```
Certbot automatically:
1. Obtains the certificate
2. Updates Nginx configuration
3. Sets up auto-renewal
Verify auto-renewal:
```bash
certbot renew --dry-run
```
---
## Step 6: Post-Deployment Configuration
### Update Supabase URLs
In Supabase Dashboard (**Authentication > URL Configuration**):
| Field | Value |
|-------|-------|
| Site URL | `https://yourdomain.com` |
| Redirect URLs | `https://yourdomain.com/auth/callback**` |
### Configure Webhooks
Point your webhooks to your new domain:
- **Supabase DB webhook**: `https://yourdomain.com/api/db/webhook`
- **Stripe webhook**: `https://yourdomain.com/api/billing/webhook`
- **Lemon Squeezy webhook**: `https://yourdomain.com/api/billing/webhook`
---
## Monitoring and Maintenance
### View Logs
```bash
# Docker logs
docker logs -f myapp
# Nginx access logs
tail -f /var/log/nginx/access.log
# Nginx error logs
tail -f /var/log/nginx/error.log
```
### Restart Application
```bash
docker restart myapp
```
### Update Application
```bash
# Pull new image
docker pull ghcr.io/YOUR_USERNAME/myapp:latest
# Stop old container
docker stop myapp
docker rm myapp
# Start new container
docker run -d \
-p 3000:3000 \
--env-file .env.production.local \
--name myapp \
--restart unless-stopped \
ghcr.io/YOUR_USERNAME/myapp:latest
```
### Automated Updates with Watchtower (Optional)
Auto-update containers when new images are pushed:
```bash
docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower \
--interval 300 \
myapp
```
---
## Troubleshooting
### Application not accessible
1. Check Docker container is running: `docker ps`
2. Check firewall allows port 3000: `ufw status`
3. Check Nginx is running: `systemctl status nginx`
4. Check Nginx config: `nginx -t`
### SSL certificate issues
1. Ensure DNS is properly configured
2. Wait for DNS propagation (up to 48 hours)
3. Check Certbot logs: `cat /var/log/letsencrypt/letsencrypt.log`
### Container keeps restarting
Check logs for errors:
```bash
docker logs myapp
```
Common causes:
- Missing environment variables
- Database connection issues
- Port conflicts
### High memory usage
Monitor with:
```bash
docker stats
```
Consider:
1. Increasing VPS size
2. Configuring memory limits on container
3. Enabling swap space
---
## Cost Comparison
| Provider | Basic VPS | Notes |
|----------|-----------|-------|
| Digital Ocean | $12/month | Good documentation |
| Hetzner | $4/month | Best value, EU-based |
| Linode | $12/month | Owned by Akamai |
| Vultr | $12/month | Good global coverage |
---
{% faq
title="Frequently Asked Questions"
items=[
{"question": "Which VPS provider should I choose?", "answer": "Hetzner offers the best value at $4-5/month for a capable server. Digital Ocean has better documentation and a simpler interface at $12/month. Choose based on your region needs and whether you value cost or convenience."},
{"question": "How much RAM do I need?", "answer": "2GB RAM is minimum for running a pre-built Docker container. 4GB+ is needed if building on the VPS itself. For production with traffic, 4GB provides headroom for spikes. Monitor usage and scale up if you see memory pressure."},
{"question": "Do I need Nginx if I'm using Docker?", "answer": "Yes, for production. Nginx handles SSL termination, serves static files efficiently, and provides a buffer between the internet and your app. It also enables zero-downtime deployments by proxying to new containers while the old ones drain."},
{"question": "Is VPS cheaper than Vercel?", "answer": "For low traffic, Vercel's free tier is cheaper. For high traffic or predictable workloads, VPS is often cheaper. A $12/month Digital Ocean droplet handles more requests than Vercel's Pro tier at $20/month, but you manage everything yourself."}
]
/%}
---
## Next Steps
- [Docker Deployment](/docs/next-supabase-turbo/going-to-production/docker): Build and push Docker images with CI/CD
- [Monitoring Setup](/docs/next-supabase-turbo/monitoring/overview): Add Sentry or PostHog for error tracking
- [Environment Variables](/docs/next-supabase-turbo/going-to-production/production-environment-variables): Complete variable reference

Some files were not shown because too many files have changed in this diff Show More