Build a Full-Stack Bookmark Manager

Build a React + Express + Supabase app with full CRUD operations.

Introduction

30 Second Summary

Think about how many browser tabs you have open right now with links you want to save "for later." Building a bookmark tool from scratch teaches you how real web apps move data from a user's screen all the way to a database and back.

In this project, you will build a full-stack Bookmark Manager with a React front-end, an Express API, and a Supabase cloud database that stores your bookmarks permanently.

What You'll Build

You will type a URL and title into a form, hit save, and watch the bookmark appear instantly in a clean list, persisted in a real PostgreSQL database that survives page refreshes and server restarts.

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

  • A live bookmark form where you type a title and URL, click save, and see it appear in your list immediately with no page reload.
  • Persistent storage in a real cloud database. Restart the server, close the browser, come back tomorrow. Your bookmarks are still there.
  • A complete request lifecycle you can demo end-to-end: browser sends data to your Express API, the API saves it to Supabase, and the updated list renders back in the UI.
  • Secret Mission: A search bar that filters your bookmarks by title in real time as you type.

Are there any prerequisites?

You should be comfortable with basic HTML, CSS, and JavaScript. You will also need Node.js and a code editor (like VS Code) already installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

This step is your moment to commit to the project ahead. Once you can describe what you're building in your own words, you're ready to start.

Set Up Your Project

Your bookmark manager needs two separate applications working together. A React front-end handles what the user sees in the browser, and an Express server handles data behind the scenes.

In this step, you will scaffold both apps inside a shared workspace folder called bookmark-manager. Keeping them side by side lets them communicate during development.

In this step, get ready to:
  • Scaffold a React front-end using Vite.
  • Initialize an Express server with a health check endpoint.
Create the project structure and scaffold the React front-end

Vite is a modern build tool that scaffolds a ready-to-run React project in seconds. You will use it to generate the front-end app.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and scaffold the React app by running these commands:
mkdir bookmark-manager
cd bookmark-manager
npm create vite@latest client -- --template react

What does this do?

  • mkdir bookmark-manager creates your root project folder on the Desktop.
  • cd bookmark-manager moves your terminal into that folder.
  • npm create vite@latest client -- --template react scaffolds a new React project inside a subfolder called client.

Seeing a prompt to install create-vite?

If npm asks "Need to install the following packages", type y and press Enter to confirm.

  • Move into the client folder and install its dependencies by running:
cd client
npm install

You should see output confirming packages were added. Now it is time to check that the React app actually runs.

Before you run the next command, what do you think will happen? The app has never been started before.

  • Start the Vite development server by running:
npm run dev
  • Open http://localhost:5173 in your browser.

You should see the Vite + React starter page with a spinning logo and a counter button.

  • Return to your terminal and press Ctrl+C to stop the development server.

Don't see the starter page?

  • Make sure you ran npm install inside the client folder before running npm run dev. Missing dependencies cause startup errors.
  • Check that port 5173 is not already in use by another app. If it is, Vite will show a different port number in the terminal output.
  • help me debug my Vite dev server not starting
Initialize the Express server

The second half of your stack is an Express server. It will receive requests from the React front-end and eventually talk to a database.

  • Navigate back to the bookmark-manager root and create the server folder by running:
cd ..
mkdir server
cd server

Where am I now?

Your terminal was inside bookmark-manager/client. The cd .. moves up one level to bookmark-manager. Then you create and enter the server folder.

  • Create a new file called package.json inside the server folder with this content:
{
  "name": "bookmark-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env index.js"
  },
  "dependencies": {
    "express": "5.2.1",
    "cors": "2.8.6"
  }
}

What does this file do?

  • "type": "module" tells Node.js to use modern import/export syntax instead of the older require() style.
  • The start script uses --env-file=.env to load environment variables from a file. You will create that .env file in a later step when you connect to the database.
  • The dependencies section pins exact versions of Express and the CORS middleware.
  • Save package.json, then install the dependencies by running:
npm install

You should see output confirming that packages were added. A node_modules folder appears inside server.

  • Create a new file called index.js inside the server folder with this code:
import express from 'express';
import cors from 'cors';

// Create the Express app
const app = express();
app.use(cors());
app.use(express.json());

// Health check route to verify the server is running
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Start listening on port 3001
const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What does this code do?

  • app.use(cors()) allows your React app (running on port 5173) to make requests to this server (on port 3001). Without it, the browser blocks cross-origin requests.
  • app.use(express.json()) lets the server read JSON data sent in request bodies. You will need this when creating bookmarks later.
  • The /health route is a simple endpoint that proves the server is alive and responding.
  • Save index.js.

