Build a Fullstack Bookmark Manager

Build a React + Express + SQLite app to save and manage bookmarks.

Introduction

30 Second Summary

You probably have dozens of browser tabs open right now with links you want to remember. Browser bookmarks get messy fast, and you never find what you saved last week.

In this project, you will build a fullstack bookmark manager from scratch. You will create a REST API with Express, store data in a SQLite database, and build a React frontend that ties everything together.

What You'll Build

You will type a URL into a form, click a button, and watch the bookmark appear instantly in a list on your screen. You can share this running app with someone and explain every layer of the stack.

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

  • A working form where you type a URL and title, hit submit, and see the bookmark pop into your list without a page refresh.
  • A delete button on each bookmark that removes it from both the screen and the database in one click.
  • A persistent database that keeps your bookmarks safe even after you restart the server, proving that your data lives beyond a single session.
  • Secret Mission: Add full-text search and a tagging system so you can filter and organize your bookmarks as the collection grows.

Are there any prerequisites?

You should have basic familiarity with JavaScript and Node.js. You do not need prior experience with React, Express, or databases. The project walks you through each layer step by step.

Before We Start

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

Set Up Both Projects

Your bookmark manager has two separate pieces that talk to each other. A backend server handles storing and retrieving data. A frontend app gives users a visual interface to interact with their bookmarks.

Before writing any application code, you need both projects set up with their dependencies installed. In this step, you'll create the folder structure, install packages for both sides, and confirm the server can start.

In this step, get ready to:
  • Create the project folder structure and initialize the server with npm.
  • Scaffold the React frontend with Vite.
  • Create a minimal Express server and confirm it runs.
Create the server project and install dependencies

Your project needs a root folder with two subdirectories inside it. The server directory holds the backend. The client directory holds the frontend (created by Vite in the next substep).

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and the server directory by running these commands:
mkdir bookmark-manager
cd bookmark-manager
mkdir server
  • Confirm the folder was created by running:
ls

You should see server listed in the output.

  • Open this project in VS Code by running:
code .

What does this command do?

The code . command opens VS Code with the current folder as the workspace. From here, you'll use VS Code's integrated terminal for the rest of the project.

  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, usually above Tab).
  • Move into the server directory by running:
cd server
  • Initialize a new Node.js project by running:
npm init -y

You should see a generated package.json printed in the terminal.

The default package.json needs a few changes before you install dependencies. You need to enable ES module syntax and set the project name.

  • In the VS Code Explorer sidebar on the left, expand the server folder and click package.json to open it.
  • Select all the content and replace it with this:
{
  "name": "bookmark-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  }
}

What does this code do?

The "type": "module" line tells Node.js to use modern import/export syntax instead of the older require() style.

The "start" script gives you a shortcut to run your server with npm start later.

  • Save package.json with Ctrl+S.
  • Install the three backend dependencies by running this in your terminal:
npm install express cors better-sqlite3

You should see output confirming that packages were added. Your package.json now has a dependencies section with express, cors, and better-sqlite3 listed.

What are these packages?

  • Express is a web framework that handles HTTP requests and routing for your API.
  • cors is middleware that lets your frontend (on one port) talk to your backend (on a different port) without the browser blocking the requests.
  • better-sqlite3 is a fast SQLite library that stores data in a simple file. No database server needed.

Seeing install errors?

If better-sqlite3 fails to install with a compilation error, you may need build tools. Run sudo apt-get install build-essential in your WSL terminal, then retry the install command.

help me fix an npm install error for better-sqlite3 in WSL

Scaffold the React frontend

With the server project ready, you now need a frontend. Vite is a modern build tool that scaffolds a React project in seconds.

  • Move back to the project root by running:
cd ..
  • Scaffold the React frontend by running:
npm create vite@latest client -- --template react

What does this command do?

This command creates a new client folder with a React project already configured. The -- separates npm's arguments from Vite's arguments. The --template react flag tells Vite to use the React starter template.

If Vite asks about ESLint or Oxlint

Vite may prompt you to choose a linter for your React project. Select the default option (Oxlint) by pressing Enter. This won't affect the project code you write.

  • Move into the client directory by running:
cd client
  • Install the frontend dependencies by running:
npm install

