Build a Real-Time Link Board

Build a collaborative link board with Supabase auth, RLS, and live updates.

Introduction

30 Second Summary

You share links all the time. Articles with teammates, tutorials with friends, resources with study groups. But those links end up scattered across chat apps and emails, impossible to find later.

In this project, you will build a collaborative link board where users sign up, save links with titles, and watch new links from other users appear live without refreshing the page.

What You'll Build

By the end of this project, you will have a multi-user link board running in your browser where you add a link in one tab and it pops up on another user's screen within seconds.

By the end of this project, you'll have:

  • A React app connected to a cloud Postgres database via Supabase, where you can sign up, sign in, and save links to a shared board.
  • Row Level Security policies that protect your data at the database level, so users can only modify their own links while everyone browses the shared feed.
  • Real-time subscriptions that push new links to every connected user instantly, with no page refresh needed.
  • Secret Mission: Add a public/private toggle so users can choose which links appear on the shared board and which stay private.

Are there any prerequisites?

No prior experience with Supabase is needed. This guide includes setup for all required tools. You just need Node.js installed and a code editor ready to go.

Before We Start

Before We Start...

You are about to build a collaborative link board that layers in Supabase features one at a time. Before you dive in, take a moment to commit to what you are building and why it matters to you.

Set Up Supabase and the React App

Before you can build a collaborative link board, you need two things wired together: a Supabase project running in the cloud and a local React app that can talk to it.

Supabase gives you a Postgres database, authentication, and real-time features out of the box. In this step, you will create a free Supabase project, scaffold a React app with Vite, and connect the two with a client file.

In this step, get ready to:
  • Create a Supabase account and new project.
  • Scaffold a React TypeScript app and install the Supabase client library.
  • Create a client file that connects your app to Supabase.
Create a Supabase account and project

Supabase is a cloud platform that wraps a full Postgres database with authentication, storage, and APIs. You need to create a project there so your React app has a backend to talk to.

  • Open supabase.com/dashboard in your browser.
  • Sign up with your GitHub account or an email address.
  • Once you are on the dashboard, click New Project.
  • Enter link-board as the project name.
  • Set a database password and save it somewhere safe (you will not need it again in this project, but keep it for future reference).
  • Choose the region closest to your location.
  • Click Create new project.

Provisioning takes about 1-2 minutes. Wait until the project status shows as active.

  • Once the project is ready, click Project Settings in the left sidebar.
  • Click API.
  • Copy the Project URL and paste it into a temporary note.
  • Copy the anon public key and paste it into the same note.

What is the anon key?

The anon key identifies your project to the Supabase API. It is safe to use in frontend code because Row Level Security policies control what each user can actually access.

You will set up those security policies in a later step. For now, just copy both values.

Scaffold the React app and install Supabase

Now you need a local React app that will talk to your Supabase project. You will use Vite to scaffold a TypeScript React project in seconds.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your React project by running this command:
npm create vite@latest link-board -- --template react-ts

What does this command do?

This creates a new folder called link-board on your Desktop with a ready-to-run React + TypeScript project inside. The --template react-ts flag tells Vite to use the React TypeScript template so you skip all the manual configuration.

  • Move into the new project folder and install its dependencies by running these commands:
cd link-board
npm install

You should see output ending with a line like "added X packages" confirming the install succeeded.

  • Install the Supabase client library by running:
npm install @supabase/supabase-js

Seeing permission errors?

On macOS, try prefixing the command with sudo. On Windows, close PowerShell and reopen it with "Run as Administrator".

help me fix npm permission errors

  • Create the environment file for your Supabase credentials. In your code editor, create a new file called .env.local in the root of the link-board folder.
  • Paste the following into .env.local, replacing the placeholder values with the Project URL and anon key you copied earlier:
VITE_SUPABASE_URL=your-project-url-here
VITE_SUPABASE_ANON_KEY=your-anon-key-here

Why .env.local?

Vite automatically loads variables from .env.local and exposes any variable prefixed with VITE_ to your app code. This keeps your configuration out of your source files.

  • Save .env.local.
  • Confirm your project files are in place by running:
ls

You should see .env.local listed alongside package.json, src, and other project files.

Create the Supabase client file

Your app needs a single shared client that handles all communication with Supabase. You will create one file that initializes this client using your environment variables.

  • In your code editor, create a new file at src/supabaseClient.ts inside the link-board folder.
  • Add the following code to src/supabaseClient.ts:
import { createClient } from '@supabase/supabase-js'

// Read the Supabase project URL from environment variables
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
// Read the anon public key from environment variables
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

// Create and export a single Supabase client for the whole app
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

