In-App Notifications
Build a full notification system for your SaaS: a notifications table with RLS, Inngest fan-out on events, a realtime unread badge over Supabase channels, per-user notification preferences, and a daily email digest through Resend.
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
Problem: Notifications look like a small feature and turn into five. You need a table nobody can forge rows in, a way to fan one event out to many recipients, a badge that updates without polling, per-user preferences that actually gate sends, and a digest email that never double-sends.
Fix: Build them as one pipeline. Postgres holds the notifications with row-level security, Inngest fans out the writes, Supabase Realtime pushes the unread count, a preferences table decides who gets what, and a scheduled Inngest function batches the leftovers into a daily digest through Resend. This guide builds the whole thing in Next.js 16, with Claude Code doing the typing.
The Shape of the System
One event comes in ("someone replied to your thread"). It has to become up to a few hundred rows, some emails, and a badge that ticks up on every open tab. Splitting that into four moving parts keeps each one boring.
The table is the source of truth. One row per recipient per notification. Read and update are guarded by RLS, insert is server-only.
The dispatcher is an Inngest function. Your API sends one event. The function loads preferences, filters recipients, bulk-inserts rows, and sends instant emails for the people who asked for them.
The badge subscribes to Postgres Changes on the notifications table, filtered to the current user. No polling, no websocket server of your own.
The digest is a scheduled Inngest function that runs hourly, picks up users whose chosen delivery hour has arrived, and batches everything they missed into one email.
Put the conventions in CLAUDE.md before you start so Claude stops guessing:
## Notifications
- Table public.notifications: select + update policies on auth.uid(), NO insert policy.
- All inserts go through the service-role client in lib/supabase/service.ts (server only).
- Preferences live in public.notification_preferences, one row per user.
- Every notification write is triggered by an Inngest event, never inline in a request.
- Client subscriptions must call supabase.removeChannel in the effect cleanup.The Notifications Table
The table stores who the notification is for, who caused it, and enough text to render a row without a join. read_at doubles as the read flag and the timestamp. There is no insert policy on purpose, which is what makes the table unforgeable from the browser.
Ask Claude to generate the migration and apply it.
-- supabase/migrations/0001_notifications.sql
create type notification_type as enum (
'comment_reply',
'mention',
'invite_accepted',
'billing_alert'
);
create table public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
actor_id uuid references auth.users (id) on delete set null,
type notification_type not null,
title text not null,
body text not null,
href text,
read_at timestamptz,
created_at timestamptz not null default now()
);
alter table public.notifications enable row level security;
create policy "Users read their own notifications"
on public.notifications for select
using (auth.uid() = user_id);
create policy "Users update their own notifications"
on public.notifications for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create index notifications_user_unread_idx
on public.notifications (user_id, created_at desc)
where read_at is null;Two more lines make the table realtime-ready. Postgres Changes only fires for tables in the supabase_realtime publication, and the badge needs the full previous row on updates to tell "just marked read" apart from any other edit. Without replica identity full, the old record contains only the primary key.
-- supabase/migrations/0002_notifications_realtime.sql
alter publication supabase_realtime add table public.notifications;
alter table public.notifications replica identity full;Preferences That Actually Gate Sends
Preferences are worthless if the dispatcher does not read them before it writes. One row per user, defaults that make sense for someone who never opens the settings page, and a per-type mute list so a user can keep mentions and kill billing alerts.
The digest_hour_utc column is what lets the digest job run hourly and still deliver one email per user per day, at the hour they picked.
-- supabase/migrations/0003_notification_preferences.sql
create table public.notification_preferences (
user_id uuid primary key references auth.users (id) on delete cascade,
in_app boolean not null default true,
email_instant boolean not null default false,
email_digest boolean not null default true,
muted_types notification_type[] not null default '{}',
digest_hour_utc smallint not null default 8
check (digest_hour_utc between 0 and 23),
last_digest_at timestamptz,
updated_at timestamptz not null default now()
);
alter table public.notification_preferences enable row level security;
create policy "Users read their own preferences"
on public.notification_preferences for select
using (auth.uid() = user_id);
create policy "Users insert their own preferences"
on public.notification_preferences for insert
with check (auth.uid() = user_id);
create policy "Users update their own preferences"
on public.notification_preferences for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);Users edit their own row through a normal oRPC procedure. Zod validates the shape, RLS enforces ownership, and the upsert covers the case where the row does not exist yet because the user never opened the settings page.
// lib/orpc/procedures/notification-preferences.ts
import { z } from "zod";
import { os } from "@orpc/server";
import { createClient } from "@/lib/supabase/server";
export const updatePreferences = os
.input(
z.object({
inApp: z.boolean(),
emailInstant: z.boolean(),
emailDigest: z.boolean(),
mutedTypes: z.array(
z.enum(["comment_reply", "mention", "invite_accepted", "billing_alert"]),
),
digestHourUtc: z.number().int().min(0).max(23),
}),
)
.handler(async ({ input }) => {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Not authenticated");
const { error } = await supabase.from("notification_preferences").upsert({
user_id: user.id,
in_app: input.inApp,
email_instant: input.emailInstant,
email_digest: input.emailDigest,
muted_types: input.mutedTypes,
digest_hour_utc: input.digestHourUtc,
updated_at: new Date().toISOString(),
});
if (error) throw new Error(error.message);
return { ok: true };
});The Service-Role Client
Inserts come from Inngest, which runs outside any user session. That needs a client built on the service role key, which bypasses RLS entirely. Keep it in its own file so it is obvious when something imports it, and never import it from a file with "use client" at the top.
// lib/supabase/service.ts
import { createClient } from "@supabase/supabase-js";
export function createServiceClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
);
}The typed Inngest client defines the one event the whole system runs on. Typing the payload here means a typo in an event name or a missing field is a compile error, not a job that silently never fires.
// lib/inngest/client.ts
import { EventSchemas, Inngest } from "inngest";
type Events = {
"notification/dispatch.requested": {
data: {
recipientIds: string[];
actorId: string | null;
notification: {
type: "comment_reply" | "mention" | "invite_accepted" | "billing_alert";
title: string;
body: string;
href?: string;
};
};
};
};
export const inngest = new Inngest({
id: "build-this-now-app",
schemas: new EventSchemas().fromRecord<Events>(),
});Fan-Out With Inngest
This is the piece that turns one event into many rows. It loads preferences for every recipient, drops the actor (nobody wants a notification about their own comment), drops anyone who muted that type, bulk-inserts the in-app rows in a single query, and sends instant emails only to people who opted in.
Each phase is its own step.run, so a Resend outage retries the email step alone and never re-inserts the rows that already committed.
// lib/inngest/functions/dispatch-notification.ts
import { Resend } from "resend";
import { inngest } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";
const resend = new Resend(process.env.RESEND_API_KEY);
type Preference = {
user_id: string;
in_app: boolean;
email_instant: boolean;
muted_types: string[];
};
export const dispatchNotification = inngest.createFunction(
{ id: "dispatch-notification", retries: 4, concurrency: { limit: 10 } },
{ event: "notification/dispatch.requested" },
async ({ event, step }) => {
const { recipientIds, actorId, notification } = event.data;
const recipients = await step.run("load-preferences", async () => {
const targets = recipientIds.filter((id) => id !== actorId);
if (targets.length === 0) return [] as Preference[];
const supabase = createServiceClient();
const { data, error } = await supabase
.from("notification_preferences")
.select("user_id, in_app, email_instant, muted_types")
.in("user_id", targets);
if (error) throw new Error(error.message);
const saved = new Map<string, Preference>(
(data ?? []).map((row) => [row.user_id, row as Preference]),
);
const defaults = { in_app: true, email_instant: false, muted_types: [] };
return targets
.map<Preference>((id) => saved.get(id) ?? { user_id: id, ...defaults })
.filter((pref) => !pref.muted_types.includes(notification.type));
});
const inAppTargets = recipients.filter((pref) => pref.in_app);
if (inAppTargets.length > 0) {
await step.run("insert-notifications", async () => {
const supabase = createServiceClient();
const { error } = await supabase.from("notifications").insert(
inAppTargets.map((pref) => ({
user_id: pref.user_id,
actor_id: actorId,
type: notification.type,
title: notification.title,
body: notification.body,
href: notification.href ?? null,
})),
);
if (error) throw new Error(error.message);
return inAppTargets.length;
});
}
const emailTargets = recipients.filter((pref) => pref.email_instant);
if (emailTargets.length > 0) {
await step.run("send-instant-emails", async () => {
const supabase = createServiceClient();
const ids = emailTargets.map((pref) => pref.user_id);
const { data, error } = await supabase
.from("profiles")
.select("id, email")
.in("id", ids);
if (error) throw new Error(error.message);
const profiles = (data ?? []) as { id: string; email: string }[];
const { error: sendError } = await resend.batch.send(
profiles.map((profile) => ({
from: "Notifications <notify@yourdomain.com>",
to: profile.email,
subject: notification.title,
text: `${notification.body}\n\n${notification.href ?? ""}`,
})),
);
if (sendError) throw new Error(sendError.message);
return profiles.length;
});
}
return { inApp: inAppTargets.length, emailed: emailTargets.length };
},
);Your API endpoints never touch the notifications table. They do their real work, send one event, and return. A comment endpoint looks like this.
// lib/orpc/procedures/comments.ts
import { z } from "zod";
import { os } from "@orpc/server";
import { inngest } from "@/lib/inngest/client";
import { createComment, getThreadParticipants } from "@/lib/db";
export const postComment = os
.input(
z.object({
threadId: z.string().uuid(),
authorId: z.string().uuid(),
body: z.string().min(1).max(5000),
}),
)
.handler(async ({ input }) => {
const comment = await createComment(input);
const participants = await getThreadParticipants(input.threadId);
await inngest.send({
name: "notification/dispatch.requested",
data: {
recipientIds: participants,
actorId: input.authorId,
notification: {
type: "comment_reply",
title: "New reply in your thread",
body: input.body.slice(0, 140),
href: `/threads/${input.threadId}`,
},
},
});
return comment;
});The Realtime Unread Badge
The badge needs two numbers: the count at page load and every change after it. The initial count comes from a Server Component, and the live updates come from a Postgres Changes subscription filtered to the current user.
The hook increments on insert and decrements only when an update flips read_at from null to a value. That comparison is why replica identity full matters.
// lib/notifications/use-unread-count.ts
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
type ReadState = { read_at: string | null };
export function useUnreadCount(userId: string, initialCount: number) {
const [count, setCount] = useState(initialCount);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel(`notifications:${userId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
() => setCount((current) => current + 1),
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload) => {
const before = payload.old as ReadState;
const after = payload.new as ReadState;
if (!before.read_at && after.read_at) {
setCount((current) => Math.max(0, current - 1));
}
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
return count;
}The Server Component fetches the starting count with a head-only query, which returns the number without the rows. Note there is no user_id filter in the query. RLS applies it for you, which is the whole point of the policy.
// components/notification-bell.tsx
import { createClient } from "@/lib/supabase/server";
import { UnreadBadge } from "./unread-badge";
export async function NotificationBell() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const { count } = await supabase
.from("notifications")
.select("id", { count: "exact", head: true })
.is("read_at", null);
return <UnreadBadge userId={user.id} initialCount={count ?? 0} />;
}The client half is small on purpose. It owns the subscription and nothing else, which keeps the rest of your header a Server Component.
// components/unread-badge.tsx
"use client";
import Link from "next/link";
import { Bell } from "lucide-react";
import { useUnreadCount } from "@/lib/notifications/use-unread-count";
type Props = { userId: string; initialCount: number };
export function UnreadBadge({ userId, initialCount }: Props) {
const count = useUnreadCount(userId, initialCount);
return (
<Link
href="/notifications"
className="relative inline-flex items-center p-2"
aria-label={`Notifications, ${count} unread`}
>
<Bell className="size-5" aria-hidden />
{count > 0 && (
<span className="absolute right-0 top-0 min-w-5 rounded-full bg-red-500 px-1.5 text-center text-xs font-medium text-white">
{count > 99 ? "99+" : count}
</span>
)}
</Link>
);
}Marking everything read is a Server Action. The user_id filter is belt and braces (RLS is the real guard), and the update fires the same Postgres Changes events the badge is already listening to, so the count drops on every open tab.
// app/notifications/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
export async function markAllRead() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { error } = await supabase
.from("notifications")
.update({ read_at: new Date().toISOString() })
.eq("user_id", user.id)
.is("read_at", null);
if (error) throw new Error(error.message);
revalidatePath("/notifications");
}The Daily Digest Through Resend
The digest query is the interesting part, and it belongs in Postgres rather than in TypeScript. One statement finds every eligible user, collects their unread notifications since their last digest, and returns them pre-grouped. The security definer marker lets the function read across users when called with the service role.
-- supabase/migrations/0004_pending_digests.sql
create or replace function public.pending_digests(for_hour smallint)
returns table (user_id uuid, email text, items jsonb)
language sql
security definer
set search_path = public
as $$
select
p.user_id,
u.email,
jsonb_agg(
jsonb_build_object('title', n.title, 'body', n.body, 'href', n.href)
order by n.created_at desc
) as items
from public.notification_preferences p
join public.profiles u on u.id = p.user_id
join public.notifications n on n.user_id = p.user_id
where p.email_digest
and p.digest_hour_utc = for_hour
and (p.last_digest_at is null
or p.last_digest_at < now() - interval '20 hours')
and n.read_at is null
and n.created_at > coalesce(p.last_digest_at, now() - interval '7 days')
group by p.user_id, u.email;
$$;
revoke execute on function public.pending_digests(smallint) from public, anon, authenticated;That revoke has to name public as well as the two Supabase roles. Postgres grants execute on a new function to public by default and every role inherits it, so revoking from anon and authenticated alone leaves a security definer function that reads across users callable straight from the browser.
The job runs every hour and only picks up users whose chosen hour just arrived, so each person still gets at most one digest a day. Resend accepts up to 100 emails per batch call, so the sends are chunked, and each chunk is its own step. A failure on chunk three retries chunk three, not the two that already went out.
// lib/inngest/functions/daily-digest.ts
import { Resend } from "resend";
import { inngest } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";
const resend = new Resend(process.env.RESEND_API_KEY);
type DigestItem = { title: string; body: string; href: string | null };
type DigestRow = { user_id: string; email: string; items: DigestItem[] };
function escapeHtml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function renderDigest(items: DigestItem[]) {
const rows = items
.map(
(item) =>
`<li><strong>${escapeHtml(item.title)}</strong><br />${escapeHtml(item.body)}</li>`,
)
.join("");
return `<p>Here is what you missed:</p><ul>${rows}</ul>`;
}
export const dailyDigest = inngest.createFunction(
{ id: "daily-notification-digest" },
{ cron: "TZ=UTC 0 * * * *" },
async ({ step }) => {
const collected = await step.run("collect-digests", async () => {
const startedAt = new Date().toISOString();
const supabase = createServiceClient();
const { data, error } = await supabase.rpc("pending_digests", {
for_hour: new Date().getUTCHours(),
});
if (error) throw new Error(error.message);
return { startedAt, rows: (data ?? []) as DigestRow[] };
});
const { startedAt, rows } = collected;
if (rows.length === 0) return { sent: 0 };
const chunks: DigestRow[][] = [];
for (let i = 0; i < rows.length; i += 100) {
chunks.push(rows.slice(i, i + 100));
}
for (const [index, chunk] of chunks.entries()) {
await step.run(`send-batch-${index}`, async () => {
const { error } = await resend.batch.send(
chunk.map((row) => ({
from: "Digest <notify@yourdomain.com>",
to: row.email,
subject: `${row.items.length} new notifications`,
html: renderDigest(row.items),
})),
);
if (error) throw new Error(error.message);
return chunk.length;
});
}
await step.run("stamp-last-digest", async () => {
const supabase = createServiceClient();
const { error } = await supabase
.from("notification_preferences")
.update({ last_digest_at: startedAt })
.in(
"user_id",
rows.map((row) => row.user_id),
);
if (error) throw new Error(error.message);
return rows.length;
});
return { sent: rows.length };
},
);The escaping is not optional. A notification body is user-typed text (the comment excerpt from the dispatch endpoint), and it lands in an HTML email, so it has to be escaped on the way in.
Stamping startedAt instead of now() is deliberate. Anything created while the job was running has a later timestamp, so it lands in tomorrow's digest instead of falling through the crack between the query and the stamp.
Both functions get exposed on the single Inngest route handler.
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { dispatchNotification } from "@/lib/inngest/functions/dispatch-notification";
import { dailyDigest } from "@/lib/inngest/functions/daily-digest";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [dispatchNotification, dailyDigest],
});Run it locally with the Inngest dev server pointed at that route. Trigger a comment, watch the fan-out step through, and open two browser tabs to confirm the badge moves in both.
npx inngest-cli dev -u http://localhost:3000/api/inngestThe Gotchas That Bite
The publication line. If the badge never moves, check that notifications is in the supabase_realtime publication. Missing it fails silently, with no error on either side.
Replica identity. Without replica identity full, the old record in an UPDATE payload contains only the primary key, so the read-state comparison never fires and the badge only counts up.
No insert policy is the feature. It is tempting to add one "just for testing." Do not. The moment a client can insert, anyone can write a notification into another user's feed.
Service role client stays server-side. Importing lib/supabase/service.ts from a Client Component ships a key that bypasses every policy you wrote. Add it to the CLAUDE.md rules so Claude never reaches for it in a "use client" file.
Remove the channel. Every subscription needs the removeChannel cleanup. Leaked channels are the reason a notification bell works in development and dies under real session lengths.
Ship It: The Quality Gates
Notification code touches SQL, a background worker, a realtime subscription, and an email provider, so the checks before you commit are worth more here than usual.
Type check with zero errors:
npx tsc --noEmitClean production build:
npm run buildThen hand Claude the whole surface at once:
claude "run tsc --noEmit and npm run build, confirm every inngest.send name exists in the
client schema, verify no file with 'use client' imports lib/supabase/service.ts, and check
that the notifications table has no insert policy"A single Claude Code session builds this feature well. Turning that into a repeatable pipeline is what the $29 Code Kit adds on top of Claude Code ($29 one time, no subscription): a harness that plans the feature, builds it, evaluates the output, and tests it, so every feature exits with zero type errors, zero lint errors, and a clean build before it merges. Claude Code itself still runs on your own paid Anthropic plan. The Code Kit is the process around it, not a replacement for it.
Posted by @speedy_devv
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
Caching and Revalidation
A Next.js 16 caching strategy for multi-tenant SaaS: cache components and partial prerendering, use cache with cacheLife and cacheTag, per-tenant cache keys, and cachetag revalidate calls fired from oRPC mutations.
Outbound Webhooks
How to send webhooks to your customers from a Next.js 16 SaaS: endpoint registration, HMAC-signed payloads with timestamps, Inngest retries with exponential backoff, and a delivery log with manual replay.