Easy real-time notifications with Supabase Realtime

Easy real-time notifications with Supabase Realtime

Create real-time notifications in a matter of minutes with Next.js App Router and Supabase Realtime

Featured on Hashnode

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:

create-next-app 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.

starting next.js application on localhost

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.

Create new supabase project

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.

anon key porject url

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=

Did you find this article valuable?

Support Code Cry Repeat by becoming a sponsor. Any amount is appreciated!