What does this code do?

  • createClient is the Supabase SDK function that creates a connection to your project.
  • import.meta.env.VITE_SUPABASE_URL reads the project URL you stored in .env.local.
  • The exported supabase object is what every component in your app will import to query the database, manage auth, and subscribe to real-time changes.
  • Save src/supabaseClient.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved both .env.local and src/supabaseClient.ts.

ⓧ I'd like to double check the full code

Here is .env.local:

VITE_SUPABASE_URL=your-project-url-here
VITE_SUPABASE_ANON_KEY=your-anon-key-here

Here is src/supabaseClient.ts:

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)

Before you run the dev server, what do you think you will see in the browser? The Supabase client file is in place, but you have not changed any of the default Vite template UI yet.

  • Start the development server by running:
npm run dev

What does npm run dev do?

This starts a local development server that serves your React app. It takes over your terminal while running. You will need to open a new terminal tab or window for future commands.

  • Open http://localhost:5173 in your browser.

You should see the default Vite + React starter page with a counter button and the Vite and React logos. This confirms your project is running and the Supabase client is silently loaded in the background.

Not seeing the page?

  • Make sure you ran npm run dev from inside the link-board folder, not the Desktop.
  • Check your terminal output for errors. If you see a message about a missing module, run npm install again.
  • Verify that your .env.local file is in the root of link-board (next to package.json), not inside the src folder.

help me debug my Vite dev server not starting

Your environment is ready. You have a Supabase project in the cloud and a local React app that knows how to connect to it. Next up, you will create a database table and build the first version of your link board.

Display and Add Links

Your Supabase project is provisioned and your React app is connected. Now you need to see data actually flowing between your browser and the cloud database.

In this step you will create a Postgres table in Supabase, build a React component that reads and writes to it, and confirm the round trip works end-to-end.

In this step, get ready to:
  • Create a links table in the Supabase SQL Editor.
  • Build a LinkBoard component that fetches and inserts rows.
  • Wire the component into your app and confirm a link appears on the page and in the database.
Create the links table in the Supabase SQL Editor

Your React app needs somewhere to store links. You will create a table directly in your Supabase project's Postgres database using SQL.

  • Switch back to the Supabase dashboard in your browser.
  • Click SQL Editor in the left sidebar.
  • Click New Query to open a blank editor tab.
  • Paste the following SQL and click Run (or press Cmd+Enter on macOS, Ctrl+Enter on Windows):
CREATE TABLE links (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  created_at timestamptz DEFAULT now(),
  title text NOT NULL,
  url text NOT NULL,
  user_id uuid
);

ALTER TABLE links DISABLE ROW LEVEL SECURITY;

What does this SQL do?

The CREATE TABLE statement builds a table with five columns. id auto-increments so each link gets a unique number. created_at records when the link was saved. title and url hold the link data. user_id is nullable for now because you have not added authentication yet.

The ALTER TABLE line disables Row Level Security. Supabase enables RLS by default on new tables, which blocks all requests that lack a policy. Disabling it lets your app read and write freely during development.

  • After the query succeeds, click Table Editor in the left sidebar.
  • You should see the links table listed. Click it to confirm the columns id, created_at, title, url, and user_id appear.

Don't see the links table?

Make sure you clicked Run and saw a green success message in the SQL Editor. If you see an error, check for typos in the column definitions.

help me troubleshoot why my CREATE TABLE SQL did not work in Supabase

Build the LinkBoard component

With the table ready in the cloud, you need a React component that talks to it. This component will fetch all links on mount, display them as clickable anchors, and include a form to add new ones.

  • In your code editor, create a new folder src/components inside your link-board project.
  • Create a new file src/components/LinkBoard.tsx and add the component setup and data-fetching logic:
import { useEffect, useState } from 'react'
import { Session } from '@supabase/supabase-js'
import { supabase } from '../supabaseClient'

interface Link {
  id: number
  created_at: string
  title: string
  url: string
  user_id: string
}

interface LinkBoardProps {
  session: Session
}