You should see output showing packages being installed.

  • Confirm the scaffold worked by running:
ls

You should see files like index.html, package.json, vite.config.js, and a src folder.

Command not found?

If npm create fails, make sure you are in the bookmark-manager root folder (not inside server) when you run it. Run pwd to check your current location.

help me fix an npm create vite error in WSL

Create a minimal Express server and confirm it runs

Your dependencies are installed but there's no code to run yet. You'll create a minimal server/index.js that proves Express is working.

  • Move back to the server directory by running:
cd ../server
  • Create a new file called index.js inside the server folder. In VS Code, right-click the server folder in the Explorer sidebar and select New File.
  • Name it index.js and press Enter.
  • Paste the following code into index.js:
import express from 'express';

// Create the Express application
const app = express();
const PORT = 3001;

// Start the server and print a confirmation message
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What does this code do?

  • import express from 'express' loads the Express framework using ES module syntax (enabled by the "type": "module" you set earlier).
  • express() creates an app object that handles incoming HTTP requests.
  • app.listen(PORT, ...) starts the server on port 3001 and prints a message once it's ready.
  • Save index.js with Ctrl+S.

Before you run this, what do you think will happen? The server should start and print a message confirming which port it's listening on.

  • Start the server by running:
node index.js

You should see this printed in your terminal:

Server running on http://localhost:3001

Seeing a module error?

If you see an error about Cannot use import statement outside a module, open server/package.json and confirm it contains "type": "module". Save the file and try again.

help me fix a module import error in my Express server

  • Stop the server by pressing Ctrl+C in the terminal. You'll restart it later.

✔️ Awesome, I've got everything!

Great. Double check you've saved both server/package.json and server/index.js.

ⓧ I'd like to double check the full code

Here's what server/index.js should look like:

import express from 'express';

const app = express();
const PORT = 3001;

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

And server/package.json should look like this (the exact version numbers may differ slightly):

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

Your project structure is locked in. Both the backend and frontend are ready for real code. Next up, you'll build the bookmark API that stores and returns data.

Build the Bookmark API with In-Memory Storage

Your project structure is ready with both a server and client directory. Now it's time to make the server do something useful: accept and return bookmarks through a REST API.

Before building any UI, you need an API that can receive and return data. Starting with a simple JavaScript array keeps the focus on designing good API routes without worrying about database complexity yet.

In this step, get ready to:
  • Add middleware to handle cross-origin requests and JSON parsing.
  • Build POST and GET routes for creating and listing bookmarks.
  • Test your API with curl to confirm it works end-to-end.
Add middleware and in-memory storage

Your server can start, but it can't understand incoming data or handle requests from a different origin. Two pieces of middleware fix that.

  • In server/index.js, replace everything above the app.listen block with the following code:
import express from 'express';
import cors from 'cors';

const app = express();
const PORT = 3001;

// Allow requests from the React frontend
app.use(cors());
// Parse incoming JSON request bodies
app.use(express.json());

// Temporary in-memory storage
let bookmarks = [];
let nextId = 1;

What does this code do?

  • cors() allows your React frontend (running on port 5173) to make requests to this server (running on port 3001). Without it, the browser blocks cross-origin requests.
  • express.json() parses JSON in incoming request bodies so you can read req.body as a JavaScript object.
  • bookmarks is a plain array that holds bookmark objects in memory. nextId auto-increments so each bookmark gets a unique ID.

What is CORS?

CORS stands for Cross-Origin Resource Sharing. Browsers block requests between different origins (different ports count as different origins) by default. The cors middleware tells the browser it's safe for your frontend to talk to this server.

  • Save server/index.js.
  • Start the server to confirm the new imports load without errors by running this command from the server directory:
node index.js

You should see the same Server running on http://localhost:3001 message as before.

Seeing a syntax error?

  • Check that import cors from 'cors'; is on its own line directly below the express import.
  • Make sure both app.use() lines appear after const app = express(); and before app.listen.
  • help me debug this server startup error
Create the bookmark routes

With middleware in place, you can now add the two routes that form your API: one to list all bookmarks and one to create a new bookmark.

  • Stop the server with Ctrl+C.
  • In server/index.js, add the following routes above the app.listen block:
