Multi-User Note App with Supabase Realtime

Multi-User Note App with Supabase Realtime

This blog post is all about creating a Multi-User Note App with Next.js and Supabase. We'll walk through structuring a dynamic database, and building cool interactive features for a smooth note-taking experience. Join us on this journey to build a space where users can create, join, and manage notes together in real time. Excited? Let's jump in and explore the magic of real-time collaboration!

Setup

To better differentiate and keep track of our users, let's use Supabase Auth. Luckily, Supabase has a “Next.js x Supabase” template which comes with an email and password authentication out of the box. To install it, run:

npx create-next-app -e with-supabase

Next up, create a new Supabase project and fill out the env.local.example file with the necessary information. Don't forget to rename env.local.example to env.local afterward.

Now let's remove all placeholder components that come with this template. The only thing left in the components directory should be AuthButton.tsx. Of course, you will also need to adjust page.tsx accordingly.

Database

Let's get our database setup. We will create two tables. One table will store our rooms with its specific admin. Another table will store the notes of each room. This means once a room is deleted, we should also delete the notes. Our SQL query will look something like this:

create table
  rooms (
    id uuid primary key default uuid_generate_v4 (),
    user_email text unique
    joined_emails jsonb
  );

-- RLS

create table
  notes (
    id uuid primary key default uuid_generate_v4 (),
    room_id uuid references rooms (id) on delete cascade,
    created_at timestamp with time zone default current_timestamp,
    text text
  );

-- RLS

Once we ran our query, we should activate the Realtime mode for our “notes” table. For that, head to the table and click on “Realtime off”. Once you confirm, it should switch to “Realtime on”. Now we can move on to our code.

Components

Join or Create Room

Let's create a component called JoinOrCreateRoom.tsx. Once our users have signed up and signed in. We will display this component, allowing our users to decide if they want to create a new room or join an existing one to view, add or delete notes. If you joined a room, you can quit the room and join another one. If you created a room, you have to delete your room to join or create another one. For these actions, let's create three functions:

const handleCreateRoom = async () => {
    if (!user) return;
    const userEmail = user.email;
    const { data, error } = await supabase
      .from("rooms")
      .insert({ user_email: userEmail });

    //Check
    if (error) {
      console.log(error);
      return;
    }

    // Get room
    const { data: roomData, error: roomError } = await supabase
      .from("rooms")
      .select()
      .eq("user_email", userEmail)
      .single();
    setRoom(roomData);

    //Check
    if (roomError) {
      console.log(roomError);
      return;
    }
  };

handleCreateRoom adds a new object to our rooms table, including user's email. The id for the room is generated automatically. That's why we need to get that ID and set our room state (using setRoom) to the room.

const [roomId, setRoomId] = useState<string>("");

  const handleJoinRoom = async () => {
    if (!user) return;
    const { data, error } = await supabase
      .from("rooms")
      .select()
      .eq("id", roomId)
      .single();
    if (!data) return;
    setRoom(data);

    //Check
    if (error) {
      console.log(error);
      return;
    }
  };

handleJoinRoom gets the roomId from the input and checks if such room exists. If so, we will set our room state (using setRoom) to the room.

const handleQuitRoom = async () => {
    if (!user) return;
    const userEmail = user.email;
    const { data, error } = await supabase
      .from("rooms")
      .delete()
      .eq("user_email", userEmail);
    setRoom(null);
    setNotes([]);

    //Check
    if (error) {
      console.log(error);
      return;
    }
  };

Finally, handleQuitRoom will delete the room only in that case if the room belongs to the user. In any case, it will always empty our notes and reset the room.

Here's how we will display it:

return (
    <div>
      {user &&
        (!room ? (
          <div>
            <input
              className="input"
              placeholder="Enter RoomID"
              type="text"
              value={roomId}
              onChange={(e) => setRoomId(e.target.value)}
            />
            <button className="btn ml-3" onClick={handleJoinRoom}>
              Join Room
            </button>
            <button className="btn ml-3" onClick={handleCreateRoom}>
              Create Room
            </button>
          </div>
        ) : (
          <div className="flex items-center">
            {room.user_email === user.email ? (
              <button className="btn" onClick={handleQuitRoom}>
                Delete Room
              </button>
            ) : (
              <button className="btn" onClick={handleQuitRoom}>
                Quit Room
              </button>
            )}
            <p className="ml-3">
              RoomID: <span className="ml-3">{room.id}</span>
            </p>
          </div>
        ))}
    </div>
  );

In the end, here are the props we will pass into that component:

type Props = {
  user: User | null;
  room: any | null;
  setRoom: React.Dispatch<React.SetStateAction<any | null>>;
  setNotes: React.Dispatch<React.SetStateAction<Note[]>>;
};

NoteBlock

Not much to say to this component, besides that it's used for displaying and deleting out notes. Here how it looks like:

import { useState } from "react";
import { createClient } from "@/utils/supabase/client";

