Build a Real-Time Notes App
Build a full-stack notes app with auth, database, storage, and real-time sync.
Introduction
30 Second Summary
Every modern app you use keeps your data in sync across devices and tabs. Building that real-time experience yourself used to require managing servers, databases, and WebSocket connections from scratch.
In this project, you will build a personal notes app with authentication, a database, file storage, and real-time sync using Supabase as your entire backend. No custom server code required.
What You'll Build
A full-stack notes app where users sign up, create notes with image attachments, and see changes sync instantly across browser tabs.
By the end of this project, you'll have:
- A Next.js app scaffolded with the official with-supabase template, pre-configured with cookie-based authentication.
- A Postgres database with Row Level Security so each user can only access their own notes.
- Real-time subscriptions that sync the notes list across tabs, plus image attachments stored in Supabase Storage.
- Secret Mission: Add a favorite toggle that syncs in real-time and a filter to show only favorites.
Are there any prerequisites?
This project assumes intermediate JavaScript/TypeScript knowledge and basic familiarity with the Next.js App Router (pages, layouts, server components vs client components).
You do not need prior Supabase experience. The project teaches everything from scratch.
Before We Start
Before We Start...
Scaffold the Next.js App with Supabase
Every full-stack app needs a foundation. In this project, your foundation is a Next.js app connected to a Supabase backend.
Instead of wiring up authentication from scratch, you will use the official with-supabase template. This gives you cookie-based auth, TypeScript, Tailwind CSS, and pre-built UI components right out of the box.
In this step, get ready to:
- Scaffold a Next.js app using the official Supabase template.
- Connect the app to your Supabase project with environment variables.
- Start the dev server and verify authentication works.
Scaffold the Next.js app
The with-supabase template scaffolds a Next.js app pre-configured with cookie-based auth via @supabase/ssr, TypeScript, Tailwind CSS, and shadcn/ui components.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your Next.js app by running this command:
npx create-next-app -e with-supabase realtime-notes
What does this command do?
The -e with-supabase flag tells create-next-app to use the official Supabase template instead of the default Next.js starter.
The last argument realtime-notes sets the project folder name so the wizard skips the naming prompt.
- Once the installation finishes, move into your new project folder by running:
cd realtime-notes
- Confirm the project files exist by running:
ls
- You should see files like package.json, app/, .env.example, and next.config.ts in the output.
Command not found or install failed?
Make sure Node.js 20+ is installed. Run node --version to check.
If npx hangs, check your internet connection. The template is downloaded from GitHub.
Help me troubleshoot a failed create-next-app scaffold with the with-supabase template.
Connect your Supabase project
Your app needs two pieces of information to talk to Supabase: the project URL and a publishable key. Both come from your Supabase Dashboard.
- Open your browser and go to database.new to create a new Supabase project.
- Give your project a name (e.g. realtime-notes) and set a database password.
- Select a region close to you and click Create new project.
- Wait for the project to finish provisioning. This takes about a minute.
What is a Supabase project?
A Supabase project gives you a dedicated Postgres database, an auth system, file storage, and real-time capabilities. All accessible through one URL and one set of API keys.
- Once your project is ready, open the Project Connect dialog (the Connect button at the top of the Dashboard).
- Copy your Project URL (it looks like https://xxxxx.supabase.co).
- Copy your Publishable key (it starts with sb_publishable_).
Alternative: Settings > API Keys
If you do not see the Connect dialog, navigate to Settings > API Keys in the Dashboard sidebar. Open the API Keys tab, and if no publishable key exists, click Create new API Keys.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the realtime-notes folder, and click Open.
- In the Explorer sidebar on the left, find the file called .env.example.
- Right-click .env.example and select Rename.
- Change the name to .env.local and press Enter.
- Open .env.local and replace the placeholder values with your Supabase project URL and publishable key so the file looks like this:
NEXT_PUBLIC_SUPABASE_URL=[[SUPABASE_URL="your Supabase project URL (e.g. https://xxxxx.supabase.co)"]]
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=[[SUPABASE_PUBLISHABLE_KEY="your Supabase publishable key (starts with sb_publishable_)"]]
Why is it safe to use these values in code?
The publishable key is designed to be exposed in client-side code. It only grants limited access that is further restricted by Row Level Security policies you will set up in the next step.
The NEXT_PUBLIC_ prefix tells Next.js to bundle these variables into the browser build. This is intentional for Supabase's auth flow.
- Save .env.local by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Not seeing .env.example in the Explorer?
Files starting with a dot are hidden by default on some systems. In VS Code, they should appear in the Explorer sidebar. If you still cannot see it, make sure you opened the correct realtime-notes folder.
Help me find the .env.example file in my project.
Start the dev server and test authentication
The template includes working sign-up and sign-in pages out of the box. Let's verify everything is connected correctly.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the template's home page with links to sign in and sign up.
- Click Sign up (or navigate to http://localhost:3000/auth/sign-up).
- Enter a test email and password, then click Sign up.
- Check your email for a confirmation link and click it to verify the account.
- After confirming, sign in with your test credentials.
What should I see?
After signing in, the page displays a message confirming you are authenticated. This proves your Supabase URL and publishable key are configured correctly.
Sign-up not working?
Double-check that the values in .env.local match exactly what the Dashboard shows. Make sure there are no extra spaces or quotes around the values.
If the page shows a connection error, confirm your Supabase project has finished provisioning in the Dashboard.
Help me debug my Supabase connection.
✔️ Awesome, I've got everything!
Great. Double check you have saved .env.local and your dev server is still running.
ⓧ I'd like to double check the full code
Here is what your .env.local file should look like:
NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-supabase-publishable-key>
Your app is scaffolded, connected to Supabase, and authentication works. Next up, you will create a database table with Row Level Security so each user's notes stay private.
Create the Notes Table with Row Level Security
Your Next.js app is connected to Supabase and authentication is working. Now you need a database table to store notes.
Supabase includes a full Postgres database. You'll create a notes table and lock it down with Row Level Security so each user can only read, create, update, and delete their own notes.
In this step, get ready to:
- Create the notes table in Supabase's Postgres database.
- Enable Row Level Security with policies for each CRUD operation.
- Enable real-time updates on the notes table.
Create the notes table in the SQL Editor
The SQL Editor in the Supabase Dashboard lets you run SQL queries directly against your project's Postgres database.
- In the Supabase Dashboard, click SQL Editor in the left sidebar.
- Click New query to open a blank editor.
- Create the notes table by pasting and running the following SQL:
-- Create the notes table
create table notes (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
title text not null,
content text,
image_url text,
is_favorite boolean default false,
created_at timestamptz default now()
);
What does this SQL do?
- id generates a unique UUID for each note automatically.
- user_id links each note to a user in Supabase Auth. The references auth.users constraint ensures the user exists.
- title is required (not null). content, image_url, and is_favorite are optional fields you'll use later.
- created_at timestamps each note so you can sort them chronologically.
- Click Run (or press Cmd+Enter on macOS, Ctrl+Enter on Windows).
- You should see a Success. No rows returned message at the bottom of the editor.
Seeing an error?
If you see a syntax error, make sure you copied the entire SQL block including the closing semicolon. Check for any extra characters that may have been introduced during pasting.
If you see a "relation already exists" error, the table was already created from a previous attempt. You can continue to the next substep.
Help me fix my SQL Editor error.
Enable Row Level Security and add policies
Without Row Level Security, any authenticated user could read or modify any row in the table. RLS policies ensure each user can only access their own notes.
- In the same SQL Editor query window, clear the previous SQL and paste the following:
-- Enable Row Level Security
alter table notes enable row level security;
-- Policy: Users can read their own notes
create policy "Users can read own notes"
on notes for select
to authenticated
using (auth.uid() = user_id);
-- Policy: Users can insert their own notes
create policy "Users can insert own notes"
on notes for insert
to authenticated
with check (auth.uid() = user_id);
-- Policy: Users can update their own notes
create policy "Users can update own notes"
on notes for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Policy: Users can delete their own notes
create policy "Users can delete own notes"
on notes for delete
to authenticated
using (auth.uid() = user_id);
What does auth.uid() = user_id do?
auth.uid() is a Supabase helper that returns the currently logged-in user's UUID from their authentication token.
Each policy compares that value to the user_id column on each row. If they don't match, Postgres blocks the operation entirely. This means User A can never see or modify User B's notes.
The to authenticated clause means these policies only apply to logged-in users. Unauthenticated requests get zero access.
- Click Run to execute the SQL.
- You should see Success. No rows returned again.
- Click Table Editor in the left sidebar. You should see the notes table listed with an RLS badge indicating Row Level Security is active.
Don't see the RLS badge?
If the notes table appears without an RLS indicator, go back to the SQL Editor and re-run the alter table notes enable row level security; statement on its own. Confirm the success message appears.
Help me enable RLS on my notes table.
Enable Realtime for the notes table
Supabase Realtime lets your app listen for database changes as they happen. You need to add the notes table to the supabase_realtime publication so your app can subscribe to insert, update, and delete events.
- Switch back to the SQL Editor in the left sidebar.
- Clear the editor and run the following SQL:
-- Enable realtime
alter publication supabase_realtime add table notes;
What is a publication?
A Postgres publication is a set of tables whose changes are broadcast to listeners. Supabase provides a built-in publication called supabase_realtime. By adding your table to it, any INSERT, UPDATE, or DELETE on notes becomes a subscribable event in your app.
- Click Run. You should see Success. No rows returned.
Seeing a publication error?
If you see an error about the table already being a member of the publication, that means Realtime was already enabled for this table. You can safely continue.
Help me troubleshoot my Realtime publication error.
✔️ Awesome, I've got everything!
Great. Double check you've run all three SQL blocks successfully.
ⓧ I'd like to double check the full code
Here is the complete SQL you should have run across all three queries:
-- Create the notes table
create table notes (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
title text not null,
content text,
image_url text,
is_favorite boolean default false,
created_at timestamptz default now()
);
-- Enable Row Level Security
alter table notes enable row level security;
-- Policy: Users can read their own notes
create policy "Users can read own notes"
on notes for select
to authenticated
using (auth.uid() = user_id);
-- Policy: Users can insert their own notes
create policy "Users can insert own notes"
on notes for insert
to authenticated
with check (auth.uid() = user_id);
-- Policy: Users can update their own notes
create policy "Users can update own notes"
on notes for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Policy: Users can delete their own notes
create policy "Users can delete own notes"
on notes for delete
to authenticated
using (auth.uid() = user_id);
-- Enable realtime
alter publication supabase_realtime add table notes;
Your database is set up and secured. Next up, you'll build the protected notes dashboard where users can create and view their notes.
Build the Protected Notes Dashboard
Your database is secured and ready for action. Now it is time to build the front-end that talks to it.
In this step, you will create a protected notes page that only authenticated users can access. The page fetches notes from your Supabase database using Row Level Security policies, so each user sees only their own data.
In this step, get ready to:
- Create a server component that checks authentication and fetches notes.
- Build a form component for adding new notes.
- Build a card component for displaying and deleting notes.
Create the protected notes page
In the Next.js App Router, creating a file at app/notes/page.tsx automatically registers the /notes route. This page will be a server component that verifies authentication before showing any content.
- In VS Code, create a new folder called notes inside the app directory.
- Create a new file called page.tsx inside app/notes/ and paste the following code:
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import NotesList from "@/components/NotesList";
import NoteForm from "@/components/NoteForm";
export default async function NotesPage() {
const supabase = await createClient();
const { data: claims, error: authError } = await supabase.auth.getClaims();
if (authError || !claims) {
redirect("/sign-in");
}
const userId = claims.sub;
const { data: notes, error } = await supabase
.from("notes")
.select()
.order("created_at", { ascending: false });
if (error) {
console.error("Error fetching notes:", error);
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">My Notes</h1>
<form action="/auth/signout" method="post">
<button
type="submit"
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Sign Out
</button>
</form>
</div>
<NoteForm userId={userId} />
<NotesList initialNotes={notes ?? []} userId={userId} />
</div>
);
}
What does this code do?
This page runs entirely on the server. It uses supabase.auth.getClaims() to verify the user's JWT token. If the token is missing or invalid, the user gets redirected to /sign-in.
The claims.sub field contains the authenticated user's UUID. The page passes this ID down to child components so they can insert notes owned by the correct user.
The query fetches notes ordered by created_at descending so the newest notes appear first. RLS policies ensure only the current user's notes are returned.
Why getClaims() instead of getSession()?
Supabase recommends always using supabase.auth.getClaims() to protect pages and user data in server code. It reads the access token from storage and verifies it cryptographically.
The older getSession() method does not validate the JWT signature, making it unsafe for server-side authorization decisions.
The page also includes a Sign Out button that posts to /auth/signout. You need to create that route handler next.
- Create a new folder path app/auth/signout/ inside your project.
- Create a file called route.ts inside app/auth/signout/ and paste this code:
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function POST() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/sign-in");
}
What does this route handler do?
This is a Next.js Route Handler that responds to POST requests. When the Sign Out button's form submits, this handler clears the user's session cookies and redirects them to the sign-in page.
- Save both app/notes/page.tsx and app/auth/signout/route.ts.
- Check your terminal. You will see compilation errors about missing NoteForm and NotesList components. This is expected because you have not created them yet.
Seeing a different error?
If you see an error about @/lib/supabase/server not being found, make sure you are working inside the project folder created by the with-supabase template. The template provides this file automatically.
If you see an error about redirect not being found, check that your import says from "next/navigation" and not from "next/router".
help me debug a compilation error in my notes page
Create the note form component
The notes page needs a form for creating new notes. This form is a client component because it manages interactive state like input values and form submission.
- In the components folder at the root of your project, create a new file called NoteForm.tsx and paste the following code:
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export default function NoteForm({ userId }: { userId: string }) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const supabase = createClient();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
setIsSubmitting(true);
let imageUrl: string | null = null;
if (imageFile) {
const fileExt = imageFile.name.split(".").pop();
const filePath = `${userId}/${Date.now()}.${fileExt}`;
const { error: uploadError } = await supabase.storage
.from("note-images")
.upload(filePath, imageFile);
if (uploadError) {
console.error("Error uploading image:", uploadError);
setIsSubmitting(false);
return;
}
const { data: urlData } = supabase.storage
.from("note-images")
.getPublicUrl(filePath);
imageUrl = urlData.publicUrl;
}
const { error } = await supabase.from("notes").insert({
user_id: userId,
title: title.trim(),
content: content.trim() || null,
image_url: imageUrl,
});
if (error) {
console.error("Error creating note:", error);
} else {
setTitle("");
setContent("");
setImageFile(null);
}
setIsSubmitting(false);
}
return (
<form onSubmit={handleSubmit} className="mb-8 p-4 border rounded-lg bg-gray-50">
<h2 className="text-lg font-semibold mb-4">Add a New Note</h2>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title..."
className="w-full p-2 mb-3 border rounded"
required
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Note content (optional)..."
className="w-full p-2 mb-3 border rounded h-24 resize-none"
/>
<input
type="file"
accept="image/*"
onChange={(e) => setImageFile(e.target.files?.[0] ?? null)}
className="mb-3 block"
/>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isSubmitting ? "Adding..." : "Add Note"}
</button>
</form>
);
}
What does this component do?
- The "use client" directive marks this as a client component so it can use React hooks like useState.
- It imports the browser Supabase client from @/lib/supabase/client (different from the server client used in page.tsx).
- On submit, it calls supabase.from('notes').insert() with the user's ID, title, content, and an optional image URL.
- The image upload logic uses Supabase Storage to save files in a note-images bucket. You will set up this bucket in Step 5. For now, the form works for text-only notes.
- Save components/NoteForm.tsx.
- Check your terminal. The NoteForm import error should now be resolved. You will still see an error about the missing NotesList component, which you will build in the next step.
Still seeing a NoteForm import error?
Make sure the file is saved as NoteForm.tsx (capital N, capital F) inside the components folder at the root of your project. The import path @/components/NoteForm is case-sensitive.
help me fix my NoteForm import error
Create the note card component
Each note in the list needs a visual card with a delete button. This component handles displaying note content and calling the Supabase delete API.
- In the components folder, create a new file called NoteCard.tsx and paste the following code:
"use client";
import { createClient } from "@/lib/supabase/client";
type Note = {
id: string;
title: string;
content: string | null;
image_url: string | null;
is_favorite: boolean;
created_at: string;
};
export default function NoteCard({
note,
onDelete,
}: {
note: Note;
onDelete: (id: string) => void;
}) {
const supabase = createClient();
async function handleDelete() {
const { error } = await supabase.from("notes").delete().eq("id", note.id);
if (error) {
console.error("Error deleting note:", error);
}
}
return (
<div className="p-4 border rounded-lg bg-white shadow-sm">
<div className="flex justify-between items-start mb-2">
<h3 className="text-lg font-semibold">{note.title}</h3>
<button
onClick={handleDelete}
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</div>
{note.content && <p className="text-gray-600 mb-3">{note.content}</p>}
{note.image_url && (
<img
src={note.image_url}
alt={`Attachment for ${note.title}`}
className="w-full max-h-64 object-cover rounded mb-3"
/>
)}
<p className="text-xs text-gray-400">
{new Date(note.created_at).toLocaleDateString()}
</p>
</div>
);
}
What does this component do?
- It defines a Note type matching the columns in your notes table.
- The handleDelete function calls supabase.from('notes').delete().eq('id', note.id) to remove the note. RLS ensures the user can only delete their own notes.
- It conditionally renders the note's content and image only if they exist.
- The onDelete prop will be used by the parent NotesList component (built in the next step) for optimistic UI updates.
- Save components/NoteCard.tsx.
- Check your VS Code Explorer sidebar. You should see NoteForm.tsx and NoteCard.tsx in the components folder, and page.tsx inside app/notes/.
The page will not render yet because the NotesList component is still missing. You will build it in the next step when you add real-time subscriptions.
Seeing a type error in NoteCard?
If TypeScript highlights an error on the Note type, check that every field name matches exactly: id, title, content, image_url, is_favorite, created_at. These must match the column names in your database.
help me fix a TypeScript error in NoteCard.tsx
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file created in this step.
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import NotesList from "@/components/NotesList";
import NoteForm from "@/components/NoteForm";
export default async function NotesPage() {
const supabase = await createClient();
const { data: claims, error: authError } = await supabase.auth.getClaims();
if (authError || !claims) {
redirect("/sign-in");
}
const userId = claims.sub;
const { data: notes, error } = await supabase
.from("notes")
.select()
.order("created_at", { ascending: false });
if (error) {
console.error("Error fetching notes:", error);
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">My Notes</h1>
<form action="/auth/signout" method="post">
<button
type="submit"
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Sign Out
</button>
</form>
</div>
<NoteForm userId={userId} />
<NotesList initialNotes={notes ?? []} userId={userId} />
</div>
);
}
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function POST() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/sign-in");
}
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export default function NoteForm({ userId }: { userId: string }) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const supabase = createClient();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
setIsSubmitting(true);
let imageUrl: string | null = null;
if (imageFile) {
const fileExt = imageFile.name.split(".").pop();
const filePath = `${userId}/${Date.now()}.${fileExt}`;
const { error: uploadError } = await supabase.storage
.from("note-images")
.upload(filePath, imageFile);
if (uploadError) {
console.error("Error uploading image:", uploadError);
setIsSubmitting(false);
return;
}
const { data: urlData } = supabase.storage
.from("note-images")
.getPublicUrl(filePath);
imageUrl = urlData.publicUrl;
}
const { error } = await supabase.from("notes").insert({
user_id: userId,
title: title.trim(),
content: content.trim() || null,
image_url: imageUrl,
});
if (error) {
console.error("Error creating note:", error);
} else {
setTitle("");
setContent("");
setImageFile(null);
}
setIsSubmitting(false);
}
return (
<form onSubmit={handleSubmit} className="mb-8 p-4 border rounded-lg bg-gray-50">
<h2 className="text-lg font-semibold mb-4">Add a New Note</h2>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title..."
className="w-full p-2 mb-3 border rounded"
required
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Note content (optional)..."
className="w-full p-2 mb-3 border rounded h-24 resize-none"
/>
<input
type="file"
accept="image/*"
onChange={(e) => setImageFile(e.target.files?.[0] ?? null)}
className="mb-3 block"
/>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isSubmitting ? "Adding..." : "Add Note"}
</button>
</form>
);
}
"use client";
import { createClient } from "@/lib/supabase/client";
type Note = {
id: string;
title: string;
content: string | null;
image_url: string | null;
is_favorite: boolean;
created_at: string;
};
export default function NoteCard({
note,
onDelete,
}: {
note: Note;
onDelete: (id: string) => void;
}) {
const supabase = createClient();
async function handleDelete() {
const { error } = await supabase.from("notes").delete().eq("id", note.id);
if (error) {
console.error("Error deleting note:", error);
}
}
return (
<div className="p-4 border rounded-lg bg-white shadow-sm">
<div className="flex justify-between items-start mb-2">
<h3 className="text-lg font-semibold">{note.title}</h3>
<button
onClick={handleDelete}
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</div>
{note.content && <p className="text-gray-600 mb-3">{note.content}</p>}
{note.image_url && (
<img
src={note.image_url}
alt={`Attachment for ${note.title}`}
className="w-full max-h-64 object-cover rounded mb-3"
/>
)}
<p className="text-xs text-gray-400">
{new Date(note.created_at).toLocaleDateString()}
</p>
</div>
);
}
Your dashboard components are ready. Next up, you will build the NotesList component with real-time subscriptions so notes sync instantly across browser tabs.
Add Real-Time Subscriptions
Your notes app can create and delete notes, but there is a problem. If you open the app in two browser tabs, changes in one tab do not appear in the other until you manually refresh the page.
In this step, you will create a NotesList client component that subscribes to Supabase Realtime. This subscription listens for database changes and updates React state instantly, so notes sync across tabs without a page refresh.
In this step, get ready to:
- Create a real-time notes list component that subscribes to database changes.
- Handle INSERT, UPDATE, and DELETE events to keep the UI in sync.
- Test real-time sync by verifying notes appear instantly across browser tabs.
Create the NotesList component with a real-time subscription
Supabase Realtime uses a feature called Postgres Changes to stream INSERT, UPDATE, and DELETE events from your database to connected clients over a WebSocket. Your notes table is already added to the supabase_realtime publication, so the backend is ready.
- Create a new file called NotesList.tsx inside your components folder.
- Paste the following code into components/NotesList.tsx:
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import NoteCard from "./NoteCard";
type Note = {
id: string;
user_id: string;
title: string;
content: string | null;
image_url: string | null;
is_favorite: boolean;
created_at: string;
};
export default function NotesList({
initialNotes,
userId,
}: {
initialNotes: Note[];
userId: string;
}) {
const [notes, setNotes] = useState<Note[]>(initialNotes);
const supabase = createClient();
useEffect(() => {
// Open a real-time channel filtered to this user's notes
const channel = supabase
.channel("notes-realtime")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "notes",
filter: `user_id=eq.${userId}`,
},
(payload) => {
if (payload.eventType === "INSERT") {
setNotes((current) => [payload.new as Note, ...current]);
} else if (payload.eventType === "DELETE") {
setNotes((current) =>
current.filter((note) => note.id !== payload.old.id)
);
} else if (payload.eventType === "UPDATE") {
setNotes((current) =>
current.map((note) =>
note.id === (payload.new as Note).id
? (payload.new as Note)
: note
)
);
}
}
)
.subscribe();
// Clean up the channel when the component unmounts
return () => {
supabase.removeChannel(channel);
};
}, [supabase, userId]);
function handleDelete(id: string) {
setNotes((current) => current.filter((note) => note.id !== id));
}
return (
<div className="grid gap-4 md:grid-cols-2">
{notes.length === 0 ? (
<p className="text-gray-500 col-span-2 text-center py-8">
No notes yet. Create your first note above!
</p>
) : (
notes.map((note) => (
<NoteCard key={note.id} note={note} onDelete={handleDelete} />
))
)}
</div>
);
}
What does this code do?
This component receives initialNotes from the server (fetched in page.tsx) and stores them in React state. On mount, it opens a Supabase Realtime channel.
- The .channel("notes-realtime") call creates a named WebSocket channel. The name can be any string except realtime.
- The .on("postgres_changes", {...}) call subscribes to database change events on the notes table, filtered to only this user's rows.
- The filter: `user_id=eq.${userId}` ensures you only receive events for your own notes. Combined with Row Level Security, this provides double protection.
- The return () => supabase.removeChannel(channel) cleanup runs when the component unmounts, preventing memory leaks.
Understand how each event type updates state
The subscription callback receives a payload object with an eventType property that tells you what happened in the database. Each event type maps to a specific state update.
How do the three event types work?
- INSERT: A new note was created. The code prepends payload.new to the front of the array so the newest note appears first.
- DELETE: A note was removed. The code filters it out of the array using payload.old.id.
- UPDATE: A note was edited. The code maps over the array and replaces the matching note with the updated version from payload.new.
Because the subscription uses event: "*", all three event types arrive through a single callback. This keeps the code simple while handling every possible change.
Why Postgres Changes instead of Broadcast?
Supabase offers two real-time methods. Broadcast is more scalable and recommended for production apps with many concurrent users. Postgres Changes is simpler to set up because it requires no triggers or authorization policies on realtime.messages.
For a learning project with a single user, Postgres Changes is the perfect fit. It works out of the box with your existing RLS policies.
Test real-time sync across browser tabs
The subscription is live. Time to prove it works across multiple tabs.
- Save components/NotesList.tsx.
- Return to your browser at http://localhost:3000/notes.
- Open a second browser tab and navigate to http://localhost:3000/notes.
- In the first tab, create a new note using the form.
- Switch to the second tab. The new note should appear instantly without a page refresh.
- Delete the note from the second tab. Switch back to the first tab and confirm it disappears immediately.
Notes not syncing across tabs?
- Make sure you saved NotesList.tsx and that the dev server reloaded. Check the terminal for compilation errors.
- Confirm you are signed in with the same account in both tabs. The subscription filters by user_id, so a different user would see different notes.
- Verify that your notes table was added to the supabase_realtime publication in Step 2. Without this, Supabase will not stream changes.
Still stuck? Why aren't my real-time subscriptions receiving events in my Next.js Supabase app?
✔️ Awesome, I've got everything!
Great. Double check you have saved components/NotesList.tsx and that notes sync between your two browser tabs.
ⓧ I'd like to double check the full code
Here is the complete components/NotesList.tsx file:
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import NoteCard from "./NoteCard";
type Note = {
id: string;
user_id: string;
title: string;
content: string | null;
image_url: string | null;
is_favorite: boolean;
created_at: string;
};
export default function NotesList({
initialNotes,
userId,
}: {
initialNotes: Note[];
userId: string;
}) {
const [notes, setNotes] = useState<Note[]>(initialNotes);
const supabase = createClient();
useEffect(() => {
const channel = supabase
.channel("notes-realtime")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "notes",
filter: `user_id=eq.${userId}`,
},
(payload) => {
if (payload.eventType === "INSERT") {
setNotes((current) => [payload.new as Note, ...current]);
} else if (payload.eventType === "DELETE") {
setNotes((current) =>
current.filter((note) => note.id !== payload.old.id)
);
} else if (payload.eventType === "UPDATE") {
setNotes((current) =>
current.map((note) =>
note.id === (payload.new as Note).id
? (payload.new as Note)
: note
)
);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase, userId]);
function handleDelete(id: string) {
setNotes((current) => current.filter((note) => note.id !== id));
}
return (
<div className="grid gap-4 md:grid-cols-2">
{notes.length === 0 ? (
<p className="text-gray-500 col-span-2 text-center py-8">
No notes yet. Create your first note above!
</p>
) : (
notes.map((note) => (
<NoteCard key={note.id} note={note} onDelete={handleDelete} />
))
)}
</div>
);
}
Your notes now sync in real-time across every open tab. Next up, you will add image attachments using Supabase Storage so notes can include photos alongside text.
Set Up Supabase Storage for Image Attachments
Your notes app handles text perfectly, but sometimes a picture says it best. Supabase Storage lets you store files alongside your database, with the same Row Level Security model you already set up for the notes table.
Your code already references a storage bucket called note-images, but that bucket does not exist yet in Supabase. In this step, you will create the bucket, add access policies, and then verify that image uploads work end-to-end.
In this step, get ready to:
- Create a note-images storage bucket in the Supabase Dashboard.
- Add access policies so users can upload to and read from their own folder.
- Test creating a note with an image attachment and see it displayed inline.
Create the note-images storage bucket
Supabase Storage organizes files into buckets. Each bucket can have its own access rules, similar to how tables have RLS policies.
- Switch back to the Supabase Dashboard in your browser.
- Click Storage in the left sidebar.
- Click New Bucket.
- Enter note-images as the bucket name.
- Toggle Public bucket to ON.
- Click Create bucket.
Why a public bucket?
A public bucket means anyone with the file URL can view the image. This simplifies image display because your app can use getPublicUrl() without needing signed URLs or extra authentication headers.
Access policies still control who can upload files. The bucket being public only affects read access to the stored files.
You should now see note-images listed in the Storage section of your Dashboard.
Add access policies for uploads and reads
By default, Supabase Storage does not allow any uploads without RLS policies. You need to create policies that let authenticated users upload files to their own folder and read their own files.
- In the Supabase Dashboard, click Policies in the Storage sidebar.
- Find the note-images bucket in the list and click Add Policy under the OBJECTS section.
- Select Custom to write the policy using SQL.
- Create the INSERT policy by running this SQL in the policy editor:
create policy "Authenticated users can upload to own folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'note-images' and
(storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
What does this policy do?
- bucket_id = 'note-images' restricts this policy to only the note-images bucket.
- (storage.foldername(name))[1] extracts the first folder in the file path.
- auth.jwt()->>'sub' is the authenticated user's ID from their JWT token.
- Together, they ensure users can only upload to a folder named with their own user ID.
- Create the SELECT policy so users can read their own uploaded files:
create policy "Authenticated users can read own files"
on storage.objects
for select
to authenticated
using (
bucket_id = 'note-images' and
(storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
- Confirm both policies appear in the Policies list for the note-images bucket.
Policy not saving?
Make sure you selected the correct bucket (note-images) before adding the policy. Double check that you chose OBJECTS (not BUCKETS) as the target table.
If you see a syntax error, confirm you copied the entire SQL statement including the closing semicolon. Help me troubleshoot my storage policy
Review the upload and display code
Your NoteForm.tsx already contains the logic to upload images to the note-images bucket. Now that the bucket exists, this code will work.
- Open components/NoteForm.tsx in VS Code and find the if (imageFile) block inside handleSubmit.
if (imageFile) {
const fileExt = imageFile.name.split(".").pop();
const filePath = `${userId}/${Date.now()}.${fileExt}`;
const { error: uploadError } = await supabase.storage
.from("note-images")
.upload(filePath, imageFile);
if (uploadError) {
console.error("Error uploading image:", uploadError);
setIsSubmitting(false);
return;
}
const { data: urlData } = supabase.storage
.from("note-images")
.getPublicUrl(filePath);
imageUrl = urlData.publicUrl;
}
How the upload flow works
- The file path uses the pattern {userId}/{timestamp}.{extension} so each user's files live in their own folder.
- supabase.storage.from("note-images").upload() sends the file to your new bucket.
- After a successful upload, getPublicUrl() generates the CDN URL for the file.
- That URL is stored in the note's image_url column when the note is inserted.
- Now open components/NoteCard.tsx and find the image display section near the bottom of the return statement.
{note.image_url && (
<img
src={note.image_url}
alt={`Attachment for ${note.title}`}
className="w-full max-h-64 object-cover rounded mb-3"
/>
)}
How the display works
When a note has an image_url value, NoteCard renders an <img> tag pointing at the public CDN URL. The max-h-64 object-cover classes keep images at a reasonable height while filling the card width.
- Return to your browser at http://localhost:3000/notes.
- In the Add a New Note form, enter a title like Test Image Note.
- Click the file input and select any image from your computer.
- Click Add Note.
After a moment, a new note card should appear with your image displayed below the title.
Image not appearing in the note card?
- Open the browser console (F12 or Cmd+Option+I) and look for an error message mentioning storage or upload.
- Confirm the note-images bucket is set to Public in the Dashboard. A private bucket would require signed URLs instead of getPublicUrl().
- Verify both the INSERT and SELECT policies are listed under your bucket's policies.
- Help me debug why my image upload is failing
Your notes app now supports image attachments backed by Supabase Storage. Next up, you will wire everything together for a final end-to-end test and add a sign-out button to complete the user experience.
Wire Everything Together and Test
You have authentication, a database with Row Level Security, real-time subscriptions, and image storage all wired into your Next.js app. Each piece works individually, but you need to confirm they work together as a cohesive experience.
In this step, you will run through the entire app flow from sign-up to sign-out. You will also verify that your security rules actually prevent users from seeing each other's data.
In this step, get ready to:
- Test the sign-out button and verify it redirects correctly.
- Test the full notes flow including real-time sync across two browser tabs.
- Verify security by confirming RLS prevents cross-user data access.
Test the sign-out flow
Your notes page already has a Sign Out button in the top-right corner. When clicked, it submits a form to your app/auth/signout/route.ts route handler, which signs the user out and redirects to the sign-in page.
- Return to your browser at http://localhost:3000.
- Sign in with the test account you created in Step 1.
- Navigate to http://localhost:3000/notes in your browser's address bar.
- Click the red Sign Out button in the top-right corner of the page.
You should be redirected to the sign-in page. This confirms the route handler is calling supabase.auth.signOut() and redirecting to /sign-in.
How does sign-out work here?
The sign-out button is a plain HTML form that POSTs to /auth/signout. Using a form instead of a client-side fetch means sign-out works even if JavaScript fails to load.
The route handler at app/auth/signout/route.ts creates a server-side Supabase client, calls signOut() to clear the session cookie, and redirects the user.
Sign-out not working?
Make sure your dev server is still running. If you stopped it, run npm run dev again from your project folder.
Check that app/auth/signout/route.ts exists and exports a POST function. Next.js route handlers must export named HTTP method functions.
Help me debug my sign-out route
Test the full notes flow with real-time sync
Now you will test every feature working together: creating notes, attaching images, and watching real-time sync happen across two browser tabs.
- Sign back in with your test account.
- Navigate to http://localhost:3000/notes.
- In the Add a New Note form, type My first note as the title and Testing the app as the content.
- Click Add Note without attaching an image.
The note card should appear immediately in the grid below the form. The form fields should clear, ready for another note.
- Create a second note with a title like Note with image.
- Click Choose File (or Browse) and select any image from your computer.
- Click Add Note.
The new note card should appear with the image displayed below the content text.
- Open a second browser tab and navigate to http://localhost:3000/notes.
- Confirm that the second tab shows both notes you just created.
- Switch back to your first tab and create another note with any title.
- Switch to the second tab without refreshing the page.
The new note should already be visible in the second tab. This is your real-time subscription at work. No page refresh needed.
- In the first tab, click Delete on any note card.
- Switch to the second tab and confirm the deleted note has disappeared from there too.
Why does real-time sync work across tabs?
Each tab creates its own Supabase real-time channel. When one tab inserts or deletes a note, Supabase's server broadcasts the change to all active channels subscribing to that table. Each tab receives the event independently and updates its local React state.
Notes not syncing between tabs?
Make sure you enabled Realtime for the notes table in Step 2 by running alter publication supabase_realtime add table notes; in the SQL Editor.
Check your browser's developer console for WebSocket errors. If you see connection failures, verify your NEXT_PUBLIC_SUPABASE_URL value in .env.local is correct.
Help me debug real-time sync
Verify authentication and Row Level Security
Your app protects the /notes page with an auth check on the server. Row Level Security in the database adds a second layer of protection. Even if someone bypassed the page redirect, the database itself refuses to return another user's rows.
- Open a new incognito or private browser window.
- Navigate to http://localhost:3000/notes.
You should be redirected to the sign-in page. This confirms your server component's getClaims() check is blocking unauthenticated access.
- In the incognito window, click Sign Up and create a second account with a different email address.
- After signing in with the second account, navigate to http://localhost:3000/notes.
The notes page should be empty for this new user. None of the notes from your first account appear here.
- Create a note using the second account to confirm it can write to the database.
- Switch back to your original browser window (where account 1 is signed in) and confirm that account 2's note does NOT appear.
Each user sees only their own notes. This is Row Level Security doing its job at the database level.
Why is RLS better than just filtering in your app code?
A filter in your app code (like .eq('user_id', userId)) can be accidentally removed or bypassed. RLS policies live in the database itself. Even if someone accesses the Supabase API directly, the database refuses to return rows the user does not own.
Seeing another user's notes?
Verify that Row Level Security is enabled on the notes table. In the Supabase Dashboard, go to Table Editor, select the notes table, and check that RLS is shown as enabled.
If RLS is off, return to the SQL Editor and run alter table notes enable row level security; followed by the four policy creation statements from Step 2.
Help me fix my RLS policies
Your notes app is fully functional. You have authentication, a secure database, real-time sync, and file storage all working together without a single line of custom backend code. Next up, you will tackle a secret mission to add favorites with real-time sync.
Secret mission
Add Favorites with Real-Time Sync
Your notes app handles CRUD and real-time sync. But what about letting users highlight their most important notes? In this secret mission, you'll add a favorite toggle that syncs across tabs in real-time, plus a filter to show only starred notes.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Supabase Free Plan, so there are no ongoing costs while your project stays within the free tier limits.
Resources you used:
- Supabase project (includes the notes table, note-images storage bucket, and authenticated users).
- Local Next.js project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your notes app.
- Your Supabase project stays active and accessible from the Dashboard.
- Your local project folder remains on your Desktop. Run npm run dev any time to start the app again.
- The Free Plan allows 2 active projects. Keep this in mind if you start new Supabase projects later.
Pause - I'll come back to this later
Pause your Supabase project to free up one of your 2 active project slots on the Free Plan. Your data is preserved and you can restore it later.
- Open the Supabase Dashboard and select your project.
- Click Project Settings in the sidebar, then select General.
- Scroll to the bottom of the page and click Pause project.
- Stop the local dev server in your terminal by pressing Ctrl+C.
- Your local project folder stays on your Desktop. When you are ready to resume, restore the project from the Dashboard and run npm run dev again.
Delete - I don't want to use this again
Remove all project resources permanently. This cannot be undone.
- Stop the local dev server in your terminal by pressing Ctrl+C if it is still running.
- Open the Supabase Dashboard and select your project.
- Click Project Settings in the sidebar, then select General.
- Scroll to the bottom of the page and click Delete project. Follow the confirmation prompts. This permanently removes the database, storage bucket, and all associated data.
- Delete your local project folder by running this command on macOS or Linux:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]
- On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your-project-folder-name"]]
Nice Work!
Nice Work!
You just built a full-stack notes app using all four Supabase pillars without writing a single line of backend code. Authentication, database, storage, and real-time sync are all handled by Supabase and wired into your Next.js app.
What you learned:
- Set up cookie-based SSR authentication with the @supabase/ssr package and Next.js middleware, protecting pages with getClaims().
- Created a Postgres database with Row Level Security policies so each user can only access their own notes.
- Added real-time subscriptions using Postgres Changes and Supabase Storage for image attachments, making notes sync instantly across browser tabs.
- Added real-time favorites with a star toggle, highlighted styling, and a filter to show only favorited notes across tabs.
Ready to quiz yourself?