// Return all bookmarks
app.get('/bookmarks', (req, res) => {
  res.json(bookmarks);
});

// Add a new bookmark
app.post('/bookmarks', (req, res) => {
  const { url, title, description } = req.body;
  if (!url || !title) {
    return res.status(400).json({ error: 'URL and title are required' });
  }
  const bookmark = {
    id: nextId++,
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  bookmarks.push(bookmark);
  res.status(201).json(bookmark);
});

What do these routes do?

  • GET /bookmarks returns the entire bookmarks array as JSON. Right now it's empty, but it grows each time you POST.
  • POST /bookmarks reads url, title, and description from the request body. It rejects requests missing a URL or title with a 400 status.
  • Each new bookmark gets an auto-incrementing id and a created_at timestamp. The server responds with 201 (Created) and the full bookmark object.
  • Save server/index.js.
  • Restart the server by running:
node index.js

You should see Server running on http://localhost:3001 again, confirming the routes loaded.

Server crashes on startup?

  • Make sure the routes are placed between let nextId = 1; and app.listen(PORT, ...). Routes defined after app.listen still work but placing them before is the standard pattern.
  • Check for missing closing braces or parentheses in the POST route. The object literal inside has nested braces that are easy to misplace.
  • help me fix my Express route code
Test your API with curl

Your API is running. Time to prove it works by sending real HTTP requests with curl.

  • Open a second terminal tab in VS Code (the server is using the first one).
  • Add your first bookmark by running this command:
curl -X POST http://localhost:3001/bookmarks -H "Content-Type: application/json" -d '{"url":"https://example.com","title":"Example"}'

You should see a JSON response with your bookmark, including an id of 1 and a created_at timestamp.

  • Add a second bookmark by running:
curl -X POST http://localhost:3001/bookmarks -H "Content-Type: application/json" -d '{"url":"https://nodejs.org","title":"Node.js Docs","description":"Official Node documentation"}'

This one includes a description. You should see it returned with an id of 2.

Before you fetch the full list, how many bookmarks do you expect to see in the response?

  • Fetch all bookmarks by running:
curl http://localhost:3001/bookmarks

You should see a JSON array containing both bookmarks you just added, with all their fields intact.

Seeing an empty array?

  • Make sure you're running curl in a separate terminal tab from the server. If you stopped the server to run curl, the bookmarks array resets.
  • Check the POST response. If you saw {"error":"URL and title are required"}, the Content-Type header might be missing from your curl command.
  • help me debug my curl requests to the bookmark API

✔️ Awesome, I've got everything!

Great. Double check you've saved server/index.js and your server is still running.

ⓧ I'd like to double check the full code

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

  • The two imports (express and cors) at the top.
  • app.use(cors()) and app.use(express.json()) after the app is created.
  • let bookmarks = [] and let nextId = 1 for in-memory storage.
  • The app.get('/bookmarks', ...) route returning the array.
  • The app.post('/bookmarks', ...) route with validation, object creation, and 201 response.
  • The app.listen(PORT, ...) block at the very end.

Your API can create and list bookmarks. There's one catch though: if you stop the server, every bookmark vanishes. Next up, you'll experience that problem firsthand and fix it with a real database.

Add SQLite for Persistent Storage

Your API works. You can add bookmarks and fetch them back. But there is a critical flaw hiding in plain sight.

Every bookmark lives in a JavaScript array inside your server's memory. The moment you stop the server, that memory is wiped clean. SQLite solves this by writing data to a file on disk, so your bookmarks survive server restarts.

In this step, get ready to:
  • Experience data loss firsthand by restarting the server and watching bookmarks disappear.
  • Replace the in-memory array with a SQLite database that persists to a file.
  • Add a DELETE route so bookmarks can be removed by ID.
Experience the persistence problem

Before writing any new code, you need to see the problem with your own eyes. Your server is still running from the previous step with bookmarks in memory.

  • First, confirm you have at least one bookmark by running:
curl -X POST http://localhost:3001/bookmarks -H "Content-Type: application/json" -d '{"url":"https://example.com","title":"Test Bookmark"}'

You should see a JSON object with your new bookmark returned.

Now, what do you think happens to that bookmark if you stop and restart the server? Take a guess before you try it.

  • Stop your server by pressing Ctrl+C in the terminal.
  • Restart the server by running:
node server/index.js
  • Now fetch your bookmarks:
curl http://localhost:3001/bookmarks

You get back an empty array: []. Your bookmark is gone. This is the problem you are solving right now.

Why did the data disappear?

The let bookmarks = [] array exists only in your server process's memory. When you stop the process, that memory is reclaimed by the operating system. Restarting creates a fresh, empty array every time.

Replace the in-memory array with SQLite

Now that you have felt the pain, let's fix it. You will swap the in-memory array for a better-sqlite3 database that writes to a file called bookmarks.db.

  • Stop the server with Ctrl+C if it is still running.
  • In server/index.js, find the line:
import cors from 'cors';
  • Add this import directly below it:
import Database from 'better-sqlite3';
  • Now find these two lines and delete them both:
let bookmarks = [];
let nextId = 1;
  • In their place, paste the following database setup code:
// Create (or open) the SQLite database file
const db = new Database('bookmarks.db');
db.pragma('journal_mode = WAL');

// Create the bookmarks table if it doesn't exist yet
db.exec(`
  CREATE TABLE IF NOT EXISTS bookmarks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    url TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT DEFAULT '',
    created_at TEXT DEFAULT (datetime('now'))
  )
`);

What does this code do?

new Database('bookmarks.db') creates a file called bookmarks.db in your server folder. All data lives in this file.

WAL mode (Write-Ahead Logging) makes reads and writes faster by letting them happen concurrently. CREATE TABLE IF NOT EXISTS ensures the table is only created once, even if you restart the server multiple times.

  • Save server/index.js and start the server:
node server/index.js

You should see Server running on http://localhost:3001 printed in the terminal.

  • Confirm the database file was created by running this in a second terminal:
ls server/bookmarks.db

You should see server/bookmarks.db printed. Your database file now exists on disk.

Server won't start?

If you see a syntax error, double check that you deleted both the let bookmarks = [] and let nextId = 1 lines. The db.exec block should sit directly below app.use(express.json()).

If you see a module not found error for better-sqlite3, make sure you are running the command from inside the server directory where node_modules lives.

Need more help? Why won't my server start after adding better-sqlite3?

Rewrite the routes to use the database

The server starts, but your GET and POST routes still reference the old bookmarks array (which no longer exists). Time to rewrite them to query the database instead.

  • Stop the server with Ctrl+C.
  • In server/index.js, find the entire app.get('/bookmarks', ...) route and replace it with:
app.get('/bookmarks', (req, res) => {
  // Fetch all bookmarks sorted by newest first
  const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all();
  res.json(bookmarks);
});
  • Now find the entire app.post('/bookmarks', ...) route and replace it with:
app.post('/bookmarks', (req, res) => {
  const { url, title, description } = req.body;
  if (!url || !title) {
    return res.status(400).json({ error: 'URL and title are required' });
  }
  // Insert the new bookmark into the database
  const result = db.prepare(
    'INSERT INTO bookmarks (url, title, description) VALUES (?, ?, ?)'
  ).run(url, title, description || '');
  // Fetch the newly created row to return it
  const bookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(
    result.lastInsertRowid
  );
  res.status(201).json(bookmark);
});

What changed in these routes?

db.prepare(...).all() compiles a SQL statement and runs it, returning all matching rows as an array of objects. db.prepare(...).run() executes a statement that modifies data and returns info about the change (like lastInsertRowid).

The ? placeholders are parameterized queries. They prevent SQL injection by keeping user input separate from SQL syntax.

  • Save server/index.js and restart the server:
node server/index.js
  • Test by adding a bookmark:
curl -X POST http://localhost:3001/bookmarks -H "Content-Type: application/json" -d '{"url":"https://nodejs.org","title":"Node.js Docs"}'
  • Fetch the list to confirm it was saved:
curl http://localhost:3001/bookmarks

You should see an array with your bookmark, including an auto-generated id and created_at timestamp from the database.

Seeing an empty array or an error?

Make sure you deleted the old route handlers completely before pasting the new ones. Having two app.get('/bookmarks', ...) declarations will cause only the first one to respond.

If you see a database error, confirm your db.exec table creation code appears before the route definitions in the file.

Still stuck? Help me debug my rewritten GET and POST routes

Add a DELETE route and verify persistence

Your bookmarks go into the database but you have no way to remove them yet. A REST API typically supports DELETE for removing resources by ID.

  • Stop the server with Ctrl+C.
  • In server/index.js, find the app.listen block at the bottom. Add this route directly above it:
app.delete('/bookmarks/:id', (req, res) => {
  const { id } = req.params;
  // Attempt to delete the bookmark with this id
  const result = db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id);
  if (result.changes === 0) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.status(204).send();
});