export default function LinkBoard({ session }: LinkBoardProps) {
  const [links, setLinks] = useState<Link[]>([])
  const [title, setTitle] = useState('')
  const [url, setUrl] = useState('')
  const [loading, setLoading] = useState(true)

What does this code set up?

The Link interface describes the shape of a row from your links table. LinkBoardProps accepts a session (used for user identity later). The four useState hooks track the link list, form inputs, and a loading flag.

  • Below the state declarations, add the data-fetching effect and the handler functions:
  useEffect(() => {
    fetchLinks()
  }, [])

  async function fetchLinks() {
    setLoading(true)
    const { data, error } = await supabase
      .from('links')
      .select('*')
      .order('created_at', { ascending: false })
    if (error) {
      console.error('Error fetching links:', error)
    } else {
      setLinks(data || [])
    }
    setLoading(false)
  }

  async function addLink(e: React.FormEvent) {
    e.preventDefault()
    if (!title.trim() || !url.trim()) return
    const { error } = await supabase
      .from('links')
      .insert([{ title, url, user_id: null }])
    if (error) {
      console.error('Error adding link:', error)
    } else {
      setTitle('')
      setUrl('')
      fetchLinks()
    }
  }

What do these functions do?

The useEffect calls fetchLinks once when the component mounts. fetchLinks uses the Supabase client to query all rows from the links table, sorted newest-first.

The addLink function inserts a new row with user_id set to null (no auth yet). After a successful insert, it clears the form and re-fetches the list so the new link appears immediately.

  • Below addLink, add the delete function and the component's JSX return to complete the file:
  async function deleteLink(id: number) {
    const { error } = await supabase.from('links').delete().eq('id', id)
    if (error) {
      console.error('Error deleting link:', error)
    } else {
      fetchLinks()
    }
  }

  if (loading) return <p>Loading links...</p>

  return (
    <div className="link-board">
      <form onSubmit={addLink} className="add-link-form">
        <input type="text" placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} required />
        <input type="url" placeholder="https://example.com" value={url} onChange={(e) => setUrl(e.target.value)} required />
        <button type="submit">Add Link</button>
      </form>
      <ul className="links-list">
        {links.map((link) => (
          <li key={link.id}>
            <a href={link.url} target="_blank" rel="noopener noreferrer">{link.title}</a>
            <button className="delete-button" onClick={() => deleteLink(link.id)}>Delete</button>
          </li>
        ))}
      </ul>
      {links.length === 0 && <p>No links yet. Add your first one!</p>}
    </div>
  )
}

What does the JSX render?

A form with two inputs (title and URL) and a submit button. Below it, a list of links where each item is a clickable anchor that opens in a new tab. Each link also has a Delete button. When the list is empty, a message prompts the user to add their first link.

  • Save src/components/LinkBoard.tsx.
Wire it into App.tsx

The component exists but the app does not render it yet. You need to update App.tsx to import and display LinkBoard, then add styles so the page looks clean.

  • Open src/App.tsx and replace its entire contents with:
import LinkBoard from './components/LinkBoard'
import './App.css'

function App() {
  return (
    <div className="app">
      <header><h1>Link Board</h1></header>
      <main><LinkBoard session={null as any} /></main>
    </div>
  )
}

export default App

Why pass session as null?

You have not added authentication yet, so there is no real session to pass. Using null as any satisfies the TypeScript type for now. You will replace this with a real session object in the next step when you add login.

  • Save src/App.tsx.
  • Check your browser at http://localhost:5173. You should see the heading "Link Board" and the text "No links yet. Add your first one!" below a form.

Seeing a blank page or error?

Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). If you see an import error, make sure the file path in App.tsx matches ./components/LinkBoard exactly.

help me fix my LinkBoard import error in App.tsx

Now add the styles. Open src/App.css and replace its entire contents. Start with the layout styles:

.app {
  max-width: 700px;
  margin: 0 auto;
  padding: 2rem;
  font-family: system-ui, -apple-system, sans-serif;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 2rem;
  padding-bottom: 1rem;
  border-bottom: 1px solid #e2e8f0;
}

header h1 { margin: 0; }