Before you start the server, what do you think visiting http://localhost:3001/health will show?

  • Start the Express server by running:
node index.js
  • Open http://localhost:3001/health in your browser.

You should see {"status":"ok"} displayed in the browser. Your Express server is running and responding to requests.

Seeing an error instead of the JSON response?

  • If you see a SyntaxError about import statements, check that your package.json contains "type": "module". Without this line, Node.js does not recognize import statements.
  • If the browser says it cannot reach the site, make sure you ran node index.js from inside the server folder and the terminal shows "Server running on http://localhost:3001".
  • help me debug my Express server not starting

✔️ Awesome, I've got everything!

Great. Double check you have saved both server/package.json and server/index.js. Leave the server running for now.

ⓧ I'd like to double check the full code

Here is the complete server/package.json:

{
  "name": "bookmark-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env index.js"
  },
  "dependencies": {
    "express": "5.2.1",
    "cors": "2.8.6"
  }
}

Here is the complete server/index.js:

import express from 'express';
import cors from 'cors';

const app = express();
app.use(cors());
app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Your workspace is ready. You have a React front-end that runs on port 5173 and an Express server that runs on port 3001. Next up, you will build real API endpoints that serve bookmark data.

Build Your API with Hardcoded Data

In the previous step, you set up the project structure and confirmed your Express server responds to a health check. Now it is time to make that server do something useful.

Before connecting a database, you want to see data flowing through your API immediately. Building endpoints with hardcoded data first lets you prove the routes work correctly and gives you a working API to test against.

In this step, get ready to:
  • Create a GET endpoint that returns bookmark data.
  • Add a POST endpoint that accepts new bookmarks.
  • Discover why in-memory data is not enough for a real app.
Create the bookmarks endpoint with sample data

A REST API organizes your data into endpoints that respond to different HTTP methods. A GET request retrieves data, and the server responds with JSON.

  • In server/index.js, find the line that says app.get('/health'.
  • Add the following code above that health route to create a bookmarks array and a GET endpoint:
// Sample bookmarks stored in memory
let bookmarks = [
  { id: 1, title: 'NextWork', url: 'https://nextwork.org' },
  { id: 2, title: 'MDN Web Docs', url: 'https://developer.mozilla.org' },
  { id: 3, title: 'Vite', url: 'https://vitejs.dev' },
];

// Return all bookmarks as JSON
app.get('/bookmarks', (req, res) => {
  res.json(bookmarks);
});

What does this code do?

The let bookmarks array holds three sample bookmark objects, each with an id, title, and url.

The app.get('/bookmarks') route responds to GET requests by sending the entire array back as JSON. When someone visits /bookmarks in a browser, they see the array.

  • Save server/index.js.
  • Make sure you are in the server folder in your terminal, then start the server by running:
node index.js
  • Open your browser and visit http://localhost:3001/bookmarks.
  • You should see a JSON array of three bookmark objects with titles like NextWork, MDN Web Docs, and Vite.

Seeing an empty page or an error?

  • Make sure the bookmarks array is placed above the /bookmarks route. The array must exist before the route tries to reference it.
  • Check that your URL is exactly http://localhost:3001/bookmarks with no trailing slash or typos.
  • Confirm you saved the file and restarted the server after making changes.

Why is my /bookmarks endpoint not returning data?

Add a POST endpoint to accept new bookmarks

A GET endpoint only reads data. To let users add new bookmarks, you need a POST endpoint that accepts data from the request body and stores it.

  • In server/index.js, add the following code below your app.get('/bookmarks') route:
// Accept a new bookmark and add it to the array
app.post('/bookmarks', (req, res) => {
  const { title, url } = req.body;
  const newBookmark = { id: Date.now(), title, url };
  bookmarks.push(newBookmark);
  res.status(201).json(newBookmark);
});

What does this code do?

The req.body object contains the JSON data sent by the client. Destructuring pulls out title and url from the request.

The Date.now() call generates a unique numeric ID based on the current timestamp. After pushing the new bookmark into the array, the server responds with a 201 status code (meaning "created") and returns the new bookmark as JSON.

  • Save server/index.js.
  • Stop the running server by pressing Ctrl+C in the terminal, then restart it:
node index.js
  • Open a second terminal window (keep the server running in the first one).
  • Test the POST endpoint by running this command to add a new bookmark:

macOS

curl -X POST http://localhost:3001/bookmarks -H "Content-Type: application/json" -d '{"title": "GitHub", "url": "https://github.com"}'

Windows

curl.exe -X POST http://localhost:3001/bookmarks -H "Content-Type: application/json" -d "{\"title\": \"GitHub\", \"url\": \"https://github.com\"}"
  • You should see a JSON response with your new bookmark, including a numeric id, the title GitHub, and the URL https://github.com.
  • Visit http://localhost:3001/bookmarks in your browser to confirm you now see four bookmarks.

Seeing an empty response or error from curl?

  • Confirm the server is still running in the first terminal. If you see no output, restart it with node index.js.
  • Check that your second terminal is a separate window, not the same one running the server.
  • On Windows, make sure you use curl.exe (not curl) to avoid the PowerShell alias conflict.

Why is my POST request not working?

✔️ Awesome, I've got everything!

Great. Double check that you have saved server/index.js and confirmed four bookmarks appear at /bookmarks.

ⓧ I'd like to double check the full code

Here is the complete server/index.js at this point:

import express from 'express';
import cors from 'cors';

const app = express();
app.use(cors());
app.use(express.json());

let bookmarks = [
  { id: 1, title: 'NextWork', url: 'https://nextwork.org' },
  { id: 2, title: 'MDN Web Docs', url: 'https://developer.mozilla.org' },
  { id: 3, title: 'Vite', url: 'https://vitejs.dev' },
];

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/bookmarks', (req, res) => {
  res.json(bookmarks);
});