What does this route do?

The :id in the path is a route parameter. Express extracts it into req.params.id.

result.changes tells you how many rows were affected. If zero rows were deleted, the bookmark does not exist, so you return a 404. A successful delete returns 204 (No Content) with an empty body.

  • Save server/index.js and restart the server:
node server/index.js

Now for the moment of truth. You are about to prove that your bookmarks survive a server restart. What do you think will happen when you stop and restart the server this time?

  • First, add a fresh bookmark:
curl -X POST http://localhost:3001/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'
  • Stop the server with Ctrl+C.
  • Restart it:
node server/index.js
  • Fetch your bookmarks:
curl http://localhost:3001/bookmarks

Your bookmarks are still there. The data survived the restart because SQLite wrote it to bookmarks.db on disk.

  • Test the DELETE route by removing a bookmark (replace 1 with an ID from your list):
curl -X DELETE http://localhost:3001/bookmarks/1
  • Fetch the list again to confirm it is gone:
curl http://localhost:3001/bookmarks

The deleted bookmark no longer appears in the response. Your full CRUD cycle (Create, Read, Delete) is working with persistent storage.

Bookmarks still disappearing on restart?

Check that you removed the old let bookmarks = [] line. If both the array and the database exist in your file, the routes might still reference the array.

Also confirm you see bookmarks.db in your server folder. If the file does not exist, the new Database('bookmarks.db') line might not be running. Make sure it sits after the middleware and before the routes.

