Table of contents
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!