Meta description: I built real-time notifications in Next.js using Supabase Realtime without rolling my own WebSocket server — here’s the exact setup, gotchas, and production tips I learned.
Last updated: June 23, 2026
Introduction
I used to reach for a dedicated WebSocket server — Socket.io, a Redis pub/sub bridge, a separate Node process — every time a product manager said the word “notifications.” It worked, but it also meant maintaining another service, another deploy pipeline, another set of reconnection edge cases to handle at 11 p.m.
Then I tried Supabase Realtime with Next.js 14. I was skeptical. Managed WebSocket infrastructure sounds like it comes with a catch. But after shipping a production notification system for a B2B SaaS app with roughly 300 concurrent users, I can say: for the majority of use cases, you don’t need to run your own WebSocket server at all. Here’s exactly how I set it up, what surprised me, and what I’d do differently next time.
TL;DR
- Supabase Realtime provides managed WebSocket infrastructure backed by PostgreSQL
LISTEN/NOTIFY— you subscribe to table changes directly from the browser without writing any WebSocket server code. - In Next.js 14+, the correct pattern is to initialize the Supabase Realtime channel in a client component and clean it up in the
useEffectreturn — skipping this step causes duplicate subscriptions on hot reload. - The biggest gotcha is Row Level Security: if your RLS policies block a user from reading a row, they also won’t receive Realtime events for that row — plan your policies accordingly.
Why Supabase Realtime Instead of Rolling Your Own
Supabase Realtime is an Elixir-based Phoenix Channel server that sits in front of your PostgreSQL database and broadcasts row-level change events (INSERT, UPDATE, DELETE) to subscribed clients over WebSockets. You don’t host it. You don’t scale it. You don’t write a single line of server-side socket code.
Compared to the alternatives, the trade-offs look like this:
- vs. Socket.io + Redis: No extra infrastructure, no pub/sub broker, no custom event schema. But you lose arbitrary event types — everything is tied to database changes.
- vs. Server-Sent Events (SSE): SSE is one-directional and simpler, but managing reconnection and missed events is on you. Supabase Realtime handles reconnection with exponential backoff automatically.
- vs. Polling: Polling is fine for low-frequency updates. At 300 concurrent users polling every 5 seconds, you’re adding 3,600 DB reads per minute for zero functional gain over Realtime.
In my experience, Supabase Realtime is the right default for notification systems where events map directly to database writes. If you need arbitrary event types that aren’t tied to a DB row — like user cursor positions or collaborative text ops — you’d need something else.
Secondary keywords used in this article: Supabase Postgres changes, Next.js real-time updates, server-sent events vs WebSockets, Supabase RLS policies, Next.js App Router client components.
[INTERNAL LINK: related article on Supabase authentication with Next.js App Router]
Prerequisites
Before following the steps below, make sure you have:
- Next.js 14.2.x with the App Router
@supabase/supabase-js2.43.x (npm install @supabase/supabase-js)@supabase/ssr0.3.x for cookie-based auth in the App Router- A Supabase project with Realtime enabled on the target table (Dashboard → Database → Replication → Tables)
- Row Level Security enabled on your
notificationstable (required for Realtime to respect user scope)
Step-by-Step Implementation
Step 1: Create the Notifications Table
Run this in the Supabase SQL editor:
create table public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
title text not null,
body text,
read boolean not null default false,
created_at timestamptz not null default now()
);
-- Enable RLS
alter table public.notifications enable row level security;
-- Users can only see their own notifications
create policy "Users see own notifications"
on public.notifications
for select
using (auth.uid() = user_id);
-- Server-side inserts via service role bypass RLS by default
Enable Replication for this table in the Dashboard under Database → Replication → 0 Tables → Add Table → notifications. Without this step, Realtime won’t broadcast changes regardless of your subscription code.
Step 2: Set Up the Supabase Client for the App Router
Create a browser client utility. In Next.js App Router, never import the server client in a client component:
// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
The NEXT_PUBLIC_ prefix is required — without it, Next.js won’t expose these variables to the browser bundle, and you’ll get a silent undefined error that only shows up at runtime.
Step 3: Build the Notification Hook
This is the core of the implementation. The hook subscribes to new notifications for the current user and manages cleanup correctly:
// hooks/useNotifications.ts
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { RealtimePostgresInsertPayload } from "@supabase/supabase-js";
type Notification = {
id: string;
title: string;
body: string | null;
read: boolean;
created_at: string;
};
export function useNotifications(userId: string) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const supabase = createClient();
// Fetch existing unread notifications on mount
supabase
.from("notifications")
.select("*")
.eq("user_id", userId)
.eq("read", false)
.order("created_at", { ascending: false })
.then(({ data }) => {
if (data) {
setNotifications(data);
setUnreadCount(data.length);
}
});
// Subscribe to new inserts
const channel = supabase
.channel(`notifications:${userId}`)
.on<Notification>(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload: RealtimePostgresInsertPayload<Notification>) => {
setNotifications((prev) => [payload.new, ...prev]);
setUnreadCount((prev) => prev + 1);
}
)
.subscribe();
// Cleanup — CRITICAL for Next.js hot reload
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
return { notifications, unreadCount };
}
Important: Always name your channel with a unique, user-scoped string like
notifications:${userId}. If two components on the same page subscribe with the same channel name, Supabase deduplicates them — but if you use a generic name like"notifications", you risk cross-user event leaks in development when multiple browser tabs share the same anon key session.
Step 4: Build the Notification Bell Component
// components/NotificationBell.tsx
"use client";
import { useNotifications } from "@/hooks/useNotifications";
export function NotificationBell({ userId }: { userId: string }) {
const { notifications, unreadCount } = useNotifications(userId);
return (
<div className="relative">
<button aria-label={`${unreadCount} unread notifications`}>
🔔
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<ul className="absolute right-0 mt-2 w-80 bg-white shadow-lg rounded-lg overflow-hidden">
{notifications.length === 0 && (
<li className="p-4 text-gray-500 text-sm">No new notifications</li>
)}
{notifications.map((n) => (
<li key={n.id} className="p-4 border-b hover:bg-gray-50">
<p className="font-semibold text-sm">{n.title}</p>
{n.body && <p className="text-sm text-gray-600 mt-1">{n.body}</p>}
</li>
))}
</ul>
</div>
);
}
Use this in a Server Component layout by fetching the userId server-side and passing it as a prop:
// app/layout.tsx (Server Component)
import { createServerClient } from "@supabase/ssr";
import { NotificationBell } from "@/components/NotificationBell";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const supabase = createServerClient(/* cookie config */);
const { data: { user } } = await supabase.auth.getUser();
return (
<html>
<body>
<nav>
{user && <NotificationBell userId={user.id} />}
</nav>
{children}
</body>
</html>
);
}
Step 5: Send Notifications from Your Server Actions
Use the service role key (never exposed to the browser) to insert notifications server-side:
// lib/notifications.ts (server-only)
import { createClient } from "@supabase/supabase-js";
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // ← never NEXT_PUBLIC_
);
export async function sendNotification(
userId: string,
title: string,
body?: string
) {
const { error } = await supabaseAdmin
.from("notifications")
.insert({ user_id: userId, title, body });
if (error) throw new Error(`Notification insert failed: ${error.message}`);
}
[SOURCE: https://supabase.com/docs/guides/realtime/postgres-changes]
Real-World Tips I Use in Production
In production, I always add a heartbeat check to confirm the Realtime channel is healthy. Supabase exposes channel status via the .subscribe((status) => { ... }) callback. Log SUBSCRIBED, CHANNEL_ERROR, and TIMED_OUT events to your observability tool (I use Axiom) so you know when clients are silently disconnecting.
I also debounce the unread count badge update by 300 ms using useTransition. Without this, rapid-fire notifications (common during bulk sends) cause excessive re-renders that noticeably lag the UI on mid-range mobile devices.
Finally, mark notifications as read with an optimistic update: flip the local state immediately, then fire the DB UPDATE in the background. Users should never wait for a network round-trip to see a notification marked as read.
[SOURCE: https://supabase.com/docs/reference/javascript/subscribe]
Common Errors and How I Fixed Them
Error: Realtime channel connects but never receives events.
Cause: The table isn’t added to the Replication publication. This is the #1 gotcha.
Fix: Go to Supabase Dashboard → Database → Replication → your publication → Add table → notifications. Then reconnect the channel.
Error: Error: new row violates row-level security policy for table "notifications" in server logs.
Cause: You’re trying to insert with the anon key instead of the service role key.
Fix: Make sure your server-side insert uses SUPABASE_SERVICE_ROLE_KEY (no NEXT_PUBLIC_ prefix).
Error: Duplicate events fired on every Next.js hot reload in development.
Cause: useEffect runs twice in React 18 Strict Mode, creating two channels before cleanup runs.
Fix: This is expected behavior in development. The cleanup function (supabase.removeChannel(channel)) handles it correctly — make sure yours is in place. In production builds, Strict Mode double-invocation doesn’t happen.
Security Note: Never put
SUPABASE_SERVICE_ROLE_KEYin aNEXT_PUBLIC_variable. The service role key bypasses all RLS policies. If it leaks to the browser, any user can read, write, or delete any row in your database. Use it exclusively in Server Actions, API routes, and server-side utilities.
FAQ
Q: How do I implement real-time notifications in Next.js App Router without a custom WebSocket server?
A: Use Supabase Realtime with the @supabase/ssr package. Subscribe to postgres_changes events in a client component with useEffect, passing a user-scoped filter like user_id=eq.${userId}. Supabase manages the WebSocket connection, reconnection, and scaling — you write zero server-side socket code.
Q: Does Supabase Realtime work with Next.js Server Components?
A: No. Server Components render on the server and have no persistent browser connection. Realtime subscriptions must live in Client Components (marked with "use client") because they require a browser WebSocket connection. Use Server Components to fetch the initial data snapshot, then hand off to a Client Component for live updates.
Q: What happens to Supabase Realtime notifications when a user is offline?
A: Events fired while a user is offline are not buffered by default. When the user reconnects, they won’t automatically receive missed events — the subscription only delivers events from the moment it re-establishes. To handle missed notifications, fetch the latest unread rows from the database on reconnect (inside a channel.on("system", ...) SUBSCRIBED callback) and merge them with local state.
Q: How do I filter Supabase Realtime events by user ID to prevent users from seeing each other’s notifications?
A: Add a filter option to your subscription: filter: \user_id=eq.${userId}`. But this alone isn't enough — you must also have an RLS policy on the notificationstable that restricts SELECT toauth.uid() = user_id`. Realtime respects RLS, so a user subscribed without RLS could receive all rows. Use both the filter and RLS together.
Q: Is Supabase Realtime scalable enough for a production SaaS application with thousands of users?
A: In my experience with ~300 concurrent users, Supabase Realtime on the Pro plan handled the load without any configuration tuning. Supabase’s own documentation notes that the hosted Realtime server is built on Elixir/Phoenix, which handles hundreds of thousands of concurrent connections efficiently. For high-concurrency workloads (10,000+ concurrent users), review Supabase’s Enterprise plan limits and consider their dedicated compute add-on.
Conclusion
Real-time notifications don’t require a custom WebSocket server, a Redis broker, or a 2 a.m. pager alert about disconnected sockets. Supabase Realtime plus a well-structured Next.js client component gets you 90% of the way there with a fraction of the operational overhead.
The real work is in the details: RLS policies that actually protect your data, cleanup functions that prevent subscription leaks, and a reconnect strategy that handles offline users gracefully. Get those right and you have a notification system that just works
About the Author
I’m a full-stack engineer with 9 years of experience building SaaS products, with a focus on React, Next.js, and PostgreSQL-backed APIs over the last four years. I’ve shipped notification systems, collaborative editing features, and real-time dashboards across fintech and developer tooling products. My current stack is Next.js 14, Supabase, Tailwind, and Vercel — and I write about the gaps between the official docs and what actually works in production.