.header-right {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.header-right span {
  color: #64748b;
  font-size: 0.875rem;
}

.auth-container {
  max-width: 400px;
  margin: 0 auto;
}
  • Below the layout styles, add the form and button styles:
form {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

input {
  padding: 0.5rem 0.75rem;
  border: 1px solid #e2e8f0;
  border-radius: 4px;
  font-size: 1rem;
}

button {
  padding: 0.5rem 1rem;
  background: #3b82f6;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

button:hover { background: #2563eb; }

.link-button {
  background: none;
  color: #3b82f6;
  padding: 0;
  text-decoration: underline;
}

.link-button:hover {
  background: none;
  color: #2563eb;
}
  • Finally, add the link list and utility styles at the bottom of the file:
.message {
  color: #64748b;
  font-size: 0.875rem;
}

.add-link-form { margin-bottom: 2rem; }

.links-list {
  list-style: none;
  padding: 0;
}

.links-list li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 0;
  border-bottom: 1px solid #f1f5f9;
}

.links-list a {
  color: #1e40af;
  text-decoration: none;
}

.links-list a:hover { text-decoration: underline; }

.delete-button {
  background: #ef4444;
  font-size: 0.75rem;
  padding: 0.25rem 0.5rem;
}

.delete-button:hover { background: #dc2626; }

What do these styles cover?

The first group centers the app at a max width of 700px with a bordered header. The second group styles the form inputs and buttons with a blue theme. The third group removes default list styling and lays out each link as a row with a red delete button on the right.

Some classes like .auth-container and .header-right are not visible yet. You will use them in the next step when you add authentication.

  • Save src/App.css.
  • Check your browser. The page should now show a clean centered layout with a "Link Board" heading, a form with styled inputs, and the "No links yet" message.

Before you test the form, what do you think will happen when you submit a link? Will it show up on the page, fail silently, or throw an error?

  • Type NextWork in the Title field and https://www.nextwork.org in the URL field.
  • Click Add Link.
  • The link should appear in the list below the form as a clickable blue title with a red Delete button beside it.
  • Switch back to the Supabase dashboard and open the Table Editor.
  • Click the links table. You should see the row you just added with its title, url, and a null value for user_id.

Link not appearing on the page?

Open the browser console and check for errors. If you see a message about failing to fetch, confirm your .env.local values are correct and that you restarted the dev server after creating that file.

If the row appears in the Supabase Table Editor but not on the page, the insert worked but the re-fetch may have failed. Check for typos in fetchLinks (especially the table name links).

help me debug why my link is not showing up after I submit the form

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/components/LinkBoard.tsx and src/App.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm each section is present in your files, in order:

src/components/LinkBoard.tsx should contain:

  • Imports from react, @supabase/supabase-js, and ../supabaseClient.
  • The Link and LinkBoardProps interfaces.
  • The LinkBoard function with four useState hooks.
  • A useEffect calling fetchLinks, then the fetchLinks, addLink, and deleteLink functions.
  • A JSX return with the form, links list, and empty-state message.

src/App.tsx should import LinkBoard and ./App.css, wrap the page in a div.app with a header and main element.

Data is flowing. You can save a link from your browser and watch it land in a cloud Postgres table. Next up, you will add user accounts so each link has an owner.

Add Authentication

Your link board works, but it has a major problem. Anyone with the API URL can post links with no identity attached.

There is no way to know who added what, and no way to restrict who can delete links. You need user accounts so each link has an owner.

In this step, get ready to:
  • Build a sign-up and sign-in form using Supabase Auth.
  • Add session management so the app knows who is logged in.
  • Associate each link with the user who created it.
Build the Auth component

Supabase provides a full authentication system out of the box. You just need a form that calls its sign-up and sign-in methods.

  • Create a new file at src/components/Auth.tsx and paste in the following code for the imports, state, and form handler:
import { useState } from 'react'
import { supabase } from '../supabaseClient'

export default function Auth() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [isSignUp, setIsSignUp] = useState(false)
  const [message, setMessage] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setMessage('')
    if (isSignUp) {
      const { error } = await supabase.auth.signUp({ email, password })
      if (error) { setMessage(error.message) } else { setMessage('Check your email for the confirmation link!') }
    } else {
      const { error } = await supabase.auth.signInWithPassword({ email, password })
      if (error) { setMessage(error.message) }
    }
  }

What does this code do?

  • isSignUp toggles the form between sign-up and sign-in mode.
  • handleSubmit calls supabase.auth.signUp or supabase.auth.signInWithPassword depending on the current mode.
  • If an error occurs, it displays the error message from Supabase. On successful sign-up, it tells the user to check their email.
  • Below the handleSubmit function (still inside the Auth component), add the return JSX:
  return (
    <div className="auth-container">
      <h2>{isSignUp ? 'Sign Up' : 'Sign In'}</h2>
      <form onSubmit={handleSubmit}>
        <input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required />
        <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
        <button type="submit">{isSignUp ? 'Sign Up' : 'Sign In'}</button>
      </form>
      {message && <p className="message">{message}</p>}
      <p>
        {isSignUp ? 'Already have an account?' : "Don't have an account?"}{' '}
        <button className="link-button" onClick={() => setIsSignUp(!isSignUp)}>{isSignUp ? 'Sign In' : 'Sign Up'}</button>
      </p>
    </div>
  )
}

What does the JSX render?

  • An email and password form with a submit button that changes label based on mode.
  • A message area that shows errors or the confirmation prompt.
  • A toggle link at the bottom so users can switch between sign-up and sign-in.
  • Save src/components/Auth.tsx.
  • Confirm the file appears in your editor's sidebar under src/components/.

Don't see the file in the sidebar?

  • Make sure you created it inside src/components/, not in the project root.
  • Check the filename is exactly Auth.tsx with a capital A.

Why isn't my Auth.tsx file showing up in the components folder?

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your src/components/Auth.tsx should contain both chunks above in order: the imports, state, and handleSubmit function first, then the return JSX below it. Scroll up and confirm each piece is present.

Add session management to App.tsx

The app needs to know whether someone is logged in. Supabase Auth provides methods to check the current session and listen for changes like sign-in or sign-out.

  • Open src/App.tsx and delete everything inside the file.
  • Paste the following imports and session logic:
import { useEffect, useState } from 'react'
import { Session } from '@supabase/supabase-js'
import { supabase } from './supabaseClient'
import Auth from './components/Auth'
import LinkBoard from './components/LinkBoard'
import './App.css'

function App() {
  const [session, setSession] = useState<Session | null>(null)

  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => { setSession(session) })
    const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { setSession(session) })
    return () => subscription.unsubscribe()
  }, [])