Need help? My bookmarks are still disappearing after I added SQLite

✔️ Awesome, I've got everything!

Great. Double check you have saved server/index.js and your server is running.

ⓧ I'd like to double check the full code

Your server/index.js should now have these sections, in order:

  • Three imports: express, cors, Database from better-sqlite3.
  • App setup: const app = express(), PORT, middleware (cors(), express.json()).
  • Database setup: new Database('bookmarks.db'), WAL pragma, CREATE TABLE IF NOT EXISTS with 5 columns.
  • GET /bookmarks route using db.prepare(...).all().
  • POST /bookmarks route using db.prepare(...).run() then .get(result.lastInsertRowid).
  • DELETE /bookmarks/:id route checking result.changes.
  • app.listen on PORT 3001.

Scroll through each section above in your file and confirm they match. There should be no let bookmarks = [] or let nextId lines anywhere.

Your API now has real persistence. Next up, you will build the React frontend that talks to this API and gives your bookmark manager a proper user interface.

Build the React Frontend

Your API handles bookmarks perfectly, but the only way to use it right now is through curl commands in the terminal. Real users need a visual interface they can click and type into.

In this step, you'll build a React frontend that talks to your Express API. Users will be able to see their bookmarks, add new ones through a form, and delete them with a button click.

In this step, get ready to:
  • Fetch and display bookmarks from your API on page load.
  • Add a form that creates new bookmarks via the API.
  • Add delete functionality and style the interface with CSS.
Set up state and fetch bookmarks from the API

The React component needs to hold data in state and fetch it from your API when the page loads. You'll replace the default Vite content with your own component.

  • In VS Code, open client/src/App.jsx from the Explorer sidebar.
  • Select all the existing content and delete it.
  • Paste the following code to set up your component's state and data fetching:
import { useState, useEffect } from 'react';
import './App.css';

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

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

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

  async function fetchBookmarks() {
    try {
      const response = await fetch(`${API_URL}/bookmarks`);
      const data = await response.json();
      setBookmarks(data);
    } catch (error) {
      console.error('Failed to fetch bookmarks:', error);
    } finally {
      setLoading(false);
    }
  }

What does this code do?

useState creates five pieces of state: the bookmark list, three form input values (url, title, description), and a loading flag.