app.post('/bookmarks', (req, res) => {
  const { title, url } = req.body;
  const newBookmark = { id: Date.now(), title, url };
  bookmarks.push(newBookmark);
  res.status(201).json(newBookmark);
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
Test the limitation: restart the server

You just added a bookmark and saw it in the list. Before you move on, think about this: what do you expect to happen if the server stops and starts again?

  • In the terminal running your server, press Ctrl+C to stop it.
  • Start it again by running:
node index.js
  • Visit http://localhost:3001/bookmarks in your browser again.

The GitHub bookmark you added is gone. Only the original three bookmarks remain.

Why did the bookmark disappear?

Your bookmarks array lives in the server's memory. Every time the server restarts, Node.js re-runs index.js from scratch and recreates the array with only the three hardcoded entries.

Anything added at runtime is wiped out. This is exactly why production apps use a database. Data needs to survive server restarts.

Your API works and you have proven the limitation of in-memory storage. Next up, you will connect a real cloud database so your bookmarks survive restarts and live forever.

Connect to Supabase

In the last step, you saw your bookmark disappear the moment you restarted the server. That happened because the bookmarks array lives in your server's memory, which resets every time the process stops.

A real database stores data on disk, independent of your server process. In this step, you will connect your Express API to Supabase, a cloud-hosted PostgreSQL database that keeps your bookmarks alive across restarts, crashes, and deployments.

In this step, get ready to:
  • Create a Supabase project and a bookmarks table.
  • Connect your Express server to Supabase with real database queries.
  • Add a DELETE endpoint so you can remove bookmarks permanently.
Create a Supabase project and bookmarks table

Supabase gives you a free PostgreSQL database in the cloud with no credit card required. You will create a project, then use SQL to define the table that stores your bookmarks.

  • Open supabase.com in your browser.
  • Click Start your project (or Sign Up) and create a free account using your email or GitHub.
  • Once signed in, you will be prompted to create an organization if you do not have one yet.
  • Click New Project.
  • Give your project a name (e.g. bookmark-manager).
  • Set a database password and save it somewhere safe.
  • Choose a region close to you for lower latency.
  • Click Create new project.

The project takes about 30 seconds to set up. Wait until the dashboard finishes loading before continuing.

  • In the left sidebar of your project dashboard, click SQL Editor.
  • Paste the following SQL into the editor to create your bookmarks table:
create table bookmarks (
  id bigint generated always as identity primary key,
  title text not null,
  url text not null,
  created_at timestamptz default now()
);
  • Click Run to execute the query.

What does this SQL do?

  • id bigint generated always as identity primary key creates an auto-incrementing unique identifier for each bookmark.
  • title text not null and url text not null store the bookmark data and prevent empty entries.
  • created_at timestamptz default now() automatically records when each bookmark was saved.

You should see a Success. No rows returned message confirming the table was created.

  • Insert a sample bookmark to verify the table works by pasting this into the SQL Editor and clicking Run:
insert into bookmarks (title, url)
values ('Example Site', 'https://example.com');
  • Click Table Editor in the left sidebar.
  • Select the bookmarks table.

You should see your sample row with an auto-generated id, the title Example Site, and a created_at timestamp.

Don't see the bookmarks table?

  • Make sure you clicked Run after pasting the CREATE TABLE query.
  • Check that the SQL Editor shows Success and not an error message. A common mistake is a missing comma or semicolon.
  • help me troubleshoot my Supabase table creation
Connect Express to Supabase

Your table is ready in the cloud. Now you will wire your Express server to read and write from it instead of the in-memory array.

  • Switch back to your terminal.
  • Make sure you are in the server folder, then install the Supabase client library by running:
npm install @supabase/supabase-js@2.109.0

Why pin to version 2.109.0?

Version 2.110.0 and above dropped support for Node.js 20. Pinning to 2.109.0 ensures compatibility whether you are on Node.js 20 or 22.

Next you need your Supabase project URL and publishable key so the server can authenticate with your database.

  • In the Supabase dashboard, click Connect at the top of the page.
  • Copy your Project URL (it looks like https://abcxyz.supabase.co).
  • Copy your publishable key (it starts with sb_publishable_ or eyJ).
  • In your code editor, create a new file called .env inside the server folder.
  • Paste the following into server/.env, replacing the placeholder values with the URL and key you just copied:
SUPABASE_URL=your-supabase-project-url
SUPABASE_KEY=your-supabase-publishable-key

What is a .env file?

A .env file stores configuration values that your code reads at runtime. Keeping these values out of your source code means you can share your project without exposing connection details.

The npm start script in your package.json already uses the --env-file=.env flag to load these values automatically.

Now update server/index.js to import and initialize the Supabase client.

  • Open server/index.js in your code editor.
  • Find the imports and hardcoded array at the top of the file (everything from line 1 through the closing ]; of the bookmarks array).
  • Replace that entire section with this code:
import express from 'express';
import cors from 'cors';
import { createClient } from '@supabase/supabase-js';

const app = express();
app.use(cors());
app.use(express.json());

// Initialize Supabase client with environment variables
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);

What does this code do?

  • import { createClient } from '@supabase/supabase-js' brings in the Supabase library you just installed.
  • process.env.SUPABASE_URL and process.env.SUPABASE_KEY read the values from your .env file at startup.
  • createClient(supabaseUrl, supabaseKey) creates a connection object that all your routes will use to talk to the database.
  • Leave the /health route as-is.
  • Find the existing app.get('/bookmarks', ...) handler and replace it with:
app.get('/bookmarks', async (req, res) => {
  const { data, error } = await supabase
    .from('bookmarks')
    .select('*')
    .order('created_at', { ascending: false });

  if (error) {
    return res.status(500).json({ error: error.message });
  }
  res.json(data);
});

What does this code do?

  • supabase.from('bookmarks').select('*') fetches all rows from the bookmarks table.
  • .order('created_at', { ascending: false }) sorts newest bookmarks first.
  • The { data, error } pattern is how Supabase returns results. If something goes wrong, error contains the details.
  • Save server/index.js.
  • Start your server using npm start (not node index.js) so the .env file gets loaded:
npm start
  • Open http://localhost:3001/bookmarks in your browser.

You should see a JSON array containing the sample bookmark you inserted through the Supabase SQL Editor earlier.

Seeing an empty array or an error?

  • Check that your .env file has the correct URL and key (no quotes around the values, no trailing spaces).
  • Make sure you used npm start instead of node index.js. The --env-file flag only runs through the start script.
  • Confirm you inserted a sample row into the bookmarks table in Supabase (check the Table Editor).
  • help me debug my Supabase connection from Express

Now replace the POST handler so new bookmarks go straight to the database.

  • Stop the server with Ctrl+C.
  • In server/index.js, find the existing app.post('/bookmarks', ...) handler and replace it with:
app.post('/bookmarks', async (req, res) => {
  const { title, url } = req.body;

  if (!title || !url) {
    return res.status(400).json({ error: 'Title and URL are required' });
  }

  const { data, error } = await supabase
    .from('bookmarks')
    .insert({ title, url })
    .select();

  if (error) {
    return res.status(500).json({ error: error.message });
  }
  res.status(201).json(data[0]);
});

What does this code do?

  • The validation check if (!title || !url) returns a 400 error if either field is missing. This protects your database from blank entries.
  • .insert({ title, url }).select() inserts a new row and returns the created bookmark (including its auto-generated id).
  • data[0] grabs the first (and only) inserted row to send back to the client.
  • Save server/index.js and start the server again:
npm start
  • Test the POST endpoint by running this curl command in a new terminal:
curl -X POST http://localhost:3001/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title": "NextWork", "url": "https://nextwork.org"}'

You should see a JSON response with your new bookmark, including an auto-generated id and created_at timestamp.

Getting a 400 or 500 error from POST?

  • A 400 error means title or url is missing from your request body. Double-check the curl command has both fields.
  • A 500 error means Supabase rejected the insert. Check that your column names in the table match title and url exactly.
  • help me fix my POST request to Supabase
Add the DELETE endpoint

You can now read and create bookmarks that persist in the cloud. The last piece is deleting bookmarks you no longer want.

  • Stop the server with Ctrl+C.
  • In server/index.js, add the following route above the const PORT = 3001; line:
app.delete('/bookmarks/:id', async (req, res) => {
  const { id } = req.params;

  const { error } = await supabase
    .from('bookmarks')
    .delete()
    .eq('id', id);

  if (error) {
    return res.status(500).json({ error: error.message });
  }
  res.status(204).send();
});

What does this code do?

  • req.params.id reads the bookmark ID from the URL (e.g. /bookmarks/5 gives id = 5).
  • .delete().eq('id', id) removes the row where id matches.
  • Status 204 means "success, no content to return." The bookmark is gone.
  • Save server/index.js and start the server:
npm start
  • Visit http://localhost:3001/bookmarks in your browser and note the id of one of your bookmarks.
  • Delete that bookmark by running this curl command (replace 1 with the actual ID you saw):
curl -X DELETE http://localhost:3001/bookmarks/1
  • Visit http://localhost:3001/bookmarks again to confirm the bookmark is gone from the response.

The deleted bookmark no longer appears. It has been permanently removed from Supabase.

Now for the real test. Before you run this, ask yourself: if you stop and restart the server, will the remaining bookmarks still be there?

  • Stop the server with Ctrl+C.
  • Start it again:
npm start
  • Visit http://localhost:3001/bookmarks one more time.

Your bookmarks are still there. The database holds them independently of the server process. No more data loss on restart.

Bookmarks disappeared after restart?

  • This means the server is still reading from a hardcoded array somewhere. Confirm that your server/index.js no longer contains the let bookmarks = [...] array from the previous step.
  • Check that npm start runs without errors. If you see undefined errors related to supabase, your .env file may not be loading correctly.
  • help me figure out why my bookmarks still disappear on restart

✔️ Awesome, I've got everything!

Great. Double check you have saved server/index.js and server/.env.

ⓧ I'd like to double check the full code

Your server/index.js should contain these sections in order. Scroll up and confirm each block is present:

  • Imports (express, cors, createClient) and the app/middleware setup.
  • Supabase initialization (supabaseUrl, supabaseKey, supabase).
  • GET /health route (unchanged from before).
  • GET /bookmarks route using supabase.from('bookmarks').select('*').
  • POST /bookmarks route using .insert({ title, url }).select().
  • DELETE /bookmarks/:id route using .delete().eq('id', id).
  • const PORT = 3001 and app.listen(...) at the bottom.

Your server/.env should have two lines: SUPABASE_URL and SUPABASE_KEY with your actual project values (no quotes).

Your API is now backed by a real cloud database. Bookmarks survive restarts, and you have full CRUD capability. Next up, you will build a React front-end so users can interact with your API through a real interface instead of curl commands.

Build the React Front-End

Your API handles GET, POST, and DELETE requests perfectly. But interacting with it through curl commands or the browser address bar is not how real users work.

In this step, you will build a React front-end that talks to your Express API. Users will see their bookmarks in a list, add new ones through a form, and delete them with a button click.

In this step, get ready to:
  • Fetch bookmarks from your API and display them as a list.
  • Add a form to create new bookmarks.
  • Wire up a delete button for each bookmark.
Fetch and display bookmarks

Your React app currently shows the default Vite starter page. You will replace the entire App.jsx file with a component that fetches data from your Express API and renders it.

  • Make sure your Express server is running. In the terminal where you run server commands, navigate to the server folder and start it:
cd server
npm start
  • Open a second terminal for the React client. You will keep both terminals open for the rest of this project.
  • In your code editor, open client/src/App.jsx. Select all the existing content and delete it.
  • Paste the following code at the top of the now-empty file. This sets up the component's state and data-fetching logic:
import { useState, useEffect } from 'react';
import './App.css';

function App() {
  const [bookmarks, setBookmarks] = useState([]);
  const [title, setTitle] = useState('');
  const [url, setUrl] = useState('');
  const [loading, setLoading] = useState(true);

  const API_URL = 'http://localhost:3001';

  async function fetchBookmarks() {
    setLoading(true);
    const response = await fetch(`${API_URL}/bookmarks`);
    const data = await response.json();
    setBookmarks(data);
    setLoading(false);
  }

  useEffect(() => {
    fetchBookmarks();
  }, []);

What does this code do?

This block imports React's useState and useEffect hooks, then declares four pieces of state: the bookmarks array, form input values (title and url), and a loading flag.

The fetchBookmarks function calls your Express API at http://localhost:3001/bookmarks and stores the response in state. The useEffect with an empty dependency array [] ensures this runs once when the component first loads.

Add a form to create bookmarks

Next, you need functions that handle form submission and bookmark deletion. These send requests to your API and then refresh the list.

  • Directly below the useEffect block you just pasted, add the two handler functions:
  async function handleSubmit(e) {
    e.preventDefault();
    if (!title || !url) return;

    await fetch(`${API_URL}/bookmarks`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, url }),
    });

    setTitle('');
    setUrl('');
    fetchBookmarks();
  }

  async function handleDelete(id) {
    await fetch(`${API_URL}/bookmarks/${id}`, {
      method: 'DELETE',
    });
    fetchBookmarks();
  }