What does the session logic do?

  • getSession() checks if the user is already logged in when the page loads.
  • onAuthStateChange() listens for auth events (sign in, sign out) and updates state automatically.
  • The cleanup function subscription.unsubscribe() prevents memory leaks when the component unmounts.
  • Below the useEffect block (still inside the App function), add the return JSX and export:
  return (
    <div className="app">
      <header>
        <h1>Link Board</h1>
        {session && (
          <div className="header-right">
            <span>{session.user.email}</span>
            <button onClick={() => supabase.auth.signOut()}>Sign Out</button>
          </div>
        )}
      </header>
      <main>{!session ? <Auth /> : <LinkBoard session={session} />}</main>
    </div>
  )
}

export default App

What does the conditional rendering do?

  • When there is no session, the Auth component renders (the sign-in form).
  • When a session exists, LinkBoard renders and receives the session as a prop.
  • The header shows the user's email and a Sign Out button only when logged in.
  • Save src/App.tsx and switch to your browser.
  • The page should now show a sign-in form with email and password fields instead of the link board.

Still seeing the link board instead of the form?

  • Make sure you deleted the old App.tsx content entirely before pasting the new code.
  • Check the terminal for TypeScript errors. A missing import or typo will prevent the update.
  • Confirm Auth.tsx is saved in src/components/ with the correct filename.

Why is my page not updating after I saved App.tsx?

✔️ Awesome, I've got everything!

Great. Make sure src/App.tsx is saved before continuing.

ⓧ I'd like to double check the full code

Your src/App.tsx should contain both chunks above in order: the imports and session logic first, then the return JSX with conditional rendering and the export default App at the bottom. Scroll up and confirm each piece is present.

Associate links with their owner

The Auth form works, but links are still inserted with user_id: null. You need to stamp each link with the logged-in user's ID so you can control who can delete what.

  • In src/components/LinkBoard.tsx, scroll down to the addLink function.
  • Find this line inside the .insert() call:
      .insert([{ title, url, user_id: null }])
  • Replace user_id: null with user_id: session.user.id so the line looks like this:
      .insert([{ title, url, user_id: session.user.id }])
  • Next, find the delete button inside the .map() that renders each link. It currently looks like this:
            <button className="delete-button" onClick={() => deleteLink(link.id)}>Delete</button>
  • Wrap the delete button in a conditional so it only appears for links you own. Replace that line with:
            {link.user_id === session.user.id && (
              <button className="delete-button" onClick={() => deleteLink(link.id)}>Delete</button>
            )}

Why hide the delete button?

The condition link.user_id === session.user.id checks if the logged-in user owns the link. Only the owner sees the delete button. This is a UI-level guard. In the next step, you will add database-level security so even a crafted request cannot delete someone else's link.

  • Save src/components/LinkBoard.tsx.

Disable email confirmation for faster testing

By default, Supabase requires email confirmation before a new account can sign in. To skip this during development, go to your Supabase dashboard.

  • Navigate to Authentication in the left sidebar, then Providers, then Email.
  • Toggle off Confirm email and click Save.

This lets you sign up and immediately start using the app without checking your inbox.

Before you test, what do you think will happen when you sign up with an email and password?

  • Switch to your browser at http://localhost:5173.
  • Enter an email address and a password (at least 6 characters) in the sign-up form.
  • Click Sign Up.
  • If you disabled email confirmation, you will be redirected to the link board with your email displayed in the header.
  • Add a test link. You should see it appear in the list.
  • Click Sign Out in the header. You should return to the sign-in form.

Seeing an error after signing up?

  • If you see "Email not confirmed", you still have email confirmation enabled. Either disable it in the dashboard (see the tip above) or check your email for the confirmation link, then sign in.
  • If you see "User already registered", switch the form to Sign In mode and enter your credentials.
  • If the password field shows a validation error, make sure it is at least 6 characters.

Help me debug my Supabase authentication sign-up error.

Your app now has user accounts. Every link is stamped with its creator's ID, and only the owner sees the delete button. Next up, you will lock this down at the database level so even someone bypassing the UI cannot touch another user's data.

Secure Data with Row Level Security

Your link board has user accounts and the UI only shows delete buttons on your own links. But is the data actually protected at the database level?

