Easy real-time notifications with Supabase Realtime
Real-time notifications is almost an essential part of any application these days. You want your users to be aware of what's happening, allow them to interact with one another or at the very minimum, allow yourself for a unidirectional communication with your in-app users.
Building real-time notifications is not a trivial task. There are tons of ways to do that, including Web-sockets, Server Sent Events (SSE) or even Notifications API SaaS. But, if you're lucky Supabase user like I am, real-time notifications may be a walk in the park compared to other solutions.
In this article, we will learn how to setup, connect and build real-time notifications using Supabase Realtime.
Setting up the project
We will only need two things to make this work:
Next.js Application
Supabase Project
Create Next.js application
Let's create a Next.js application first. Create a new directory and cd into it:
mkdir real-time-notifications && cd real-time-notifications
Initialise a new Next.js app:
npx create-next-app@latest
Feel free to select any configuration you like, but keep in mind that we will use Tailwind CSS and not use src/
directory. If you're not sure what to select, you can see the image below and follow my config:
Let's start your app. cd
into new app directory and run npm run dev
it should start your project at http://localhost:3000
.
Stop the app. Let's clean up the Next.js app a bit.
// app/page.tsx
import Link from 'next/link';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center p-24">
<Link href="/1" className="text-blue-500 hover:underline">
Go to user 1
</Link>
<Link href="/2" className="text-blue-500 hover:underline">
Go to user 2
</Link>
</main>
);
}
Also, remove everything from globals.css
except for Tailwind:
@tailwind base;
@tailwind components;
@tailwind utilities;
Alright, done with Next.js for now. Let's create a new Supabase project.
Create Supabase project
If you don't have a Supabase project yet, go to database.new and create a new project. I will name it real-time-notifications
. Come up with a strong password and select your region. Then, hit "Create new project" and wait while Supabase will prepare it for you.
Once Supabase created a new project, copy and save the anon
/public
key and project URL
somewhere. We will need it later to connect our Next.js app with Supabase.
Next, we need to create our first table. Go to Table Editor and add a new table, we will call it notifications
.
Note: You need to select Enable Reatime checkbox.
Don't worry, we can do it later as well.
We will keep the schema for this table pretty simple:
public.notifications (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
text character varying not null,
sender_id integer null,
receiver_id integer not null,
constraint notifications_pkey primary key (id)
)
id
and created_at
will be handled by Postgres for us and we will only care about text
, sender_id
and receiver_id
. As per definition, sender_id
may be nullable, just in case if we want to create system notifications, that don't have any sender.
You can add more columns to this table, including type
, data
and many more, but for this article we will keep it simple.
The last thing we need to do right now is to configure RLS policies. This is needed to allow non-authenticated users to read and write to and from our database. Go to SQL Editor and paste the following:
create policy "public can read and insert notifications"
on public.notifications
for select to anon
using (true);
Let's connect Supabase with our Next.js app.
Connect Next.js and Supabase
I hope you noted the anon
key and project URL
somewhere, cause we will need them now. If not, don't worry! Go to Project Settings > API, where you can find this info.
In the root of your Next.js app, create a new .env.local
file and add Supabase key and project URL there in the following format:
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
This will allow Next.js Client components to successfully connect and communicate with supabase.
Let's create a shared Supabase client that we will initialise only once and will reuse across different pages and components.
First, install @supabase/supabase-js
library:
npm i @supabase/supabase-js
Next, create a new file lib/supabase.ts
and paste the following code inside:
import { createClient } from '@supabase/supabase-js';
// Create a single supabase client for interacting with your database
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
Cool, now we can import supabase
from this file across our app. Let's start building real-time notifications!
Building
Let's create a new page - [user_id]/page.tsx
. Since we don't have actual users or authentication, we will cheat a bit and use user_id
from URL params to simulate different users.
export default function UserPage() {
return (
<div className="justify-center items-center flex flex-col w-full h-screen">
<h1>My notifications:</h1>
</div>
)
}
Now, let's test our Supabase and get the list of all notifications. Create a mock notification in notifications
table.
Using Supabase from Server Components
We have installed and initialised only client (in-browser) Supabase client, but in order to fetch data on server, we also need to create a separate SSR Supabase client. First, install @supabase/ssr
package:
npm install @supabase/ssr
Next, create a new file inside lib
directory - supabaseSsr.ts
import { cookies } from 'next/headers';
import { CookieOptions, createServerClient } from '@supabase/ssr';
export function createSsrClient() {
const cookieStore = cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
cookieStore.set({ name, value: '', ...options })
},
},
}
)
}
This will allow us to use supabase
in the browser environment and call createSsrClient
in server environment. createSsrClient
function is required in order not to reuse the same client across multiple sessions.
Let's fetch notifications from Supabase inside [user_id]/page.tsx
and print them in the console:
import { createSsrClient } from '@/lib/supabaseSsr';
import { Notifcations } from './notifications';
export default async function UserPage() {
const client = createSsrClient();
const { data: notifications } = await client.from('notifications').select('*');
console.log(notifications);
return (
<div className="justify-center items-center flex flex-col w-full h-screen">
<h1>My notifications:</h1>
{notifications && <Notifcations notifications={notifications} />}
</div>
)
}
And this is what will be printed in the terminal:
GET / 200 in 89ms
[
{
id: 1,
created_at: '2024-08-06T03:21:06.563022+00:00',
text: 'My first notification',
sender_id: 2,
receiver_id: 1
}
]
Great, now we have successfully connected and fetched data from Supabase inside Server Components.
Building Notifications Client Component
Since notifications require real-time, they can't be a server component. Hence, we will need to create a client component in order to handle notifications and update them in real-time. In the same [user_id]
directory, create a notifications.tsx
component that will be responsible for rendering notifications:
"use client";
import { useState } from 'react';
export const Notifcations = ({ notifications }: { notifications: any[] }) => {
const [localNotifications, setLocalNotifications] = useState(notifications);
return (
<div className="flex flex-col gap-4 justify-center items-center border px-4 py-2 rounded-lg min-w-[450px]">
{localNotifications.length > 0 ? localNotifications.map((notification) => (
<div key={notification.id} className="flex flex-row gap-4 items-center">
<div className="flex flex-col">
<p className="text-sm">{notification.text}</p>
</div>
</div>
)) : <p>No notifications</p>}
</div>
);
};
For now, this component just renders all notifications. Note, we are passing notifications
to useState
and use localNotifications
- this is needed, so we can control upcoming notifications instantly without refetching the data.
Next, let's add Supabase Realtime to our Notifications
component:
// no changes
import { supabase } from '@/lib/supabase';
export const Notifcations = ({ notifications }: { notifications: any[] }) => {
const [localNotifications, setLocalNotifications] = useState(notifications);
useEffect(() => {
supabase
.channel('notifications')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications' }, payload => {
console.log(payload);
})
.subscribe();
}, []);
return (
// no changes
);
};
The code inside useEffect
will subscribe to all INSERT
changes in the notifications
table and whenever any change is received we will get the payload object. Let's test if it works!
Add Notifications
component to [user_id]/page.tsx
:
import { createSsrClient } from "@/lib/supabaseSsr";
import { Notifcations } from './notifications';
export default async function UserPage() {
const client = createSsrClient();
const { data: notifications } = await client.from('notifications').select('*');
return (
<div className="justify-center items-center flex flex-col w-full h-screen">
<h1>My notifications:</h1>
{notifications && <Notifcations notifications={notifications} />}
</div>
)
}
Run the app npm run dev
and go to http://localhost:3000/1
and open Dev Tools console. Next, in another tab go to your Supabase Table Editor and add a new entry to notifications
table.
This is what I added (id 2):
And this is what I got in the console:
Awesome! We just got our first real-time notification!
Handling incoming notifications
You have probably noticed that for now we are fetching and handling all the incoming notifications. It doesn't work like that in real world, right? Let's fix it!
Inside [user_id]/page.tsx
let's edit our query:
import { createSsrClient } from '@/lib/supabaseSsr';
import { Notifcations } from './notifications';
export default async function UserPage({ params }: { params: { user_id: string } }) {
const { user_id } = params;
const client = createSsrClient();
const { data: notifications } = await client.from('notifications').select('*').eq('receiver_id', user_id);
return (
<div className="justify-center items-center flex flex-col w-full h-screen">
<h1>My notifications:</h1>
{notifications && <Notifcations notifications={notifications} />}
</div>
)
}
Since we only added notifications with receiver_id: 1
when you navigate from user 1 to user 2, you will see that only user 1 has notifications. Now, we need to handle the user_id
in the Notifications
component as well and update the state when new notification arrives:
Add user_id
as prop to Notifications
component:
// [user_id]/page.tsx
<Notifcations notifications={notifications} user_id={user_id} />
// notifications.tsx
export const Notifcations = ({ notifications, user_id }: { notifications: any[], user_id: string }) => {
Modify the payload handler inside supabase
subscription:
useEffect(() => {
supabase
.channel('notifications')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications' }, payload => {
const notification = payload.new;
if (notification.receiver_id === parseInt(user_id)) {
setLocalNotifications([...localNotifications, notification]);
}
})
.subscribe();
}, []);
Now, we are still listening to all notifications, but we update the state only when notification receiver_id
is equal to our user_id
.
Test it out! Go to user 1 page and user 2 page and try creating new notifications with different receiver_id
.
Check out my test in two different browser windows and notice how notifications are coming in real time:
And this is it! You can keep building on top of this to achieve any result you want. Different types of notifications, toasts, notification tabs and many-many more. You have the foundation ready, next it's up to you how to use it!
Below you can find the full code for this application.
Have fun and see you later ✌️
P.S.: Would appreciate follow on X 🐦
The full code
// app/page.tsx
import Link from 'next/link';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center p-24">
<Link href="/1" className="text-blue-500 hover:underline">
Go to user 1
</Link>
<Link href="/2" className="text-blue-500 hover:underline">
Go to user 2
</Link>
</main>
);
}
// app/[user_id]/page.tsx
import { createSsrClient } from '@/lib/supabaseSsr';
import { Notifcations } from './notifications';
export default async function UserPage({ params }: { params: { user_id: string } }) {
const { user_id } = params;
const client = createSsrClient();
const { data: notifications } = await client.from('notifications').select('*').eq('receiver_id', user_id);
return (
<div className="justify-center items-center flex flex-col w-full h-screen">
<h1>Notifications for User ID: {user_id}</h1>
{notifications && <Notifcations notifications={notifications} user_id={user_id} />}
</div>
)
}
// app/[user_id]/notifications.tsx
"use client";
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
export const Notifcations = ({ notifications, user_id }: { notifications: any[], user_id: string }) => {
const [localNotifications, setLocalNotifications] = useState(notifications);
useEffect(() => {
supabase
.channel('notifications')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications' }, payload => {
const notification = payload.new;
if (notification.receiver_id === parseInt(user_id)) {
setLocalNotifications([...localNotifications, notification]);
}
})
.subscribe();
}, []);
return (
<div className="flex flex-col gap-4 justify-center items-center border px-4 py-2 rounded-lg min-w-[450px]">
{localNotifications.length > 0 ? localNotifications.map((notification) => (
<div key={notification.id} className="flex flex-row gap-4 items-center">
<div className="flex flex-col">
<p className="text-sm">{notification.text}</p>
</div>
</div>
)) : <p>No notifications</p>}
</div>
);
};
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
// lib/supabaseSsr.ts
import { cookies } from 'next/headers';
import { CookieOptions, createServerClient } from '@supabase/ssr';
export function createSsrClient() {
const cookieStore = cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
cookieStore.set({ name, value: '', ...options })
},
},
}
)
}
// .env.local
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
Subscribe to my newsletter
Read articles from Sergii Kirianov directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Sergii Kirianov
Sergii Kirianov
Developer Advocate, OSS contributor, JavaScript Messiah