useEffect with an empty [] dependency array runs fetchBookmarks() exactly once when the component first loads. The function calls your API and stores the response in state.

  • Directly below the fetchBookmarks function (still inside the App function), paste the return statement that renders your bookmarks:
  if (loading) return <p className="loading">Loading bookmarks...</p>;

  return (
    <div className="app">
      <h1>My Bookmarks</h1>

      <div className="bookmark-list">
        {bookmarks.length === 0 ? (
          <p className="empty">No bookmarks yet. Add one above!</p>
        ) : (
          bookmarks.map((bookmark) => (
            <div key={bookmark.id} className="bookmark-card">
              <div className="bookmark-info">
                <a href={bookmark.url} target="_blank" rel="noopener noreferrer">
                  {bookmark.title}
                </a>
                {bookmark.description && <p>{bookmark.description}</p>}
                <span className="bookmark-url">{bookmark.url}</span>
              </div>
            </div>
          ))
        )}
      </div>
    </div>
  );
}

export default App;

What does this JSX do?

If bookmarks are still loading, it shows a loading message. Otherwise it renders a heading and a list of bookmark cards.

Each card displays the bookmark's title as a clickable link, its description (if it has one), and the raw URL. The target="_blank" attribute opens links in a new tab.

  • Save client/src/App.jsx.
  • Open a second terminal in VS Code by clicking the + icon in the terminal panel.
  • Start the React dev server by running these commands:
cd ~/Desktop/bookmark-manager/client
npm run dev

The terminal prints a local URL at http://localhost:5173.

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

If you added bookmarks via curl in earlier steps, you'll see them listed on the page. If not, you'll see the "No bookmarks yet" message.

Seeing a blank page or errors?

  • Make sure your Express server is still running in the other terminal tab. The React app fetches data from http://localhost:3001, which requires the server to be up.
  • Check the browser console (F12 then Console tab) for error messages. A "Failed to fetch" error means the backend is not running.
  • Confirm you deleted ALL the default Vite content before pasting your code.

Help me debug why my React app can't fetch bookmarks from the API.

Add a form to create new bookmarks

Your app displays bookmarks, but there's no way to add new ones from the browser. You need a form that collects a URL, title, and description, then sends them to your API.

  • In client/src/App.jsx, find the if (loading) line. Paste the following handleSubmit function directly above it:
  async function handleSubmit(e) {
    e.preventDefault();
    if (!url || !title) return;

    try {
      const response = await fetch(`${API_URL}/bookmarks`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url, title, description }),
      });
      const newBookmark = await response.json();
      setBookmarks([newBookmark, ...bookmarks]);
      setUrl('');
      setTitle('');
      setDescription('');
    } catch (error) {
      console.error('Failed to add bookmark:', error);
    }
  }

What does handleSubmit do?

It prevents the default form reload, then sends a POST request to your API with the form values as JSON.

When the API responds with the new bookmark, it prepends the bookmark to the existing list using the spread operator ([newBookmark, ...bookmarks]). Then it clears all three input fields so the form is ready for the next entry.

  • Now find the <h1>My Bookmarks</h1> line inside the return statement. Paste the following form JSX directly below it (above the bookmark-list div):
      <form onSubmit={handleSubmit} className="bookmark-form">
        <input
          type="url"
          placeholder="https://example.com"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          required
        />
        <input
          type="text"
          placeholder="Title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
        />
        <input
          type="text"
          placeholder="Description (optional)"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
        />
        <button type="submit">Add Bookmark</button>
      </form>

What does this form do?

Each input is a "controlled component" where React state (url, title, description) holds the current value. The onChange handler updates state on every keystroke, keeping the input and state in sync.

When the form submits, React calls handleSubmit which sends the state values to your API. The required attribute on the first two inputs gives you free browser-level validation.

  • Save client/src/App.jsx.
  • Switch to your browser. Vite automatically reloads the page.
  • Type https://react.dev in the URL field, React Docs in the Title field, and Official React documentation in the Description field.
  • Click Add Bookmark.

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

Bookmark not appearing after submit?

  • Check that handleSubmit is placed ABOVE the if (loading) line and INSIDE the App function body.
  • Open the browser console (F12) and look for a "Failed to add bookmark" error. This means the POST request to your Express server failed.
  • Confirm the onSubmit={handleSubmit} attribute is on the <form> tag, not on the button.

