Next.js Supabase V3 (#463)

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

View File

@@ -0,0 +1,294 @@
---
status: "published"
title: "Notification UI Components"
label: "UI Components"
description: "Use the NotificationsPopover component or build custom notification UIs with the provided React hooks."
order: 2
---
MakerKit provides a ready-to-use `NotificationsPopover` component and React hooks for building custom notification interfaces.
## NotificationsPopover
The default notification UI: a bell icon with badge that opens a dropdown list.
```tsx
import { NotificationsPopover } from '@kit/notifications/components';
function AppHeader({ accountId }: { accountId: string }) {
return (
<header>
<NotificationsPopover
accountIds={[accountId]}
realtime={false}
/>
</header>
);
}
```
### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `accountIds` | `string[]` | Yes | Account IDs to fetch notifications for |
| `realtime` | `boolean` | Yes | Enable Supabase Realtime subscriptions |
| `onClick` | `(notification) => void` | No | Custom click handler |
### How accountIds works
Pass all account IDs the user has access to. For a user with a personal account and team memberships:
```tsx
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
function NotificationsWithAllAccounts() {
const { account, accounts } = useUserWorkspace();
// Include personal account + all team accounts
const accountIds = [
account.id,
...accounts.filter(a => !a.is_personal_account).map(a => a.id)
];
return (
<NotificationsPopover
accountIds={accountIds}
realtime={false}
/>
);
}
```
The built-in layouts handle this automatically. You only need to configure `accountIds` for custom implementations.
### Custom click handling
By default, clicking a notification with a `link` navigates using an anchor tag. Override this with `onClick`:
```tsx
import { useRouter } from 'next/navigation';
function CustomNotifications({ accountId }: { accountId: string }) {
const router = useRouter();
return (
<NotificationsPopover
accountIds={[accountId]}
realtime={false}
onClick={(notification) => {
if (notification.link) {
// Custom navigation logic
router.push(notification.link);
}
}}
/>
);
}
```
### What the component renders
- **Bell icon** with red badge showing unread count
- **Popover dropdown** with notification list on click
- **Each notification** shows:
- Type icon (info/warning/error with color coding)
- Message body (truncated at 100 characters)
- Relative timestamp ("2 minutes ago", "Yesterday")
- Dismiss button (X icon)
- **Empty state** when no notifications
The component uses Shadcn UI's Popover, Button, and Separator components with Lucide icons.
## React hooks
Build custom notification UIs using these hooks from `@kit/notifications/hooks`.
### useFetchNotifications
Fetches initial notifications and optionally subscribes to real-time updates.
```tsx
'use client';
import { useState, useCallback } from 'react';
import { useFetchNotifications } from '@kit/notifications/hooks';
type Notification = {
id: number;
body: string;
dismissed: boolean;
type: 'info' | 'warning' | 'error';
created_at: string;
link: string | null;
};
function CustomNotificationList({ accountIds }: { accountIds: string[] }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const onNotifications = useCallback((newNotifications: Notification[]) => {
setNotifications(prev => {
// Deduplicate by ID
const existingIds = new Set(prev.map(n => n.id));
const unique = newNotifications.filter(n => !existingIds.has(n.id));
return [...unique, ...prev];
});
}, []);
useFetchNotifications({
accountIds,
realtime: false,
onNotifications,
});
return (
<ul>
{notifications.map(notification => (
<li key={notification.id}>{notification.body}</li>
))}
</ul>
);
}
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `accountIds` | `string[]` | Account IDs to fetch for |
| `realtime` | `boolean` | Subscribe to real-time updates |
| `onNotifications` | `(notifications: Notification[]) => void` | Callback when notifications arrive |
**Behavior:**
- Fetches up to 10 most recent non-dismissed, non-expired notifications
- Uses React Query with `refetchOnMount: false` and `refetchOnWindowFocus: false`
- Calls `onNotifications` with initial data and any real-time updates
### useDismissNotification
Returns a function to dismiss (mark as read) a notification.
```tsx
'use client';
import { useDismissNotification } from '@kit/notifications/hooks';
function NotificationItem({ notification }) {
const dismiss = useDismissNotification();
const handleDismiss = async () => {
await dismiss(notification.id);
// Update local state after dismissing
};
return (
<div>
<span>{notification.body}</span>
<button onClick={handleDismiss}>Dismiss</button>
</div>
);
}
```
The function updates the `dismissed` field to `true` in the database. RLS ensures users can only dismiss their own notifications.
## Notification type
All hooks work with this type:
```typescript
type Notification = {
id: number;
body: string;
dismissed: boolean;
type: 'info' | 'warning' | 'error';
created_at: string;
link: string | null;
};
```
## Building a custom notification center
Full example combining the hooks:
```tsx
'use client';
import { useState, useCallback } from 'react';
import {
useFetchNotifications,
useDismissNotification,
} from '@kit/notifications/hooks';
type Notification = {
id: number;
body: string;
dismissed: boolean;
type: 'info' | 'warning' | 'error';
created_at: string;
link: string | null;
};
export function NotificationCenter({ accountIds }: { accountIds: string[] }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const dismiss = useDismissNotification();
const onNotifications = useCallback((incoming: Notification[]) => {
setNotifications(prev => {
const ids = new Set(prev.map(n => n.id));
const newOnes = incoming.filter(n => !ids.has(n.id));
return [...newOnes, ...prev];
});
}, []);
useFetchNotifications({
accountIds,
realtime: true, // Enable real-time
onNotifications,
});
const handleDismiss = async (id: number) => {
await dismiss(id);
setNotifications(prev => prev.filter(n => n.id !== id));
};
if (notifications.length === 0) {
return <p>No notifications</p>;
}
return (
<div className="space-y-2">
{notifications.map(notification => (
<div
key={notification.id}
className="flex items-center justify-between p-3 border rounded"
>
<div>
<span className={`badge badge-${notification.type}`}>
{notification.type}
</span>
{notification.link ? (
<a href={notification.link}>{notification.body}</a>
) : (
<span>{notification.body}</span>
)}
</div>
<button onClick={() => handleDismiss(notification.id)}>
Dismiss
</button>
</div>
))}
</div>
);
}
```
This gives you full control over styling and behavior while leveraging the built-in data fetching and real-time infrastructure.
## Related documentation
- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and architecture
- [Configuration](/docs/next-supabase-turbo/notifications/notifications-configuration): Enable/disable notifications and real-time
- [Sending notifications](/docs/next-supabase-turbo/notifications/sending-notifications): Create notifications from server code
- [Database schema](/docs/next-supabase-turbo/notifications/notifications-schema): Table structure and RLS policies

View File

@@ -0,0 +1,141 @@
---
status: "published"
title: "Configuring Notifications"
label: "Configuration"
description: "Enable or disable notifications, configure real-time updates, and understand the cost implications of Supabase Realtime subscriptions."
order: 0
---
Notifications are controlled by two environment variables in your `.env` file. Both are optional with sensible defaults.
## Environment variables
```bash
# Enable the notifications feature (default: true)
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
# Enable real-time updates via Supabase Realtime (default: false)
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=false
```
These values are read in `apps/web/config/feature-flags.config.ts` using a helper that parses the string value:
```typescript
const featuresFlagConfig = FeatureFlagsSchema.parse({
enableNotifications: getBoolean(
process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS,
true, // default
),
realtimeNotifications: getBoolean(
process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS,
false, // default
),
});
function getBoolean(value: unknown, defaultValue: boolean) {
if (typeof value === 'string') {
return value === 'true';
}
return defaultValue;
}
```
## NEXT_PUBLIC_ENABLE_NOTIFICATIONS
Controls whether the notification bell icon appears in the header.
| Value | Behavior |
|-------|----------|
| `true` (default) | Bell icon visible, notifications functional |
| `false` | Bell icon hidden, no notification queries |
When disabled, no database queries are made and the `NotificationsPopover` component doesn't render.
**When to disable**: If your app doesn't need in-app notifications (e.g., you only use email notifications), set this to `false` to simplify your UI.
## NEXT_PUBLIC_REALTIME_NOTIFICATIONS
Controls whether the client subscribes to Supabase Realtime for instant notification delivery.
| Value | Behavior |
|-------|----------|
| `false` (default) | Notifications load on page navigation only |
| `true` | New notifications appear instantly without refresh |
### How real-time works
When enabled, each connected client opens a WebSocket connection to Supabase and subscribes to `INSERT` events on the `notifications` table, filtered by the user's account IDs:
```typescript
client.channel('notifications-channel')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `account_id=in.(${accountIds.join(', ')})`,
}, (payload) => {
// New notification received
})
.subscribe();
```
### Cost considerations
Supabase Realtime connections count toward your plan limits:
- **Free tier**: 200 concurrent connections
- **Pro tier**: 500 concurrent connections (more available as add-on)
Each browser tab from each user maintains one connection. For an app with 100 concurrent users averaging 2 tabs each, that's 200 connections.
**Recommendation**: Start with real-time disabled. Enable it only if instant notification delivery is a core requirement. Most users check notifications on page load anyway.
### Without real-time
Notifications are fetched via React Query on component mount:
- Initial page load fetches the 10 most recent non-dismissed, non-expired notifications
- No refetch on window focus (to reduce server load)
- Users see new notifications when they navigate to a new page
This approach works well for most SaaS applications and has zero cost impact.
## Using feature flags in your code
Check these flags before rendering notification-related UI:
```typescript
import featuresFlagConfig from '~/config/feature-flags.config';
function AppHeader() {
return (
<header>
{/* Other header content */}
{featuresFlagConfig.enableNotifications && (
<NotificationsPopover
accountIds={[accountId]}
realtime={featuresFlagConfig.realtimeNotifications}
/>
)}
</header>
);
}
```
The built-in layouts already handle this check. You only need to worry about feature flags if you're building custom notification UI.
## Testing notifications locally
1. Ensure your local Supabase is running: `pnpm supabase:web:start`
2. Notifications are enabled by default in development
3. Use the [sending notifications API](/docs/next-supabase-turbo/notifications/sending-notifications) to create test notifications
4. If testing real-time, set `NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true` in `.env.local`
To verify real-time is working, open two browser tabs, send a notification, and confirm it appears in both tabs without refreshing.
## Related documentation
- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and architecture
- [Sending notifications](/docs/next-supabase-turbo/notifications/sending-notifications): Server-side API for creating notifications
- [Database schema](/docs/next-supabase-turbo/notifications/notifications-schema): Table structure and RLS policies

View File

@@ -0,0 +1,246 @@
---
status: "published"
title: "Notifications Database Schema"
label: "Database Schema"
description: "Understand the notifications table structure, Row Level Security policies, and how to extend the schema."
order: 3
---
The notifications system uses a single table with Row Level Security. This page documents the schema, security policies, and extension patterns.
## Table structure
```sql
create table if not exists public.notifications (
id bigint generated always as identity primary key,
account_id uuid not null references public.accounts(id) on delete cascade,
type public.notification_type not null default 'info',
body varchar(5000) not null,
link varchar(255),
channel public.notification_channel not null default 'in_app',
dismissed boolean not null default false,
expires_at timestamptz default (now() + interval '1 month'),
created_at timestamptz not null default now()
);
```
### Columns
| Column | Type | Default | Description |
|--------|------|---------|-------------|
| `id` | `bigint` | Auto-generated | Primary key |
| `account_id` | `uuid` | Required | Personal or team account ID |
| `type` | `notification_type` | `'info'` | Severity: info, warning, error |
| `body` | `varchar(5000)` | Required | Message text or translation key |
| `link` | `varchar(255)` | `null` | Optional URL for clickable notifications |
| `channel` | `notification_channel` | `'in_app'` | Delivery method |
| `dismissed` | `boolean` | `false` | Has user dismissed this notification |
| `expires_at` | `timestamptz` | Now + 1 month | Auto-expiration timestamp |
| `created_at` | `timestamptz` | Now | Creation timestamp |
### Enums
```sql
create type public.notification_type as enum('info', 'warning', 'error');
create type public.notification_channel as enum('in_app', 'email');
```
## Row Level Security
Three key security constraints:
### 1. Read policy
Users can read notifications for their personal account or any team account they belong to:
```sql
create policy notifications_read_self on public.notifications
for select to authenticated using (
account_id = (select auth.uid())
or has_role_on_account(account_id)
);
```
### 2. Update policy
Users can update notifications they have access to:
```sql
create policy notifications_update_self on public.notifications
for update to authenticated using (
account_id = (select auth.uid())
or has_role_on_account(account_id)
);
```
### 3. Update trigger (dismissed only)
A trigger prevents updating any field except `dismissed`:
```sql
create or replace function kit.update_notification_dismissed_status()
returns trigger set search_path to '' as $$
begin
old.dismissed := new.dismissed;
if (new is distinct from old) then
raise exception 'UPDATE of columns other than "dismissed" is forbidden';
end if;
return old;
end;
$$ language plpgsql;
create trigger update_notification_dismissed_status
before update on public.notifications
for each row execute procedure kit.update_notification_dismissed_status();
```
This ensures users cannot modify notification content, type, or other fields after creation.
### Insert permissions
Only `service_role` can insert notifications:
```sql
revoke all on public.notifications from authenticated, service_role;
grant select, update on table public.notifications to authenticated, service_role;
grant insert on table public.notifications to service_role;
```
This is why you must use `getSupabaseServerAdminClient()` when creating notifications.
## Indexes
One composite index optimizes the common query pattern:
```sql
create index idx_notifications_account_dismissed
on notifications (account_id, dismissed, expires_at);
```
This index supports queries that filter by account, dismissed status, and expiration.
## Realtime
The table is added to Supabase Realtime publication:
```sql
alter publication supabase_realtime add table public.notifications;
```
This enables the real-time subscription feature when `NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true`.
## Common modifications
### Adding a read_at timestamp
Track when notifications were first viewed (not just dismissed):
```sql
alter table public.notifications
add column read_at timestamptz default null;
```
Update the trigger to allow `read_at` updates:
```sql
create or replace function kit.update_notification_status()
returns trigger set search_path to '' as $$
begin
old.dismissed := new.dismissed;
old.read_at := new.read_at;
if (new is distinct from old) then
raise exception 'UPDATE of columns other than "dismissed" and "read_at" is forbidden';
end if;
return old;
end;
$$ language plpgsql;
```
### Adding notification categories
Extend with a category for filtering:
```sql
create type public.notification_category as enum(
'system',
'billing',
'team',
'content'
);
alter table public.notifications
add column category public.notification_category default 'system';
```
### Batch deletion of old notifications
Create a function to clean up expired notifications:
```sql
create or replace function kit.cleanup_expired_notifications()
returns integer
language plpgsql
security definer
set search_path to ''
as $$
declare
deleted_count integer;
begin
with deleted as (
delete from public.notifications
where expires_at < now()
returning id
)
select count(*) into deleted_count from deleted;
return deleted_count;
end;
$$;
```
Call this from a cron job, Supabase Edge Function, or pg_cron extension.
## Testing RLS policies
The schema includes pgTAP tests in `apps/web/supabase/tests/database/notifications.test.sql`:
```sql
-- Users cannot insert notifications
select throws_ok(
$$ insert into notifications (account_id, body) values ('...', 'test') $$,
'new row violates row-level security policy for table "notifications"'
);
-- Service role can insert
set role service_role;
select lives_ok(
$$ insert into notifications (account_id, body) values ('...', 'test') $$
);
```
Run tests with:
```bash
pnpm --filter web supabase test db
```
Expected output on success:
```
# Running: notifications.test.sql
ok 1 - Users cannot insert notifications
ok 2 - Service role can insert notifications
ok 3 - Users can read their own notifications
...
```
## Related documentation
- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and when to use notifications
- [Configuration](/docs/next-supabase-turbo/notifications/notifications-configuration): Environment variables and feature flags
- [Sending notifications](/docs/next-supabase-turbo/notifications/sending-notifications): Server-side API for creating notifications
- [UI Components](/docs/next-supabase-turbo/notifications/notifications-components): Display notifications in your app

View File

@@ -0,0 +1,263 @@
---
status: "published"
title: "Sending Notifications"
label: "Sending Notifications"
description: "Create in-app notifications from Server Actions, API routes, and background jobs using the notifications API."
order: 1
---
Notifications are created server-side using `createNotificationsApi`. This requires the Supabase admin client because only the `service_role` can insert into the notifications table.
## Basic usage
```typescript
import { createNotificationsApi } from '@kit/notifications/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function sendNotification(accountId: string) {
const client = getSupabaseServerAdminClient();
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: 'Your report is ready',
});
}
```
The `account_id` determines who sees the notification. Pass a user's personal account ID to notify just them, or a team account ID to notify all team members.
## Notification fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `account_id` | `uuid` | Yes | - | Personal or team account ID |
| `body` | `string` | Yes | - | Message text (max 5000 chars) |
| `type` | `'info' \| 'warning' \| 'error'` | No | `'info'` | Severity level |
| `link` | `string` | No | `null` | URL to navigate on click |
| `channel` | `'in_app' \| 'email'` | No | `'in_app'` | Delivery channel |
| `expires_at` | `Date` | No | 1 month | Auto-expiration timestamp |
## Notification types
Use types to indicate severity. Each type renders with a distinct icon color:
```typescript
// Info (blue) - General updates
await api.createNotification({
account_id: accountId,
body: 'New feature: Dark mode is now available',
type: 'info',
});
// Warning (yellow) - Attention needed
await api.createNotification({
account_id: accountId,
body: 'Your trial expires in 3 days',
type: 'warning',
link: '/settings/billing',
});
// Error (red) - Action required
await api.createNotification({
account_id: accountId,
body: 'Payment failed. Update your card to continue.',
type: 'error',
link: '/settings/billing',
});
```
## Adding links
Include a `link` to make notifications actionable. Users click the notification to navigate:
```typescript
await api.createNotification({
account_id: accountId,
body: 'John commented on your document',
link: '/documents/abc123#comment-456',
});
```
Links should be relative paths within your app. The UI renders the body as a clickable anchor.
## Setting expiration
By default, notifications expire after 1 month. Set a custom expiration for time-sensitive messages:
```typescript
// Expire in 24 hours
const tomorrow = new Date();
tomorrow.setHours(tomorrow.getHours() + 24);
await api.createNotification({
account_id: accountId,
body: 'Flash sale ends tonight!',
link: '/pricing',
expires_at: tomorrow,
});
```
Expired notifications are filtered out on fetch. They remain in the database but won't appear in the UI.
## Team notifications
Send to a team account ID to notify all members:
```typescript
import { createNotificationsApi } from '@kit/notifications/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function notifyTeam(teamAccountId: string, newMemberName: string) {
const client = getSupabaseServerAdminClient();
const api = createNotificationsApi(client);
await api.createNotification({
account_id: teamAccountId,
body: `${newMemberName} joined the team`,
link: '/settings/members',
type: 'info',
});
}
```
Every user with a role on that team account will see this notification via the RLS policy.
## Common patterns
### Welcome notification on signup
```typescript
// In your post-signup hook or Server Action
export async function onUserCreated(userId: string) {
const client = getSupabaseServerAdminClient();
const api = createNotificationsApi(client);
await api.createNotification({
account_id: userId,
body: 'Welcome! Start by creating your first project.',
link: '/projects/new',
type: 'info',
});
}
```
### Subscription renewal reminder
```typescript
export async function sendRenewalReminder(
accountId: string,
daysRemaining: number
) {
const client = getSupabaseServerAdminClient();
const api = createNotificationsApi(client);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + daysRemaining);
await api.createNotification({
account_id: accountId,
body: `Your subscription renews in ${daysRemaining} days`,
link: '/settings/billing',
type: daysRemaining <= 3 ? 'warning' : 'info',
expires_at: expiresAt,
});
}
```
### Background job completion
```typescript
export async function onExportComplete(
accountId: string,
exportId: string
) {
const client = getSupabaseServerAdminClient();
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: 'Your data export is ready to download',
link: `/exports/${exportId}`,
type: 'info',
});
}
```
### Payment failure
```typescript
export async function onPaymentFailed(accountId: string) {
const client = getSupabaseServerAdminClient();
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: 'Payment failed. Please update your payment method.',
link: '/settings/billing',
type: 'error',
});
}
```
## Using translation keys
For internationalized apps, store translation keys instead of plain text:
```typescript
await api.createNotification({
account_id: accountId,
body: 'notifications.exportReady', // Translation key
link: '/exports',
});
```
The UI component runs the body through `t()` from next-intl, falling back to the raw string if no translation exists.
Add the translation to your locale files:
```json
{
"notifications": {
"exportReady": "Your data export is ready to download"
}
}
```
## Notification channels
The `channel` field supports `'in_app'` (default) and `'email'`. Currently, only `in_app` is implemented. The `email` channel is reserved for future use where a database trigger could send email notifications.
```typescript
// In-app only (default)
await api.createNotification({
account_id: accountId,
body: 'New message received',
channel: 'in_app',
});
```
## Error handling
The API throws on failure. Wrap calls in try-catch for production code:
```typescript
try {
await api.createNotification({
account_id: accountId,
body: 'Notification message',
});
} catch (error) {
console.error('Failed to send notification:', error);
// Don't throw - notification failure shouldn't break the main flow
}
```
Notifications are typically non-critical. Consider logging failures but not throwing, so the primary operation (signup, export, etc.) still succeeds.
## Related documentation
- [Notifications overview](/docs/next-supabase-turbo/notifications): Feature overview and when to use notifications
- [Configuration](/docs/next-supabase-turbo/notifications/notifications-configuration): Environment variables and feature flags
- [UI Components](/docs/next-supabase-turbo/notifications/notifications-components): How notifications appear in the UI
- [Database schema](/docs/next-supabase-turbo/notifications/notifications-schema): Table structure and security policies