What do these functions do?

The handleSubmit function prevents the page from refreshing (e.preventDefault()), sends a POST request with the title and URL from the form inputs, clears the inputs, then re-fetches the full bookmark list so the new entry appears instantly.

The handleDelete function sends a DELETE request for a specific bookmark ID, then re-fetches the list so the removed bookmark disappears from the UI.

  • Now add the component's return statement. This contains the form and the heading. Paste this directly below handleDelete:
  return (
    <div className="app">
      <h1>Bookmark Manager</h1>

      <form onSubmit={handleSubmit} className="bookmark-form">
        <input
          type="text"
          placeholder="Title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
        <input
          type="url"
          placeholder="https://example.com"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
        />
        <button type="submit">Save Bookmark</button>
      </form>

What does the form do?

The form has two controlled inputs. Their values come from the title and url state variables, and each onChange updates the corresponding state as the user types. When the user clicks Save Bookmark, the onSubmit handler fires.

Add a delete button

The final piece of the component renders the bookmark list with conditional states for loading and empty, plus a delete button on each item.

  • Paste this directly below the closing </form> tag to complete the component:
      {loading ? (
        <p className="status-message">Loading...</p>
      ) : bookmarks.length === 0 ? (
        <p className="status-message">No bookmarks yet. Add your first one above!</p>
      ) : (
        <ul className="bookmark-list">
          {bookmarks.map((bookmark) => (
            <li key={bookmark.id} className="bookmark-card">
              <a href={bookmark.url} target="_blank" rel="noopener noreferrer">
                {bookmark.title}
              </a>
              <button
                className="delete-btn"
                onClick={() => handleDelete(bookmark.id)}
              >
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default App;

What does this section render?

This uses a ternary chain to show one of three states: a "Loading..." message while data is being fetched, a "No bookmarks yet" message when the array is empty, or the full bookmark list.

Each bookmark renders as a list item with a clickable link (opens in a new tab) and a Delete button that calls handleDelete with that bookmark's ID.

  • Save client/src/App.jsx.
  • In your second terminal, navigate to the client folder and start the React dev server:
cd client
npm run dev
  • Before you check the browser, predict: what do you expect to see at http://localhost:5173? Will the page show bookmarks or be empty?
  • Open http://localhost:5173 in your browser.

You should see the heading "Bookmark Manager", the form with Title and URL inputs, and your bookmarks from Supabase displayed as a list of clickable links.

  • Test the form: type NextWork in the Title field and https://www.nextwork.org in the URL field.
  • Click Save Bookmark.

The new bookmark appears at the top of the list instantly. The form inputs clear themselves, ready for the next entry.

  • Now test the delete button. Click Delete next to one of your bookmarks.

The bookmark disappears from the list. If you refresh the page, it stays gone because the deletion happened in Supabase.

Not seeing your bookmarks?

  • Make sure your Express server is running in the other terminal. The React app fetches data from http://localhost:3001 and will show a blank page if the server is down.
  • Open your browser's developer console (F12) and check for CORS or network errors. If you see a CORS error, confirm your server uses the cors middleware.
  • Check that API_URL in App.jsx is exactly http://localhost:3001 with no trailing slash.

Still stuck? Help me debug why my React app can't connect to my Express server.

✔️ Awesome, I've got everything!

Great. Double check you have saved client/src/App.jsx and that both your server and client terminals are running.

ⓧ I'd like to double check the full code

Your client/src/App.jsx was built across four code blocks above. Scroll up and confirm each section is present in this order:

  • Imports and state declarations (useState, useEffect, four state variables, API_URL, fetchBookmarks, and the useEffect call).
  • The handleSubmit and handleDelete functions.
  • The return statement opening with the heading and form.
  • The conditional render (loading, empty, bookmark list with delete buttons) and export default App.

Your front-end is fully connected to your API. Next up, you will add styling to make the app look polished and professional.

Style and Polish

Your bookmark manager works. You can add, view, and delete bookmarks stored in a real database. But right now the app looks like raw, unstyled HTML.

In this step, you will replace the default Vite styles with custom CSS that gives the app a clean card layout, readable typography, and clearly interactive buttons.

In this step, get ready to:
  • Style the form, bookmark cards, and delete buttons with custom CSS.
  • Verify that loading and empty states display correctly.
Add CSS styling

The file client/src/App.css currently contains the default Vite boilerplate styles. You will replace everything in it with your own styles, starting with the page layout and form.

  • Open client/src/App.css in your editor.
  • Delete all the existing content in the file.
  • Paste in the following base layout and form styles:
/* Center the app and set a comfortable reading width */
.app {
  max-width: 600px;
  margin: 2rem auto;
  padding: 0 1rem;
  font-family: system-ui, -apple-system, sans-serif;
}

h1 {
  text-align: center;
  color: #1a1a1a;
}

/* Style the form as a card with stacked inputs */
.bookmark-form {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin-bottom: 2rem;
  padding: 1rem;
  background: #f5f5f5;
  border-radius: 8px;
}

.bookmark-form input {
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

What does this CSS do?

  • .app constrains the content to 600px and centers it horizontally with margin: 2rem auto.
  • .bookmark-form uses flexbox to stack the inputs vertically with a light gray background, making it visually distinct from the rest of the page.
  • Save App.css.
  • Switch to your browser. Your React dev server auto-refreshes, so you should see the heading centered and the form sitting inside a rounded gray card.

Still seeing the old Vite styles?

Make sure you deleted all the previous CSS before pasting. Leftover Vite styles can override your new rules.

Also confirm your React dev server is still running in the terminal. If it stopped, restart it with npm run dev from the client folder.

Why isn't my CSS showing up after saving?

  • Now add the button, status message, and list layout styles below what you just pasted:
/* Indigo button with hover state */
.bookmark-form button {
  padding: 0.75rem;
  background: #4f46e5;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
}

.bookmark-form button:hover {
  background: #4338ca;
}

/* Centered message for loading and empty states */
.status-message {
  text-align: center;
  color: #666;
}

/* Remove default list bullets and stack cards */
.bookmark-list {
  list-style: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

What does this CSS do?

  • .bookmark-form button gives the Save Bookmark button a bold indigo background that darkens on hover, signaling it is clickable.
  • .status-message centers the loading and empty-state text so it does not look like a stray paragraph.
  • .bookmark-list removes the default bullet points and stacks bookmark cards in a vertical column with even spacing.
  • Save App.css and check the browser.
  • You should see the Save Bookmark button in a solid indigo color. If you have bookmarks, they now appear without bullet points in a vertical stack.
  • Finally, add the card and delete button styles below the existing CSS:
/* Each bookmark as a row with space between link and button */
.bookmark-card {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  background: white;
  border: 1px solid #e5e5e5;
  border-radius: 8px;
}

.bookmark-card a {
  color: #4f46e5;
  text-decoration: none;
  font-weight: 500;
}

.bookmark-card a:hover {
  text-decoration: underline;
}

/* Red-tinted delete button */
.delete-btn {
  padding: 0.4rem 0.75rem;
  background: #fee2e2;
  color: #dc2626;
  border: 1px solid #fecaca;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.875rem;
}

.delete-btn:hover {
  background: #fecaca;
}

What does this CSS do?

  • .bookmark-card uses justify-content: space-between to push the link to the left and the delete button to the right inside a bordered card.
  • .delete-btn uses a light red background with darker red text, making it clearly different from the primary indigo button.
  • Save App.css and check the browser.
  • Each bookmark now sits inside a white card with rounded corners. The title is an indigo link, and the delete button is a small red-tinted pill on the right.

Cards look squished or overlapping?

Check that you included the .bookmark-list rule from the previous chunk. The gap: 0.75rem property on the list is what creates space between cards.

Why are my bookmark cards overlapping each other?

✔️ Awesome, I've got everything!

Great. Double check you have saved the file.

ⓧ I'd like to double check the full code

Your App.css file is made up of 3 sections you pasted above. Scroll up and confirm each is present in this order:

  • Base layout and form styles (.app, h1, .bookmark-form, .bookmark-form input).
  • Button, status, and list styles (.bookmark-form button, .status-message, .bookmark-list).
  • Card and delete button styles (.bookmark-card, .bookmark-card a, .delete-btn).
Verify loading and empty states

Your component already handles two edge cases: a loading state while data is being fetched, and an empty state when no bookmarks exist. The .status-message class you just added styles both of these. Time to confirm they look right.

  • In the browser, delete all of your bookmarks using the delete buttons.
  • Once the list is empty, you should see the text "No bookmarks yet. Add your first one above!" centered in gray below the form.
  • Refresh the page. For a brief moment you should see "Loading..." appear in the same centered gray style before the empty message shows.

Why handle these states?

Without a loading state, the page flashes an empty list before data arrives. Without an empty state, users see nothing and wonder if the app is broken. These small touches make the difference between a prototype and a polished app.

  • Add a new bookmark (any title and URL) to confirm the full cycle still works. The bookmark should appear as a styled card immediately.

Before you check the final result, predict: what will the app look like with a few bookmarks in the list? Picture the layout, the colors, and the button positions.

  • Add two or three bookmarks if you have not already.

You should see a centered page with a gray form card at the top, an indigo Save Bookmark button, and below it a stack of white bookmark cards. Each card shows an indigo link on the left and a small red Delete button on the right. Hovering the Save button darkens it, and hovering a delete button deepens its red background.

Your bookmark manager looks polished and presentable. Next up, you will take on the Secret Mission and add real-time search filtering to find bookmarks instantly.

Secret mission

Add Search Filtering

Your bookmark list works, but as it grows, scrolling through everything to find one link gets tedious. Real applications almost always need filtering. Can you add a search bar that filters bookmarks by title in real time?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with a free-tier Supabase database, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Supabase project (free tier, 500 MB database, no cost).
  • Local bookmark-manager folder on your Desktop (React client and Express server).

Keep everything running

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

  • Your Supabase project stays active on the free tier. Free projects pause automatically after 7 days of inactivity but unpause on the next request.
  • Your local React and Express apps only run when you start them. They consume no resources when stopped.
  • Your server/.env file contains your Supabase credentials. Keep it safe and do not commit it to version control.

Pause - I'll come back to this later

Free up local resources while keeping your project files and database intact.

  • Stop any running dev servers by pressing Ctrl+C in each terminal window (one for the React client, one for the Express server).
  • Your Supabase project pauses automatically after 7 days of inactivity. No action needed on your part.
  • When you return, start the Express server with npm start from the server folder and npm run dev from the client folder.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop any running dev servers by pressing Ctrl+C in each terminal window.
  • Delete your Supabase project: open the Supabase Dashboard, navigate to Settings > General > Delete project. Click Delete Project, enter your project name to confirm, then click Delete.
  • Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf bookmark-manager
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force bookmark-manager
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see bookmark-manager in the output.

Nice Work!

Nice Work!

You just built a full-stack web application from scratch. Your Bookmark Manager has a React front-end talking to an Express API, backed by a real PostgreSQL database on Supabase.

You learned how to:

  • Built a REST API with Express featuring GET, POST, and DELETE endpoints that handle CRUD operations against a real database.
  • Connected a React front-end to your API using fetch, useState, and useEffect to display, create, and delete bookmarks in the browser.
  • Traced the full data flow from a browser form submission, through your Express server, into a Supabase PostgreSQL database, and back to the UI.
  • Secret Mission: Added real-time search filtering using derived state in React to filter bookmarks by title as you type.

Ready to quiz yourself?