interface Props {
  id: number;
  text: string;
  className?: string;
}

const NoteBlock: React.FC<Props> = ({ id, text, className }) => {
  const [isDeleting, setIsDeleting] = useState(false);

  const supabase = createClient();

  const handleDelete = async () => {
    try {
      setIsDeleting(true);
      const { error } = await supabase.from("notes").delete().eq("id", id);
      if (error) {
        throw new Error(error.message);
      }
    } catch (error) {
      console.error("Error deleting note:", error);
    } finally {
      setIsDeleting(false);
    }
  };

  return (
    <li
      className={`flex justify-between border border-white px-6 py-3 rounded-lg ${className}`}
    >
      <p className="w-full text-white border-r-2 border-white pr-6 mr-6">
        {text}
      </p>
      <button className="btn" onClick={handleDelete} disabled={isDeleting}>
        Delete
      </button>
    </li>
  );
};

export default NoteBlock;

Page

Finally, let's put our page together.

const [user, setUser] = useState<User | null>(null);
const [room, setRoom] = useState<any | null>(null);
const [notes, setNotes] = useState<Note[]>([]);

const [note, setNote] = useState<string>("");

We will use the following states to track our user, room and notes data. As well, as the input for the note.

We will have two useEffects one will be used to get the room data of the admin if he decides to leave the page. Another one will be used to set up our Realtime connection.

useEffect(() => {
    const getUser = async () => {
      const userData = await supabase.auth.getUser();
      setUser(userData.data.user);
      if (!userData.data.user) return;

      const user = userData.data.user;

      // Get room

      const { data, error } = await supabase
        .from("rooms")
        .select()
        .eq("user_email", user.email)
        .single();
      setRoom(data);
      getNotes();

      //Check
      if (error) {
        console.log(error);
        return;
      }
    };

    getUser();
  }, []);
useEffect(() => {
    if (room) {
      getNotes();
      supabase
        .channel(room.id)
        .on(
          "postgres_changes",
          {
            event: "*",
            schema: "public",
            table: "notes",
          },
          () => {
            getNotes();
          }
        )
        .subscribe();
    }
  }, [room]);

First, we create a channel. As the identifier, we will use the ID of the room. Next, once we get an update in our “notes” table, whether it's INSERT, DELETE or UPDATE (that's why we use *) we will update our notes.

To update our notes, we use getNotes method:

const getNotes = async () => {
    if (!user || !room) return;
    const { data, error } = await supabase
      .from("notes")
      .select()
      .eq("room_id", room.id);
    if (!data) return;
    setNotes(data);

    //Check
    if (error) {
      console.log(error);
      return;
    }
  };

To save a note, we use saveNote method:

const saveNote = () => async () => {
    if (!user || !room) return;
    const { data, error } = await supabase
      .from("notes")
      .insert({ room_id: room.id, text: note });

    //Check
    if (error) {
      console.log(error);
      return;
    }

    setNote("");
    getNotes();
  };

In the end, this is how we display our page:

return (
    <div className="flex-1 w-full flex flex-col gap-20 items-center">
      <nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
        <div className="w-full max-w-4xl flex justify-between items-center p-3 text-sm">
          <JoinOrCreateRoom
            user={user}
            room={room}
            setRoom={setRoom}
            setNotes={setNotes}
          />
          {supabase && <AuthButton user={user} />}
        </div>
      </nav>

      <div className="animate-in flex-1 flex flex-col gap-20 opacity-0 max-w-4xl px-3">
        <main className="flex-1 flex flex-col gap-6">
          {user && room && (
            <div className="flex flex-col gap-6">
              <div>
                <input
                  className="input mr-3"
                  onChange={(e) => setNote(e.target.value)}
                  value={note}
                  type="text"
                  placeholder="Enter Note"
                />
                <button className="btn" onClick={saveNote()}>
                  Save Note
                </button>
              </div>
              <div className="mt-6">
                <h1 className="text-2xl font-bold">Notes</h1>

                <ul className="flex flex-col gap-2 mt-3">
                  {notes.map((note, index) => (
                    <NoteBlock
                      className="mt-3"
                      key={index}
                      id={note.id}
                      text={note.text}
                    />
                  ))}
                </ul>
              </div>
            </div>
          )}
        </main>
      </div>

      <footer className="w-full border-t border-t-foreground/10 p-8 flex justify-center text-center text-xs">
        <p>
          Powered by{" "}
          <a
            href="https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs"
            target="_blank"
            className="font-bold hover:underline"
            rel="noreferrer"
          >
            Supabase
          </a>
        </p>
      </footer>
    </div>
  );
}

Demo

We did it! Here is the demo of the final product. Enjoy! 🎉


Thanks for reading! ❤️ If you want to be the first one to see my next article, follow me on Hashnode and Twitter!

Did you find this article valuable?

Support Kirill Inoz by becoming a sponsor. Any amount is appreciated!