Right now, the answer is no. Anyone who knows your project URL and anon key can query the database directly and get back every single link. Even worse, authenticated users could craft API calls to delete other people's links. The security is purely cosmetic. In this step, you will lock down the database using Row Level Security so that Postgres itself enforces who can read, insert, and delete rows.

In this step, get ready to:
  • Prove that the database is currently exposed without authentication.
  • Enable Row Level Security and create three policies (SELECT, INSERT, DELETE).
  • Verify that unauthenticated requests are blocked and users can only modify their own data.
Enable RLS and create policies

Before you fix the problem, see it for yourself. Your Supabase REST API is currently accessible to anyone with the anon key.

  • Open a new browser tab and paste this URL (replacing the values with your own Project URL and anon key from .env.local):
https://your-project-url-here/rest/v1/links?select=*&apikey=your-anon-key-here

Before you hit Enter, predict what you will see. Do you think an unauthenticated browser request will get back your links, or will it be blocked?

You see a JSON array containing every link in your database. No login. No token. Anyone with your project URL and anon key can read all your data. That is the gap you are about to close.

Why is the anon key not enough protection?

The anon key identifies your project to Supabase, but it does not grant any specific permissions on its own. It is designed to be safe to include in frontend code.

The actual access control comes from RLS policies. Without policies in place, the database defaults to allowing everything. With policies, the anon key still gets the request to Supabase, but Postgres decides what data to return based on the user's identity.

  • Return to the Supabase Dashboard and open the SQL Editor from the left sidebar.
  • Paste the following SQL to enable RLS and create three access policies:
-- Enable Row Level Security on the links table
ALTER TABLE links ENABLE ROW LEVEL SECURITY;

-- Policy 1: All authenticated users can read all links
CREATE POLICY "Allow authenticated users to read all links"
ON links FOR SELECT
TO authenticated
USING (true);

-- Policy 2: Users can only insert links with their own user_id
CREATE POLICY "Allow users to insert their own links"
ON links FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);

-- Policy 3: Users can only delete their own links
CREATE POLICY "Allow users to delete their own links"
ON links FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
  • Click Run (or press Ctrl+Enter / Cmd+Enter).

What do these policies do?

  • ALTER TABLE links ENABLE ROW LEVEL SECURITY turns on the RLS engine. Once enabled, all access is denied by default unless a policy explicitly allows it.
  • The SELECT policy uses USING (true) which means any authenticated user can read all rows. The shared feed stays visible to everyone.
  • The INSERT policy uses WITH CHECK (auth.uid() = user_id) which ensures the user_id column in new rows must match the logged-in user's ID. You cannot insert links pretending to be someone else.
  • The DELETE policy uses USING (auth.uid() = user_id) which means Postgres only lets you delete rows where you are the owner.

You should see a Success. No rows returned message in the SQL Editor results panel.

Seeing a "policy already exists" error?

If you ran the SQL more than once, Postgres will reject duplicate policy names. Drop the existing policies first by running:

DROP POLICY IF EXISTS "Allow authenticated users to read all links" ON links;
DROP POLICY IF EXISTS "Allow users to insert their own links" ON links;
DROP POLICY IF EXISTS "Allow users to delete their own links" ON links;

Then re-run the original SQL above.

help me fix my RLS policy error in Supabase

Test the policies

RLS is active. Time to confirm it actually blocks unauthorized access and still allows legitimate operations.

  • Return to the browser tab where you pasted the REST API URL earlier.
  • Refresh the page.

What do you expect to see now that RLS requires authentication?

The page now shows an empty array []. The same URL that returned all your data moments ago is now completely locked out. Unauthenticated requests see nothing.

  • Switch to your app at http://localhost:5173 and refresh the page.
  • Confirm your links still display (the authenticated session passes the SELECT policy).
  • Add a new link via the form and confirm it appears in the list.
  • Delete one of your own links and confirm it disappears.

The app works exactly as before. Authenticated users can read all links, insert their own, and delete their own.

Now verify that the DELETE policy truly blocks cross-user deletion. Open a second browser window (incognito) and sign up or sign in as a different user. Then test from that user's browser console.

  • In the incognito window (logged in as User B), open the browser DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
  • Find a link ID from User A. You can see it in the main browser window's list, or check the Supabase Table Editor.
  • Paste this code into User B's console, replacing the placeholder values:
// Get User B's access token from localStorage
const storageKey = Object.keys(localStorage).find(k => k.startsWith('sb-'))
const session = JSON.parse(localStorage.getItem(storageKey))
const token = session.access_token

// Attempt to delete User A's link
fetch('your-project-url-here/rest/v1/links?id=eq.LINK_ID', {
  method: 'DELETE',
  headers: {
    'apikey': 'your-anon-key-here',
    'Authorization': `Bearer ${token}`,
    'Prefer': 'return=representation'
  }
}).then(r => r.json()).then(d => console.log('Deleted rows:', d))

