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:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
69
docs/admin/adding-super-admin.mdoc
Normal file
69
docs/admin/adding-super-admin.mdoc
Normal 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.
|
||||
363
docs/analytics/analytics-and-events.mdoc
Normal file
363
docs/analytics/analytics-and-events.mdoc
Normal 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
|
||||
360
docs/analytics/custom-analytics-provider.mdoc
Normal file
360
docs/analytics/custom-analytics-provider.mdoc
Normal 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
|
||||
148
docs/analytics/google-analytics-provider.mdoc
Normal file
148
docs/analytics/google-analytics-provider.mdoc
Normal 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
|
||||
32
docs/analytics/meshes-provider.mdoc
Normal file
32
docs/analytics/meshes-provider.mdoc
Normal 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.
|
||||
202
docs/analytics/posthog-analytics-provider.mdoc
Normal file
202
docs/analytics/posthog-analytics-provider.mdoc
Normal 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
|
||||
151
docs/analytics/umami-analytics-provider.mdoc
Normal file
151
docs/analytics/umami-analytics-provider.mdoc
Normal 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
448
docs/api/account-api.mdoc
Normal 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
|
||||
473
docs/api/account-workspace-api.mdoc
Normal file
473
docs/api/account-workspace-api.mdoc
Normal 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
|
||||
531
docs/api/authentication-api.mdoc
Normal file
531
docs/api/authentication-api.mdoc
Normal 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
302
docs/api/otp-api.mdoc
Normal 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
551
docs/api/policies-api.mdoc
Normal 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
415
docs/api/registry-api.mdoc
Normal 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
|
||||
650
docs/api/team-account-api.mdoc
Normal file
650
docs/api/team-account-api.mdoc
Normal 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
|
||||
358
docs/api/user-workspace-api.mdoc
Normal file
358
docs/api/user-workspace-api.mdoc
Normal 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
|
||||
461
docs/billing/billing-api.mdoc
Normal file
461
docs/billing/billing-api.mdoc
Normal 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
|
||||
632
docs/billing/billing-schema.mdoc
Normal file
632
docs/billing/billing-schema.mdoc
Normal 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
|
||||
467
docs/billing/billing-webhooks.mdoc
Normal file
467
docs/billing/billing-webhooks.mdoc
Normal 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
|
||||
487
docs/billing/credit-based-billing.mdoc
Normal file
487
docs/billing/credit-based-billing.mdoc
Normal 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
|
||||
638
docs/billing/custom-integration.mdoc
Normal file
638
docs/billing/custom-integration.mdoc
Normal 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
|
||||
266
docs/billing/lemon-squeezy.mdoc
Normal file
266
docs/billing/lemon-squeezy.mdoc
Normal 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
|
||||
399
docs/billing/metered-usage.mdoc
Normal file
399
docs/billing/metered-usage.mdoc
Normal 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
|
||||
387
docs/billing/one-off-payments.mdoc
Normal file
387
docs/billing/one-off-payments.mdoc
Normal 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
268
docs/billing/overview.mdoc
Normal 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
475
docs/billing/paddle.mdoc
Normal 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)
|
||||
293
docs/billing/per-seat-billing.mdoc
Normal file
293
docs/billing/per-seat-billing.mdoc
Normal 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
292
docs/billing/stripe.mdoc
Normal 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
|
||||
84
docs/components/app-breadcrumbs.mdoc
Normal file
84
docs/components/app-breadcrumbs.mdoc
Normal 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.
|
||||
86
docs/components/bordered-navigation-menu.mdoc
Normal file
86
docs/components/bordered-navigation-menu.mdoc
Normal 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.
|
||||
156
docs/components/card-button.mdoc
Normal file
156
docs/components/card-button.mdoc
Normal 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.
|
||||
116
docs/components/coming-soon.mdoc
Normal file
116
docs/components/coming-soon.mdoc
Normal 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'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'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;
|
||||
```
|
||||
130
docs/components/cookie-banner.mdoc
Normal file
130
docs/components/cookie-banner.mdoc
Normal 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.
|
||||
108
docs/components/data-table.mdoc
Normal file
108
docs/components/data-table.mdoc
Normal 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.
|
||||
95
docs/components/empty-state.mdoc
Normal file
95
docs/components/empty-state.mdoc
Normal 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
95
docs/components/if.mdoc
Normal 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.
|
||||
96
docs/components/loading-overlay.mdoc
Normal file
96
docs/components/loading-overlay.mdoc
Normal 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.
|
||||
566
docs/components/marketing-components.mdoc
Normal file
566
docs/components/marketing-components.mdoc
Normal 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.
|
||||
345
docs/components/multi-step-forms.mdoc
Normal file
345
docs/components/multi-step-forms.mdoc
Normal 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
136
docs/components/page.mdoc
Normal 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
1621
docs/components/shadcn.mdoc
Normal file
File diff suppressed because it is too large
Load Diff
111
docs/components/stepper.mdoc
Normal file
111
docs/components/stepper.mdoc
Normal 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.
|
||||
133
docs/configuration/application-configuration.mdoc
Normal file
133
docs/configuration/application-configuration.mdoc
Normal 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
|
||||
206
docs/configuration/authentication-configuration.mdoc
Normal file
206
docs/configuration/authentication-configuration.mdoc
Normal 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
|
||||
305
docs/configuration/environment-variables.mdoc
Normal file
305
docs/configuration/environment-variables.mdoc
Normal 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
|
||||
260
docs/configuration/feature-flags-configuration.mdoc
Normal file
260
docs/configuration/feature-flags-configuration.mdoc
Normal 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
|
||||
214
docs/configuration/paths-configuration.mdoc
Normal file
214
docs/configuration/paths-configuration.mdoc
Normal 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
|
||||
291
docs/configuration/personal-account-sidebar-configuration.mdoc
Normal file
291
docs/configuration/personal-account-sidebar-configuration.mdoc
Normal 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
|
||||
265
docs/configuration/team-account-sidebar-configuration.mdoc
Normal file
265
docs/configuration/team-account-sidebar-configuration.mdoc
Normal 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
467
docs/content/cms-api.mdoc
Normal 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
128
docs/content/cms.mdoc
Normal 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
|
||||
629
docs/content/creating-your-own-cms-client.mdoc
Normal file
629
docs/content/creating-your-own-cms-client.mdoc
Normal 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
321
docs/content/keystatic.mdoc
Normal 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
|
||||

|
||||
```
|
||||
|
||||
### 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
423
docs/content/supabase.mdoc
Normal 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
293
docs/content/wordpress.mdoc
Normal 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
|
||||
278
docs/customization/fonts.mdoc
Normal file
278
docs/customization/fonts.mdoc
Normal 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)
|
||||
285
docs/customization/layout-style.mdoc
Normal file
285
docs/customization/layout-style.mdoc
Normal 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
|
||||
194
docs/customization/logo.mdoc
Normal file
194
docs/customization/logo.mdoc
Normal 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)
|
||||
236
docs/customization/tailwind-css.mdoc
Normal file
236
docs/customization/tailwind-css.mdoc
Normal 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
|
||||
252
docs/customization/theme.mdoc
Normal file
252
docs/customization/theme.mdoc
Normal 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
|
||||
209
docs/data-fetching/captcha-protection.mdoc
Normal file
209
docs/data-fetching/captcha-protection.mdoc
Normal 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 });
|
||||
```
|
||||
32
docs/data-fetching/csrf-protection.mdoc
Normal file
32
docs/data-fetching/csrf-protection.mdoc
Normal 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.
|
||||
713
docs/data-fetching/react-query.mdoc
Normal file
713
docs/data-fetching/react-query.mdoc
Normal 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
|
||||
567
docs/data-fetching/route-handlers.mdoc
Normal file
567
docs/data-fetching/route-handlers.mdoc
Normal 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
|
||||
816
docs/data-fetching/server-actions.mdoc
Normal file
816
docs/data-fetching/server-actions.mdoc
Normal 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
|
||||
487
docs/data-fetching/server-components.mdoc
Normal file
487
docs/data-fetching/server-components.mdoc
Normal 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
|
||||
354
docs/data-fetching/supabase-clients.mdoc
Normal file
354
docs/data-fetching/supabase-clients.mdoc
Normal 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
|
||||
159
docs/dev-tools/environment-variables.mdoc
Normal file
159
docs/dev-tools/environment-variables.mdoc
Normal 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.
|
||||
39
docs/dev-tools/translations.mdoc
Normal file
39
docs/dev-tools/translations.mdoc
Normal 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.
|
||||
|
||||
263
docs/development/adding-turborepo-app.mdoc
Normal file
263
docs/development/adding-turborepo-app.mdoc
Normal 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
|
||||
364
docs/development/adding-turborepo-package.mdoc
Normal file
364
docs/development/adding-turborepo-package.mdoc
Normal 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
|
||||
304
docs/development/application-tests.mdoc
Normal file
304
docs/development/application-tests.mdoc
Normal 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.
|
||||
215
docs/development/approaching-local-development.mdoc
Normal file
215
docs/development/approaching-local-development.mdoc
Normal 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.
|
||||
821
docs/development/database-architecture.mdoc
Normal file
821
docs/development/database-architecture.mdoc
Normal 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.
|
||||
341
docs/development/database-functions.mdoc
Normal file
341
docs/development/database-functions.mdoc
Normal 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
|
||||
542
docs/development/database-schema.mdoc
Normal file
542
docs/development/database-schema.mdoc
Normal 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!
|
||||
961
docs/development/database-tests.mdoc
Normal file
961
docs/development/database-tests.mdoc
Normal 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.
|
||||
263
docs/development/database-webhooks.mdoc
Normal file
263
docs/development/database-webhooks.mdoc
Normal 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
|
||||
210
docs/development/external-marketing-website.mdoc
Normal file
210
docs/development/external-marketing-website.mdoc
Normal 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
|
||||
221
docs/development/legal-pages.mdoc
Normal file
221
docs/development/legal-pages.mdoc
Normal 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
|
||||
231
docs/development/loading-data-from-database.mdoc
Normal file
231
docs/development/loading-data-from-database.mdoc
Normal 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.
|
||||
409
docs/development/marketing-pages.mdoc
Normal file
409
docs/development/marketing-pages.mdoc
Normal 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
|
||||
309
docs/development/migrations.mdoc
Normal file
309
docs/development/migrations.mdoc
Normal 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
|
||||
526
docs/development/permissions-and-roles.mdoc
Normal file
526
docs/development/permissions-and-roles.mdoc
Normal 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
432
docs/development/seo.mdoc
Normal 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
|
||||
294
docs/development/writing-data-to-database.mdoc
Normal file
294
docs/development/writing-data-to-database.mdoc
Normal 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>
|
||||
```
|
||||
242
docs/emails/authentication-emails.mdoc
Normal file
242
docs/emails/authentication-emails.mdoc
Normal 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
|
||||
377
docs/emails/custom-mailer.mdoc
Normal file
377
docs/emails/custom-mailer.mdoc
Normal 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
|
||||
184
docs/emails/email-configuration.mdoc
Normal file
184
docs/emails/email-configuration.mdoc
Normal 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
|
||||
350
docs/emails/email-templates.mdoc
Normal file
350
docs/emails/email-templates.mdoc
Normal 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
188
docs/emails/inbucket.mdoc
Normal 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
|
||||
273
docs/emails/sending-emails.mdoc
Normal file
273
docs/emails/sending-emails.mdoc
Normal 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
|
||||
309
docs/going-to-production/authentication-emails.mdoc
Normal file
309
docs/going-to-production/authentication-emails.mdoc
Normal 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>© 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
|
||||
326
docs/going-to-production/authentication.mdoc
Normal file
326
docs/going-to-production/authentication.mdoc
Normal 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
|
||||
338
docs/going-to-production/checklist.mdoc
Normal file
338
docs/going-to-production/checklist.mdoc
Normal 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
|
||||
342
docs/going-to-production/cloudflare.mdoc
Normal file
342
docs/going-to-production/cloudflare.mdoc
Normal 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
|
||||
388
docs/going-to-production/docker.mdoc
Normal file
388
docs/going-to-production/docker.mdoc
Normal 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
|
||||
369
docs/going-to-production/production-environment-variables.mdoc
Normal file
369
docs/going-to-production/production-environment-variables.mdoc
Normal 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
|
||||
348
docs/going-to-production/supabase.mdoc
Normal file
348
docs/going-to-production/supabase.mdoc
Normal 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
|
||||
309
docs/going-to-production/vercel.mdoc
Normal file
309
docs/going-to-production/vercel.mdoc
Normal 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
|
||||
418
docs/going-to-production/vps.mdoc
Normal file
418
docs/going-to-production/vps.mdoc
Normal 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
Reference in New Issue
Block a user