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
294
docs/notifications/notifications-components.mdoc
Normal file
294
docs/notifications/notifications-components.mdoc
Normal 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
|
||||
141
docs/notifications/notifications-configuration.mdoc
Normal file
141
docs/notifications/notifications-configuration.mdoc
Normal 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
|
||||
246
docs/notifications/notifications-schema.mdoc
Normal file
246
docs/notifications/notifications-schema.mdoc
Normal 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
|
||||
263
docs/notifications/sending-notifications.mdoc
Normal file
263
docs/notifications/sending-notifications.mdoc
Normal 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
|
||||
Reference in New Issue
Block a user