What should I see?

The console logs Deleted rows: []. An empty array means zero rows were affected. Postgres silently refused the delete because User B's auth.uid() does not match the link's user_id. The data is protected at the database level.

Getting a token parsing error in the console?

Make sure you are running the code in the incognito window where User B is signed in. The localStorage key only exists after a successful sign-in.

If storageKey is undefined, User B is not signed in. Sign in first, then re-run the snippet.

help me test my Supabase RLS delete policy from the browser console

Your database is locked down. Authenticated users can browse the shared feed, add their own links, and delete their own links. Nobody can tamper with data that does not belong to them. Next up, you will make the board come alive with real-time updates so new links appear instantly without refreshing.

Make It Real-Time

Your link board works, but it has a blind spot. After adding a link, you have to refresh the page to see links from other users.

Open two browser tabs with different user accounts and add a link in one. The other tab stays completely stale. Supabase Realtime solves this by pushing database changes to connected clients over WebSockets the instant they happen.

In this step, get ready to:
  • Enable Realtime on the links table so Supabase broadcasts changes.
  • Subscribe to live INSERT and DELETE events in the LinkBoard component.
  • Test that two users see each other's changes within seconds, without refreshing.

Before fixing anything, prove the problem exists.

  • Open your app at http://localhost:5173 in two separate browser tabs.
  • Sign in as one user in Tab A and a different user in Tab B.
  • In Tab A, add a new link using the form.
  • Look at Tab B without refreshing.

Do you expect the link to appear in Tab B automatically?

Tab B shows nothing new. The link only appears after a manual page refresh. That staleness is the problem you are about to fix.

Enable Realtime on the links table

Supabase does not broadcast changes for a table unless you explicitly opt it in. You need to add the links table to the supabase_realtime publication.

  • In the Supabase Dashboard, open the SQL Editor from the left sidebar.
  • Run this SQL to enable Realtime on your table:
ALTER PUBLICATION supabase_realtime ADD TABLE links;

What does this command do?

Supabase Realtime uses Postgres logical replication under the hood. This command adds the links table to the supabase_realtime publication, which tells Postgres to stream INSERT, UPDATE, and DELETE events for that table to Supabase's Realtime service.

Without this, your client-side subscription would connect but never receive any events.

  • Click Run. You should see a success message confirming the publication was updated.

Alternative via the Dashboard UI

You can also enable Realtime by navigating to Database then Publications in the sidebar, finding supabase_realtime, and toggling on the links table. Both approaches do the same thing.

Subscribe to changes in LinkBoard

Now that the database broadcasts changes, your React component needs to listen. You will replace the manual re-fetch pattern with a Realtime subscription that updates state the instant an event arrives.

  • In src/components/LinkBoard.tsx, find the useEffect block that currently looks like this:
  useEffect(() => {
    fetchLinks()
  }, [])
  • Replace the entire useEffect block with this version that adds a Realtime channel:
  useEffect(() => {
    fetchLinks()

    const channel = supabase
      .channel('links-changes')
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'links' },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setLinks((current) => [payload.new as Link, ...current])
          } else if (payload.eventType === 'DELETE') {
            setLinks((current) =>
              current.filter((link) => link.id !== payload.old.id)
            )
          }
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [])

What does this code do?

  • supabase.channel('links-changes') opens a WebSocket channel named links-changes.
  • .on('postgres_changes', ...) listens for all database events (INSERT, UPDATE, DELETE) on the links table.
  • On INSERT, the new row is prepended to the existing links array so it appears at the top.
  • On DELETE, the row is filtered out of state so it disappears instantly.
  • The cleanup function calls supabase.removeChannel(channel) when the component unmounts to prevent memory leaks.
  • Save LinkBoard.tsx.

Now you need to remove the manual fetchLinks() calls that fire after inserts and deletes. The Realtime subscription handles those updates now.

  • In the addLink function, find this line:
    if (error) { console.error('Error adding link:', error) } else { setTitle(''); setUrl(''); fetchLinks() }
  • Remove fetchLinks() from the else branch so the line looks like this:
    if (error) {
      console.error('Error adding link:', error)
    } else {
      setTitle('')
      setUrl('')
    }
  • In the deleteLink function, find this line:
    if (error) { console.error('Error deleting link:', error) } else { fetchLinks() }
  • Remove the entire else { fetchLinks() } branch so the function only logs errors:
  async function deleteLink(id: number) {
    const { error } = await supabase.from('links').delete().eq('id', id)
    if (error) {
      console.error('Error deleting link:', error)
    }
  }
  • Save LinkBoard.tsx.
  • Return to your browser tab and add a link using the form.

The link should still appear in your list after submitting. This confirms the Realtime subscription is receiving the INSERT event and updating state.