Help me figure out why my form isn't creating bookmarks in the React app.

Add delete functionality and style the interface

Users can add bookmarks, but they cannot remove ones they no longer need. You also need CSS to make the interface usable instead of unstyled HTML.

  • In client/src/App.jsx, find the handleSubmit function you just added. Paste the following handleDelete function directly below it:
  async function handleDelete(id) {
    try {
      await fetch(`${API_URL}/bookmarks/${id}`, { method: 'DELETE' });
      setBookmarks(bookmarks.filter((b) => b.id !== id));
    } catch (error) {
      console.error('Failed to delete bookmark:', error);
    }
  }

What does handleDelete do?

It sends a DELETE request to your API using the bookmark's id in the URL path. Then it filters that bookmark out of the local state array, which immediately removes it from the screen without needing a full page reload.

  • Now find the closing </div> tag for the bookmark-info div inside the bookmarks.map block. It looks like this:
              </div>
            </div>
  • Add the delete button between those two closing </div> tags, so it looks like this:
              </div>
              <button
                onClick={() => handleDelete(bookmark.id)}
                className="delete-btn"
              >
                Delete
              </button>
            </div>
  • Save client/src/App.jsx and check your browser.

Each bookmark card now shows a Delete button on the right side. Click it on one of your bookmarks and watch it disappear from the list.

Delete button not working?

  • Make sure handleDelete is defined inside the App function, not outside it.
  • Verify the button's onClick uses an arrow function: () => handleDelete(bookmark.id). Without the arrow function, it would call handleDelete immediately on every render.

Help me debug why the delete button isn't removing bookmarks.

Now the functionality is complete, but the page looks like raw unstyled HTML. CSS will give it a clean, usable layout.

  • Open client/src/App.css in VS Code.
  • Select all the existing content and delete it.
  • Paste the following page layout and form styles:
.app {
  max-width: 700px;
  margin: 0 auto;
  padding: 2rem;
  font-family: system-ui, -apple-system, sans-serif;
}

h1 {
  text-align: center;
  margin-bottom: 2rem;
}

.bookmark-form {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-bottom: 2rem;
  padding: 1.5rem;
  background: #f5f5f5;
  border-radius: 8px;
}

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

What do these styles do?

The .app class centers the page content with a max width of 700px. The .bookmark-form uses flexbox column layout to stack inputs vertically with a light gray background.

  • Directly below that code, paste the button and card layout styles:
.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;
}