Link not appearing after submit?

  • Check that you ran the ALTER PUBLICATION SQL in the previous substep. Without it, the channel connects but never receives events.
  • Open your browser's DevTools console and look for WebSocket connection errors.
  • Make sure you removed fetchLinks() from addLink but did NOT remove the setTitle('') and setUrl('') lines.

Help me debug my Realtime subscription

✔️ Awesome, I've got everything!

Great. Double check you have saved LinkBoard.tsx with all three changes (new useEffect, updated addLink, updated deleteLink).

ⓧ I'd like to double check the full code

Your src/components/LinkBoard.tsx file is now quite long. Scroll up and confirm these sections are present in order:

  • The Link interface and LinkBoardProps interface at the top.
  • State declarations for links, title, url, and loading.
  • The useEffect that calls fetchLinks() then sets up the Realtime channel with supabase.channel('links-changes') and returns a cleanup that calls supabase.removeChannel(channel).
  • The fetchLinks function (unchanged from before).
  • The addLink function that clears the form inputs on success but does NOT call fetchLinks().
  • The deleteLink function that only logs errors and does NOT call fetchLinks().
  • The JSX return with the form, links list, and conditional delete buttons.
Test live updates across two users

The real test of Realtime is whether two separate users see each other's changes without refreshing.

  • Open your app in two browser tabs.
  • Sign in as User A in one tab and User B in the other tab.

Before you add a link, predict what will happen. Do you think the link will appear in both tabs instantly this time?

  • In Tab A, add a link using the form.
  • Watch Tab B without touching it.

The new link appears in Tab B within 1-2 seconds. No refresh needed.

  • In Tab B, click Delete on one of User B's links.
  • Watch Tab A. The link disappears from both tabs simultaneously.

Why is this so fast?

Supabase Realtime uses WebSockets to push events directly from the database to your browser. There is no polling and no repeated API calls. The moment Postgres commits the INSERT or DELETE, the Realtime service broadcasts the change to every connected client.

Tab B not updating?

  • Confirm both tabs are signed in. You need an active session for the Realtime connection to authenticate.
  • Check the browser console in Tab B for WebSocket errors.
  • Verify the ALTER PUBLICATION command succeeded by running SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime'; in the SQL Editor. You should see links in the results.

Help me troubleshoot Realtime sync

Your link board is now fully real-time. Two users on opposite sides of the world would see each other's links appear and disappear instantly. You built identity, security, and live sync into a collaborative app without writing a single line of backend code.

Secret mission

Add a Public/Private Toggle

Right now every link on the board is visible to every user. What if you could choose which links to share publicly and which to keep as private bookmarks only you can see? In this secret mission, you will modify the database schema, update your RLS policy with conditional logic, and add UI controls for toggling visibility and filtering links.

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 tier, so there are no ongoing costs as long as your project stays active.

Free projects pause after inactivity

Supabase pauses free projects after 1 week of inactivity. If you want to keep your link board available, interact with your project at least once a week. You can always unpause it from the Supabase dashboard later.

Resources you used:

  • Supabase cloud project (link-board with the links table, RLS policies, and Realtime enabled).
  • Local project folder (link-board on your Desktop with all source files and node_modules).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the link board soon.

  • Your Supabase project stays live and accessible.
  • Your local link-board folder remains on your Desktop.
  • Remember to interact with your Supabase project at least once a week to prevent it from auto-pausing.

Pause - I'll come back to this later

Shut down your local dev server to free up terminal resources. Your Supabase project will auto-pause after 1 week of inactivity.

  • In your terminal, press Ctrl+C to stop the Vite dev server.
  • Your local files remain on disk so you can resume later with npm run dev.
  • Your Supabase project will pause on its own after a week without requests. You can unpause it from the dashboard when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible for the cloud project.

  • Delete the Supabase project: in the Supabase dashboard, select your link-board project. Go to Settings, then General, then scroll down to Delete project. Click Delete Project, type the project name exactly as shown, and confirm.
  • Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf link-board
  • On Windows, run:
cd ~\Desktop
rmdir /s /q link-board
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. The link-board folder should no longer appear.

That's a Wrap!

That's a Wrap!

Nice work! You just built a real-time collaborative link board powered entirely by Supabase, without writing a single line of backend code.

You've learned how to:

  • Built a React + Vite frontend connected to a Supabase Postgres database via auto-generated REST APIs.
  • Implemented email/password authentication with session management using Supabase Auth.
  • Secured data at the database level using Row Level Security policies (SELECT, INSERT, DELETE).
  • Added live updates across users with Supabase Realtime subscriptions using postgres_changes.
  • Secret Mission: Extended RLS with conditional logic for public/private visibility and added UI filtering.

Ready to quiz yourself?