.bookmark-list {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.bookmark-card {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  padding: 1rem;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

.bookmark-info {
  flex: 1;
}

What do these styles do?

The submit button gets a purple background (#4f46e5) that darkens on hover. Each .bookmark-card uses justify-content: space-between to push the bookmark info to the left and the delete button to the right.

  • Save client/src/App.css and check your browser.

The page is already looking much better. The form sits in a rounded gray box and the bookmark cards have visible borders.

  • Return to client/src/App.css and paste the link and text styles below what you have:
.bookmark-info a {
  font-size: 1.1rem;
  font-weight: 600;
  color: #4f46e5;
  text-decoration: none;
}

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

.bookmark-info p {
  margin: 0.25rem 0;
  color: #6b7280;
}

.bookmark-url {
  font-size: 0.85rem;
  color: #9ca3af;
}
  • Finally, paste the delete button and utility styles at the bottom of the file:
.delete-btn {
  padding: 0.5rem 1rem;
  background: #ef4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

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

.loading,
.empty {
  text-align: center;
  color: #6b7280;
}

What do these final styles do?

Bookmark titles become purple clickable links that underline on hover. The URL text is small and gray so it doesn't compete with the title.

The .delete-btn class gives the delete button a red background that darkens on hover, making it visually distinct from the purple Add button.

  • Save client/src/App.css and check your browser.

Your bookmark manager now has a polished layout with a centered form, styled cards, purple links, and a red delete button on each card.

Styles not showing up?

  • Confirm the import at the top of App.jsx says import './App.css'. Without this import, Vite won't load your CSS file.
  • Make sure you replaced ALL the default CSS in App.css. Leftover Vite styles can conflict with your custom classes.
  • Check that your class names in the CSS match the className values in your JSX exactly (case-sensitive).

Help me figure out why my CSS styles aren't applying to my React bookmark manager.

Before you move on, do a full test of the CRUD loop. What do you think will happen if you add a bookmark, refresh the page, and then delete it?

  • Add a new bookmark using the form (any URL and title you like).
  • Refresh the page with Ctrl+R (or Cmd+R on macOS).
  • Confirm the bookmark is still there after the reload.
  • Click Delete on the bookmark and confirm it disappears.

The bookmark persists across page reloads because your React app fetches from the SQLite database every time it loads. Deleting removes it from both the UI and the database permanently.

✔️ Awesome, I've got everything!

Great. Double check you've saved both App.jsx and App.css, then move on.

ⓧ I'd like to double check the full code

Both files are over 30 lines, so confirm each section is present in order.

In client/src/App.jsx, confirm these sections appear from top to bottom:

  • Imports (useState, useEffect, './App.css').
  • Five useState declarations (bookmarks, url, title, description, loading).
  • API_URL constant set to http://localhost:3001.
  • useEffect calling fetchBookmarks() on mount.
  • fetchBookmarks async function with try/catch/finally.
  • handleSubmit async function that POSTs and prepends.
  • handleDelete async function that calls DELETE and filters.
  • Loading check (if (loading) return ...).
  • Return JSX with the form (three inputs + submit button) and the bookmark list with delete buttons.
  • export default App at the bottom.

In client/src/App.css, confirm these selectors appear from top to bottom:

  • .app, h1, .bookmark-form, .bookmark-form input.
  • .bookmark-form button, .bookmark-form button:hover.
  • .bookmark-list, .bookmark-card, .bookmark-info.
  • .bookmark-info a, .bookmark-info a:hover, .bookmark-info p, .bookmark-url.
  • .delete-btn, .delete-btn:hover, .loading, .empty.

Your fullstack bookmark manager is fully functional. You built a React UI that communicates with your Express API, which persists data in SQLite. Every layer of the stack is working together.

Secret mission

Add Search and Tags

A flat list of bookmarks becomes useless as it grows. In this extension, you will add full-text search and a tagging system so you can organize and find bookmarks instantly. This touches every layer of your stack: database schema, API endpoints, and React UI.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. The SQLite database is a local file and both servers run on your machine.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder on your Desktop containing server/ and client/ directories.
  • bookmarks.db SQLite database file (inside the server/ folder).
  • Express server process running on port 3001.
  • Vite dev server process running on port 5173.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep using your bookmark manager.

  • Your bookmarks persist in the bookmarks.db file even after restarting your machine.
  • If you closed your terminal, restart the server with node server/index.js from the server/ directory.
  • Restart the frontend with npm run dev from the client/ directory.

Pause - I'll come back to this later

Shut down both running processes to free up terminal sessions and ports. Your project files stay on disk.

  • Switch to the terminal running your Express server and press Ctrl+C to stop it.
  • Switch to the terminal running your Vite dev server and press Ctrl+C to stop it.
  • Your project folder and bookmarks.db remain on disk. When you come back, restart both servers from their respective directories.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes your bookmarks database and all code.

  • Switch to the terminal running your Express server and press Ctrl+C to stop it.
  • Switch to the terminal running your Vite dev server and press Ctrl+C to stop it.
  • Delete the entire project folder by running:
cd ~/Desktop
rm -rf bookmark-manager
  • Confirm deletion by running:
ls ~/Desktop

The bookmark-manager folder should no longer appear in the output.

Nice Work!

Nice Work!

You just built a fullstack JavaScript application from scratch. Your bookmark manager has a React UI, an Express API, and a SQLite database all working together as a complete system.

You've learned how to:

  • Build a REST API from scratch with Express 5, handling POST, GET, and DELETE routes with proper status codes and input validation.
  • Experience the data persistence problem firsthand and solve it by replacing an in-memory array with SQLite using better-sqlite3.
  • Wire up a complete fullstack data flow: user action in React triggers an API call, which triggers a database operation, which triggers a UI update.
  • Secret Mission: Added search and tag filtering across all three layers of the stack (database query, API endpoint, React UI).

Ready to quiz yourself?