Build a Fullstack Bookmark Manager

Build a React + Express + MongoDB app with full CRUD in under an hour.

Introduction

30 Second Summary

Every day you save links in browser tabs, notes apps, and messages to yourself. Finding them again when you actually need them is the hard part.

In this project, you will build a Bookmark Manager web app that lets you save, organize, and delete bookmarks with tags. You'll create a React frontend, a Node.js/Express API, and a MongoDB database.

What You'll Build

A fullstack web application with a React frontend, an Express REST API, and a MongoDB Atlas cloud database that stores and manages your bookmarks.

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

  • A React frontend with a form to add bookmarks and a list to display them.
  • A Node.js/Express backend with RESTful API endpoints for creating, reading, and deleting bookmarks.
  • A MongoDB Atlas database storing bookmark documents in the cloud for free.
  • A GitHub repository containing your complete fullstack project code.
  • Secret Mission: Add tag-based filtering so you can click a tag to see only matching bookmarks.

Are there any prerequisites?

No prior experience with React, Node.js, or MongoDB is needed. This guide walks you through setting up every tool from scratch.

Set Up the Project Structure and Git

A fullstack app needs two separate codebases that work together: a frontend that users see, and a backend that handles data. Keeping them in separate folders inside one project makes development easier and keeps your Git history clean.

In this step, you will create the project folder structure, initialize Git for version control, install backend dependencies, and scaffold a React frontend.

In this step, get ready to:
  • Create the project folder with Git initialized and the two-folder structure.
  • Initialize the server and install backend dependencies.
  • Scaffold the React frontend using Vite and push your code to GitHub.
Create the project folder and initialize Git

Every project starts with a home folder on your machine. You will create a bookmark-manager folder on your Desktop with two subfolders: server for backend code and client for frontend code (created later by Vite).

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, initialize Git, and create the server subfolder by running these commands:
mkdir bookmark-manager
cd bookmark-manager
git init
mkdir server

What do these commands do?

  • mkdir bookmark-manager creates a new folder named bookmark-manager.
  • cd bookmark-manager moves you inside that folder.
  • git init turns this folder into a Git repository so you can track changes.
  • mkdir server creates the subfolder where your backend code will live.
  • Confirm the folder structure by running:
ls
  • You should see server in the output.

Now create a .gitignore file to tell Git which files to skip when tracking changes.

  • Open the bookmark-manager folder in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the bookmark-manager folder on your Desktop, then click Open.
  • Create a new file by clicking the new file icon in the Explorer sidebar (or press Cmd+N on macOS, Ctrl+N on Windows).
  • Name the file .gitignore and paste the following code into it:
node_modules/
.env

What does this file do?

The .gitignore file tells Git to ignore specific files and folders.

  • node_modules/ contains thousands of dependency files that can be reinstalled with npm install. Tracking them would bloat your repository.
  • .env will hold your database connection string. Keeping secrets out of Git is a critical security practice.
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

Don't see the Explorer sidebar?

Press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle the Explorer panel.

help me open the Explorer sidebar in VS Code

Initialize the server and install dependencies

The server folder needs its own package.json file to manage backend dependencies. You will install four packages: Express (web framework), Mongoose (database library), cors (cross-origin requests), and dotenv (environment variables).

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Move into the server folder by running:
cd server
  • Initialize a new package.json file by running:
npm init -y

This creates a default package.json with placeholder values. Update the project name and description to match your app.

  • In the Explorer sidebar, expand the server folder and click package.json to open it.
  • Find the line that says "name": "server" and change it to "name": "bookmark-manager-server".
  • Find the line that says "description": "" and change it to "description": "Bookmark Manager REST API".
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
  • Install the four backend packages by running:
npm install express mongoose cors dotenv

What does this command do?

npm install downloads each package and saves it to a node_modules folder. It also records the exact versions in your package.json under dependencies.

  • express is the web framework that handles incoming HTTP requests.
  • mongoose connects your server to MongoDB and lets you define data structures.
  • cors allows your frontend to communicate with your backend during development.
  • dotenv loads secret values (like your database password) from a .env file.
  • Verify the install worked by checking your package.json in VS Code.
  • You should see a dependencies section listing express, mongoose, cors, and dotenv.

✔️ Awesome, I've got everything!

Great. Double check you have saved package.json and you can see a node_modules folder inside server/.

ⓧ I'd like to double check the full code

Your server/package.json should look like this:

{
  "name": "bookmark-manager-server",
  "version": "1.0.0",
  "description": "Bookmark Manager REST API",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "cors": "^2.8.6",
    "dotenv": "^16.4.7",
    "express": "^5.2.1",
    "mongoose": "^9.7.3"
  }
}

Your .gitignore at the project root should look like this:

node_modules/
.env

Version numbers may differ slightly

The caret (^) before each version means "compatible with." Your patch versions might be slightly higher depending on when you run the install. That is normal.

Seeing permission errors during install?

Make sure you are inside the server folder before running npm install. Run pwd to check your current location. It should end with bookmark-manager/server.

help me fix npm install permission errors in my bookmark manager server folder

Scaffold the React frontend and push to GitHub

Vite is a modern build tool that creates a ready-to-use React project in seconds. It generates all the starter files your frontend needs.

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

What might happen next?

If this is your first time running this command, npm may ask you to confirm installing the create-vite package. Type y and press Enter to continue.

On Windows with PowerShell, if you see a framework selection prompt instead of the project being created, run the command with three dashes instead: npm create vite@latest client --- --template react.

  • Move into the new client folder and install the React dependencies by running:
cd client
npm install
  • Move back to the project root by running:
cd ..
  • Confirm both folders are ready by running:
ls
  • You should see client, server, and .gitignore in the output.

Don't see the client folder?

Make sure you ran the Vite command from the bookmark-manager root folder, not from inside server. Run pwd to check. If you see bookmark-manager/server, run cd .. first and try the Vite command again.

help me troubleshoot why the Vite scaffold command did not create a client folder

Now commit your work and push it to GitHub so you have a remote backup of your code.

  • Stage and commit all your files by running:
git add .
git commit -m "Initial project setup"
  • Go to github.com/new in your browser.
  • Enter bookmark-manager as the Repository name.
  • Leave everything else as the defaults and click Create repository.
  • GitHub shows a quick setup page with commands. Copy the two commands under the heading that says "push an existing repository from the command line."
  • Paste and run those commands in your VS Code terminal. They look like this:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/bookmark-manager.git"]]
git push -u origin main

What if Git says the branch is called master?

Some Git installations default to master instead of main. If you see an error about main not existing, run git branch -M main first to rename the branch, then try the push again.

  • Refresh your GitHub repository page. You should see your server folder, client folder, and .gitignore listed.

Push was rejected or authentication failed?

Make sure you are signed into GitHub in your terminal. If prompted for a password, GitHub now requires a personal access token instead of your account password. Generate one at Settings > Developer settings > Personal access tokens.

help me authenticate with GitHub from my terminal so I can push my code

Your project skeleton is ready with both folders in place and your code safely on GitHub. Next up, you will connect to a cloud database so your bookmarks can be stored permanently.

Set Up MongoDB Atlas

Your project structure is in place with a separate frontend and backend folder. Now you need somewhere to actually store the bookmarks your users will create.

MongoDB Atlas is a cloud database service that gives you a free database without needing a credit card. In this step, you'll set up an Atlas cluster and get the connection string your server needs to talk to the database.

In this step, get ready to:
  • Create a free MongoDB Atlas account and deploy a cluster.
  • Configure a database user and network access.
  • Get your connection string and save it in a .env file.
Create a free MongoDB Atlas account

MongoDB Atlas hosts your database in the cloud so you don't need to install or manage a database server on your computer. The free tier gives you 512 MB of storage that never expires.

  • Open your browser and go to mongodb.com/cloud/atlas/register.
  • Fill in your name, email, and a password, then click Create your Atlas account.
  • You can also sign up with your Google account if you prefer.
  • After signing in, Atlas will prompt you to deploy your first cluster.
  • Select the M0 option (the free tier).
  • Choose a cloud provider (any of AWS, GCP, or Azure is fine) and a region close to you.
  • Enter a name for your cluster in the Name box (the default name works perfectly).
  • Click Create to deploy the cluster.

What is a cluster?

A cluster is a group of servers that store your data. Atlas manages all the infrastructure for you, so you just interact with it through a connection string in your code.

The free M0 cluster gives you 512 MB of storage and never expires. It auto-pauses after 30 days of inactivity but resumes as soon as you connect again.

Configure database access

After clicking Create, Atlas opens the Security Quickstart wizard. You need to create a database user and allow your computer's IP address to connect.

  • In the Username field, enter a username you'll remember.
  • In the Password field, enter a password (or copy the auto-generated one Atlas suggests).
  • Write down your username and password somewhere safe. You'll need them in a moment.
  • Click Create Database User.
  • Under the network access section, click Add My Current IP Address.
  • Click Finish and Close.
  • Click Go to Overview.

Why do I need a database user and IP address?

The database user is like a login for your server code to authenticate with MongoDB. It's separate from your Atlas account login.

The IP access list tells Atlas which computers are allowed to connect. Adding your current IP address means your local development machine can reach the database.

Get your connection string and create the .env file

Your Express server needs a connection string to find and authenticate with your Atlas database. Atlas provides this string in a format that Mongoose understands.

  • In the Atlas dashboard, find your cluster and click Connect.
  • Select Drivers.
  • Make sure Node.js is selected as the driver.
  • Copy the connection string shown. It looks something like mongodb+srv://<db_username>:<db_password>@cluster0.abc123.mongodb.net/.
  • In the connection string, replace <db_password> with the database user password you created earlier.
  • Replace <db_username> with the database username you created earlier (it may already be filled in).

Special characters in your password

If your password contains characters like @, #, or ?, you need to URL-encode them. The simplest solution is to use a password that only contains letters and numbers.

Now save this connection string in an environment file so your server code can access it without hardcoding credentials.

  • Switch back to VS Code.
  • Create a new file called .env inside the server folder.
  • Paste the following into server/.env, replacing your-connection-string-here with the full connection string you just copied (with your password already filled in):
MONGODB_URI=your-connection-string-here
PORT=5000

What does this file do?

MONGODB_URI holds the full connection string including your username, password, and cluster address. Your server code will read this value to connect to the database.

PORT tells Express which port to listen on. Setting it here keeps all configuration in one place.

  • Save server/.env.
  • Confirm that your .gitignore file (in the project root) already includes .env on its own line. This prevents your connection string from being pushed to GitHub.

Not sure if .gitignore is set up?

Open the .gitignore file in your project root. It should contain .env on its own line. If you set up your project in Step 1, this is already there.

help me check if my .gitignore is protecting my .env file

✔️ Awesome, I've got everything!

Great. Make sure you've saved server/.env before moving on.

ⓧ I'd like to double check the full code

Your server/.env file should look like this (with your actual connection string instead of the placeholder):

MONGODB_URI=your-connection-string-here
PORT=5000

Your database is deployed and your server has the credentials it needs to connect. Next up, you'll build the Express API that actually talks to MongoDB.

Build the Express REST API

Your MongoDB Atlas database is live and waiting for data. Now it's time to build the server that connects your future frontend to that database.

Express is a lightweight framework for handling HTTP requests in Node.js. You'll create three files that together form a complete REST API for managing bookmarks.

In this step, get ready to:
  • Create the Express server file that connects to MongoDB.
  • Define the data shape for bookmarks using a Mongoose schema.
  • Build API routes for creating, reading, and deleting bookmarks.
Create the Express server file

The server file is the entry point for your backend. It wires together the database connection, middleware, and route handlers into a single running application.

  • In the VS Code Explorer sidebar, right-click the server folder.
  • Click New File.
  • Type index.js and press Enter.
  • Paste the following code into index.js:
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();

// Import bookmark routes
const bookmarkRoutes = require('./routes/bookmarks');

const app = express();
const PORT = process.env.PORT || 5000;

// Enable cross-origin requests and JSON body parsing
app.use(cors());
app.use(express.json());

// Connect to MongoDB using the connection string from .env
mongoose.connect(process.env.MONGODB_URI)
  .then(() => console.log('Connected to MongoDB'))
  .catch((err) => console.error('MongoDB connection error:', err));

// Mount bookmark routes at /api/bookmarks
app.use('/api/bookmarks', bookmarkRoutes);

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

  • require('dotenv').config() loads your .env file so the connection string is available as process.env.MONGODB_URI.
  • app.use(cors()) allows your React frontend (running on a different port) to make requests to this server.
  • app.use(express.json()) parses incoming JSON request bodies so you can read them in your routes.
  • mongoose.connect() opens a connection to your Atlas cluster. The .then() and .catch() log whether it succeeded or failed.

What is CORS?

CORS stands for Cross-Origin Resource Sharing. Browsers block requests between different origins (like port 5173 to port 5000) by default. The cors middleware tells the browser that your server allows these cross-origin requests.

  • Save index.js.

You won't be able to run this file yet. It requires the routes file you'll create in a moment.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();

// Import bookmark routes
const bookmarkRoutes = require('./routes/bookmarks');

const app = express();
const PORT = process.env.PORT || 5000;

// Enable cross-origin requests and JSON body parsing
app.use(cors());
app.use(express.json());

// Connect to MongoDB using the connection string from .env
mongoose.connect(process.env.MONGODB_URI)
  .then(() => console.log('Connected to MongoDB'))
  .catch((err) => console.error('MongoDB connection error:', err));

// Mount bookmark routes at /api/bookmarks
app.use('/api/bookmarks', bookmarkRoutes);

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Define the bookmark schema

Mongoose is a library that lets you define the shape of your data (called a schema) and interact with MongoDB using JavaScript objects instead of raw database commands.

  • In the Explorer sidebar, right-click the server folder.
  • Click New File.
  • Type models/Bookmark.js and press Enter. VS Code creates the models folder automatically.
  • Paste the following code into Bookmark.js:
const mongoose = require('mongoose');

// Define the shape of a bookmark document
const bookmarkSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
  },
  url: {
    type: String,
    required: true,
  },
  tag: {
    type: String,
    default: 'general',
  },
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

// Create and export the Bookmark model
module.exports = mongoose.model('Bookmark', bookmarkSchema);

What does this code do?

  • bookmarkSchema defines four fields that every bookmark document will have in MongoDB.
  • title and url are required. If a request is missing either, Mongoose rejects it.
  • tag defaults to 'general' if the user doesn't provide one.
  • createdAt records when the bookmark was saved, so you can sort by newest first.
  • mongoose.model('Bookmark', bookmarkSchema) creates a model you can use to find, create, and delete documents in MongoDB.
  • Save Bookmark.js.

You won't see a change yet. The routes file you create next imports this model to read and write bookmarks.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

const mongoose = require('mongoose');

// Define the shape of a bookmark document
const bookmarkSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
  },
  url: {
    type: String,
    required: true,
  },
  tag: {
    type: String,
    default: 'general',
  },
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

// Create and export the Bookmark model
module.exports = mongoose.model('Bookmark', bookmarkSchema);
Create the API routes

Routes define what happens when a request arrives at a specific URL. You'll create three endpoints: one to get all bookmarks, one to add a new bookmark, and one to delete a bookmark.

  • In the Explorer sidebar, right-click the server folder.
  • Click New File.
  • Type routes/bookmarks.js and press Enter. VS Code creates the routes folder automatically.
  • Paste the following code into bookmarks.js:
const express = require('express');
const router = express.Router();
const Bookmark = require('../models/Bookmark');

// GET all bookmarks, sorted newest first
router.get('/', async (req, res) => {
  try {
    const bookmarks = await Bookmark.find().sort({ createdAt: -1 });
    res.json(bookmarks);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// POST a new bookmark
router.post('/', async (req, res) => {
  const bookmark = new Bookmark({
    title: req.body.title,
    url: req.body.url,
    tag: req.body.tag,
  });

  try {
    const newBookmark = await bookmark.save();
    res.status(201).json(newBookmark);
  } catch (err) {
    res.status(400).json({ message: err.message });
  }
});

// DELETE a bookmark by ID
router.delete('/:id', async (req, res) => {
  try {
    const bookmark = await Bookmark.findByIdAndDelete(req.params.id);
    if (!bookmark) {
      return res.status(404).json({ message: 'Bookmark not found' });
    }
    res.json({ message: 'Bookmark deleted' });
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

module.exports = router;

What does this code do?

Each route handler responds to a different HTTP method:

  • GET / fetches every bookmark from the database and returns them as JSON, sorted newest first.
  • POST / reads title, url, and tag from the request body, creates a new Bookmark document, and saves it to MongoDB.
  • DELETE /:id finds a bookmark by its unique ID and removes it. If no bookmark matches, it returns a 404 error.

Because index.js mounts these routes at /api/bookmarks, the full URLs become /api/bookmarks (GET and POST) and /api/bookmarks/:id (DELETE).

What are CRUD operations?

CRUD stands for Create, Read, Update, and Delete. These are the four basic operations for any data-driven application. Your API covers three of the four (no Update yet), which is the standard starting point for a first project.

  • Save bookmarks.js.

All three files are ready. Time to start the server and confirm everything connects.

  • Open your terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
  • Navigate into the server folder by running this command:
cd server
  • Start the Express server by running:
node index.js

You should see two messages in the terminal:

Connected to MongoDB
Server running on port 5000
  • Open a second terminal by clicking the + icon in the terminal panel.
  • Test the API by running this command:
curl http://localhost:5000/api/bookmarks

You should see an empty array [] returned. This confirms your server is connected to MongoDB and responding to requests.

Not seeing Connected to MongoDB?

  • Check your .env file. Make sure MONGODB_URI has your actual Atlas connection string with the password filled in (not the placeholder <password>).
  • Confirm your IP address is still in the Atlas network access list. If you're on a different network than when you set it up, add your new IP.
  • Make sure you're running node index.js from inside the server folder, not the project root.

Help me debug my MongoDB connection error.

Seeing command not found: curl?

If curl is not available on your system, open your browser and navigate to http://localhost:5000/api/bookmarks instead. You should see [] displayed on the page.

✔️ Awesome, I've got everything!

Great. Your API is running and responding to requests.

ⓧ I'd like to double check the full code

const express = require('express');
const router = express.Router();
const Bookmark = require('../models/Bookmark');

// GET all bookmarks, sorted newest first
router.get('/', async (req, res) => {
  try {
    const bookmarks = await Bookmark.find().sort({ createdAt: -1 });
    res.json(bookmarks);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// POST a new bookmark
router.post('/', async (req, res) => {
  const bookmark = new Bookmark({
    title: req.body.title,
    url: req.body.url,
    tag: req.body.tag,
  });

  try {
    const newBookmark = await bookmark.save();
    res.status(201).json(newBookmark);
  } catch (err) {
    res.status(400).json({ message: err.message });
  }
});

// DELETE a bookmark by ID
router.delete('/:id', async (req, res) => {
  try {
    const bookmark = await Bookmark.findByIdAndDelete(req.params.id);
    if (!bookmark) {
      return res.status(404).json({ message: 'Bookmark not found' });
    }
    res.json({ message: 'Bookmark deleted' });
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

module.exports = router;

Your API is live and responding to requests. In the next step, you'll build the React frontend that calls these endpoints and displays bookmarks in the browser.

Build the React Frontend

Your backend is working. The Express API can receive requests and talk to MongoDB. Now you need a way for users to actually interact with it.

In this step, you will build the React frontend that displays bookmarks and gives users a form to add new ones. You will create reusable components that handle their own state and render the UI.

In this step, get ready to:
  • Replace the default Vite scaffold with the Bookmark Manager layout.
  • Create a BookmarkForm component with controlled inputs.
  • Create a BookmarkList component that renders bookmarks.
Replace the default App.jsx with the Bookmark Manager layout

The Vite scaffold comes with demo content you do not need. You will replace it with a clean component that renders the app heading and placeholders for the form and list components.

  • In VS Code, open client/src/App.jsx.
  • Select all the existing code and delete it.
  • Replace it with the following code:
import { useState, useEffect } from 'react';
import BookmarkForm from './components/BookmarkForm';
import BookmarkList from './components/BookmarkList';
import './App.css';

function App() {
  // Store all bookmarks in state
  const [bookmarks, setBookmarks] = useState([]);

  return (
    <div className="app">
      <h1>Bookmark Manager</h1>
      <BookmarkForm onAdd={() => {}} />
      <BookmarkList bookmarks={bookmarks} onDelete={() => {}} />
    </div>
  );
}

export default App;

What does this code do?

This sets up the main app structure with three key pieces.

  • useState([]) creates an empty array to hold bookmarks. When this array changes, React re-renders the UI automatically.
  • BookmarkForm and BookmarkList are components you will create next. For now, onAdd and onDelete are placeholder functions that do nothing.
  • useEffect is imported but not used yet. You will wire it up in the next step to fetch bookmarks from your API.
  • Save App.jsx.

You will not see anything in the browser yet because the imported components do not exist. That is expected.

Replace App.css with Bookmark Manager styles

The default Vite styles include demo animations and layout you do not need. You will replace them with styles for your bookmark form, list items, and tags.

  • Open client/src/App.css.
  • Select all the existing content and delete it.
  • Replace it with the following 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.5rem;
  margin-bottom: 2rem;
}

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

.bookmark-form button {
  padding: 0.5rem 1rem;
  background-color: #4f46e5;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

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

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

.bookmark-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
  margin-bottom: 0.5rem;
}

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

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

.bookmark-tag {
  display: inline-block;
  margin-left: 0.5rem;
  padding: 0.15rem 0.5rem;
  background-color: #f3f4f6;
  border-radius: 12px;
  font-size: 0.8rem;
  color: #6b7280;
}

.delete-btn {
  padding: 0.25rem 0.75rem;
  background-color: #ef4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

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

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

What do these styles do?

Each class name matches a component you are about to build.

  • .app centers the content with a max width so it looks good on wide screens.
  • .bookmark-form stacks the input fields vertically with a gap between them.
  • .bookmark-item uses flexbox to put the bookmark link on the left and the delete button on the right.
  • .bookmark-tag renders a small rounded pill next to each bookmark title.
  • Save App.css.

Seeing red underlines in App.jsx?

VS Code may show import errors because BookmarkForm and BookmarkList do not exist yet. These will disappear once you create the components in the next substep.

help me understand why VS Code shows import errors for files that do not exist yet

Create the BookmarkForm component

React apps are built from small, focused components. The BookmarkForm component handles user input for adding new bookmarks. It manages its own local state for the title, URL, and tag fields.

  • Create a components folder inside client/src/ by running this command from your client folder:
mkdir src/components
  • Create a new file at client/src/components/BookmarkForm.jsx and add the following code:
import { useState } from 'react';

function BookmarkForm({ onAdd }) {
  // Local state for each input field
  const [title, setTitle] = useState('');
  const [url, setUrl] = useState('');
  const [tag, setTag] = useState('general');

  const handleSubmit = (e) => {
    // Prevent the browser from refreshing the page
    e.preventDefault();
    if (!title || !url) return;
    // Pass the new bookmark data up to the parent
    onAdd({ title, url, tag });
    // Clear the form fields after submission
    setTitle('');
    setUrl('');
    setTag('general');
  };

  return (
    <form onSubmit={handleSubmit} className="bookmark-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
      />
      <input
        type="text"
        placeholder="Tag (e.g. work, learning)"
        value={tag}
        onChange={(e) => setTag(e.target.value)}
      />
      <button type="submit">Add Bookmark</button>
    </form>
  );
}

export default BookmarkForm;

What does this component do?

BookmarkForm uses a pattern called controlled inputs. Each input field is tied to a piece of React state so you always know what the user typed.

  • { onAdd } is a prop passed down from App.jsx. It is the function the parent uses to handle new bookmarks.
  • e.preventDefault() stops the default HTML form behavior of refreshing the page on submit.
  • After calling onAdd, the form resets all fields back to their defaults so the user can immediately add another bookmark.
  • Save BookmarkForm.jsx.

Form not rendering?

Make sure the file is saved at client/src/components/BookmarkForm.jsx (not client/components/). The import path in App.jsx expects it inside src/components/.

help me fix an import error for BookmarkForm in my React project

Create the BookmarkList component

The BookmarkList component receives an array of bookmarks and renders each one as a clickable link with its tag and a delete button.

  • Create a new file at client/src/components/BookmarkList.jsx and add the following code:
function BookmarkList({ bookmarks, onDelete }) {
  // Show a friendly message if no bookmarks exist yet
  if (bookmarks.length === 0) {
    return <p className="empty-message">No bookmarks yet. Add one above!</p>;
  }

  return (
    <ul className="bookmark-list">
      {bookmarks.map((bookmark) => (
        <li key={bookmark._id} className="bookmark-item">
          <div className="bookmark-info">
            <a href={bookmark.url} target="_blank" rel="noopener noreferrer">
              {bookmark.title}
            </a>
            <span className="bookmark-tag">{bookmark.tag}</span>
          </div>
          <button onClick={() => onDelete(bookmark._id)} className="delete-btn">
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

export default BookmarkList;

What does this component do?

BookmarkList handles two scenarios: an empty state and a populated list.

  • When bookmarks.length === 0, it renders a friendly message instead of an empty list.
  • bookmarks.map() loops through every bookmark and creates a list item for each one. The key prop uses _id from MongoDB so React can efficiently track which items change.
  • target="_blank" opens bookmarked links in a new tab. rel="noopener noreferrer" is a security best practice that prevents the new page from accessing your app.
  • Save BookmarkList.jsx.
  • Start the Vite dev server by running this command from the client folder:
npm run dev
  • Open the URL shown in your terminal (usually http://localhost:5173) in your browser.

You should see the Bookmark Manager heading, three input fields (Title, URL, Tag), an Add Bookmark button, and the message "No bookmarks yet. Add one above!" below.

Seeing a blank page or errors?

Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and check for error messages.

  • If you see "Failed to resolve import", double check that BookmarkForm.jsx and BookmarkList.jsx are inside client/src/components/.
  • If you see a syntax error, compare your files against the full code in the double-check tab below.

help me fix a blank page error in my Vite React app

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file in this step.

import { useState, useEffect } from 'react';
import BookmarkForm from './components/BookmarkForm';
import BookmarkList from './components/BookmarkList';
import './App.css';

function App() {
  const [bookmarks, setBookmarks] = useState([]);

  return (
    <div className="app">
      <h1>Bookmark Manager</h1>
      <BookmarkForm onAdd={() => {}} />
      <BookmarkList bookmarks={bookmarks} onDelete={() => {}} />
    </div>
  );
}

export default App;
.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.5rem;
  margin-bottom: 2rem;
}

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

.bookmark-form button {
  padding: 0.5rem 1rem;
  background-color: #4f46e5;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

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

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

.bookmark-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
  margin-bottom: 0.5rem;
}

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

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

.bookmark-tag {
  display: inline-block;
  margin-left: 0.5rem;
  padding: 0.15rem 0.5rem;
  background-color: #f3f4f6;
  border-radius: 12px;
  font-size: 0.8rem;
  color: #6b7280;
}

.delete-btn {
  padding: 0.25rem 0.75rem;
  background-color: #ef4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

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

.empty-message {
  text-align: center;
  color: #6b7280;
}
import { useState } from 'react';

function BookmarkForm({ onAdd }) {
  const [title, setTitle] = useState('');
  const [url, setUrl] = useState('');
  const [tag, setTag] = useState('general');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!title || !url) return;
    onAdd({ title, url, tag });
    setTitle('');
    setUrl('');
    setTag('general');
  };

  return (
    <form onSubmit={handleSubmit} className="bookmark-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
      />
      <input
        type="text"
        placeholder="Tag (e.g. work, learning)"
        value={tag}
        onChange={(e) => setTag(e.target.value)}
      />
      <button type="submit">Add Bookmark</button>
    </form>
  );
}

export default BookmarkForm;
function BookmarkList({ bookmarks, onDelete }) {
  if (bookmarks.length === 0) {
    return <p className="empty-message">No bookmarks yet. Add one above!</p>;
  }

  return (
    <ul className="bookmark-list">
      {bookmarks.map((bookmark) => (
        <li key={bookmark._id} className="bookmark-item">
          <div className="bookmark-info">
            <a href={bookmark.url} target="_blank" rel="noopener noreferrer">
              {bookmark.title}
            </a>
            <span className="bookmark-tag">{bookmark.tag}</span>
          </div>
          <button onClick={() => onDelete(bookmark._id)} className="delete-btn">
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

export default BookmarkList;

Your frontend is rendering. Next, you will wire it up to the Express API so bookmarks actually save to MongoDB and persist across page refreshes.

Connect Frontend to Backend

In the previous step, you built the React frontend with a form and a list. Right now those components exist in isolation. The form's onAdd and the list's onDelete props are hooked up to empty functions that do nothing.

In this step, you will connect the frontend to your Express API so that bookmarks flow from the browser to MongoDB and back. This is where the full request cycle comes alive.

In this step, get ready to:
  • Configure the Vite dev server to proxy API requests to Express.
  • Wire up fetch calls so the UI can create, read, and delete bookmarks.
  • Run both servers and verify end-to-end data persistence.
Add the proxy configuration to Vite

The Vite dev server runs on port 5173, while your Express API runs on port 5000. When React tries to call /api/bookmarks, the browser sends that request to port 5173 by default. A proxy tells Vite to forward any request starting with /api to your Express server instead.

  • In VS Code, open client/vite.config.js.
  • Replace the entire file contents with the following:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// Forward /api requests to the Express backend
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:5000',
    },
  },
});

What does this configuration do?

The server.proxy option intercepts any request whose path starts with /api and redirects it to http://localhost:5000. This means your React code can call fetch('/api/bookmarks') without worrying about cross-origin errors.

The string shorthand '/api': 'http://localhost:5000' keeps the path intact. A request to /api/bookmarks on the Vite server arrives at http://localhost:5000/api/bookmarks on Express.

This proxy only runs during development. In production you would deploy both servers behind a single domain or reverse proxy.

  • Save client/vite.config.js.

Why a proxy instead of a full URL?

Without a proxy, you would need to write fetch('http://localhost:5000/api/bookmarks') everywhere. That hardcodes the backend's address into your frontend code. The proxy lets you use relative paths like /api/bookmarks, which works identically in development and production.

Add fetch calls in App.jsx

Your React components already know how to render bookmarks and handle form submissions. They just need real functions that talk to the API. You will add three async functions: one to load bookmarks, one to create a bookmark, and one to delete a bookmark.

  • In VS Code, open client/src/App.jsx.
  • Find the line const [bookmarks, setBookmarks] = useState([]);.
  • Directly below that line, add the fetchBookmarks function and the useEffect call:
  const fetchBookmarks = async () => {
    try {
      const response = await fetch('/api/bookmarks');
      const data = await response.json();
      setBookmarks(data);
    } catch (error) {
      console.error('Error fetching bookmarks:', error);
    }
  };

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

What does this code do?

The fetchBookmarks function sends a GET request to /api/bookmarks, which the Vite proxy forwards to Express. Express queries MongoDB and returns the bookmarks as JSON.

The useEffect hook with an empty dependency array [] runs fetchBookmarks exactly once when the component first loads. This populates the bookmark list on page load.

  • Below the useEffect block, add the addBookmark and deleteBookmark functions:
  const addBookmark = async (bookmark) => {
    try {
      const response = await fetch('/api/bookmarks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(bookmark),
      });
      const newBookmark = await response.json();
      setBookmarks([newBookmark, ...bookmarks]);
    } catch (error) {
      console.error('Error adding bookmark:', error);
    }
  };

  const deleteBookmark = async (id) => {
    try {
      await fetch(`/api/bookmarks/${id}`, { method: 'DELETE' });
      setBookmarks(bookmarks.filter((b) => b._id !== id));
    } catch (error) {
      console.error('Error deleting bookmark:', error);
    }
  };

What does this code do?

The addBookmark function sends a POST request with the bookmark data as JSON. When Express saves it to MongoDB and returns the new document, the function prepends it to the existing bookmarks array so it appears at the top of the list instantly.

The deleteBookmark function sends a DELETE request with the bookmark's _id. After Express removes the document from MongoDB, the function filters it out of the local state so it disappears from the UI without a full page reload.

Now you need to pass these real functions to the components instead of the empty placeholders.

  • In the same file, find the return statement. Locate these two lines:
      <BookmarkForm onAdd={() => {}} />
      <BookmarkList bookmarks={bookmarks} onDelete={() => {}} />
  • Replace those two lines with:
      <BookmarkForm onAdd={addBookmark} />
      <BookmarkList bookmarks={bookmarks} onDelete={deleteBookmark} />
  • Save client/src/App.jsx.

Seeing a syntax error in the console?

Make sure the fetchBookmarks, addBookmark, and deleteBookmark functions are placed inside the App function, between the useState line and the return statement. Placing them outside the component causes a scope error.

Also check that every opening brace { has a matching closing brace }. A missing brace after deleteBookmark is a common typo.

help me fix a syntax error in my App.jsx

✔️ Awesome, I've got everything!

Great. Double check you have saved client/src/App.jsx before moving on.

ⓧ I'd like to double check the full code

import { useState, useEffect } from 'react';
import BookmarkForm from './components/BookmarkForm';
import BookmarkList from './components/BookmarkList';
import './App.css';

function App() {
  const [bookmarks, setBookmarks] = useState([]);

  const fetchBookmarks = async () => {
    try {
      const response = await fetch('/api/bookmarks');
      const data = await response.json();
      setBookmarks(data);
    } catch (error) {
      console.error('Error fetching bookmarks:', error);
    }
  };

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

  const addBookmark = async (bookmark) => {
    try {
      const response = await fetch('/api/bookmarks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(bookmark),
      });
      const newBookmark = await response.json();
      setBookmarks([newBookmark, ...bookmarks]);
    } catch (error) {
      console.error('Error adding bookmark:', error);
    }
  };

  const deleteBookmark = async (id) => {
    try {
      await fetch(`/api/bookmarks/${id}`, { method: 'DELETE' });
      setBookmarks(bookmarks.filter((b) => b._id !== id));
    } catch (error) {
      console.error('Error deleting bookmark:', error);
    }
  };

  return (
    <div className="app">
      <h1>Bookmark Manager</h1>
      <BookmarkForm onAdd={addBookmark} />
      <BookmarkList bookmarks={bookmarks} onDelete={deleteBookmark} />
    </div>
  );
}

export default App;
Run both servers and test the full app

Your frontend and backend code is ready. To test the full request cycle, you need both servers running at the same time in separate terminal windows.

  • In VS Code, open a new terminal by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
  • Start the Express server by running this command from the server folder:
cd server
node index.js
  • You should see Connected to MongoDB and Server running on port 5000 in the terminal output.
  • Open a second terminal by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows) again.
  • Start the Vite dev server by running this command from the client folder:
cd client
npm run dev
  • Open the URL shown in the terminal output (usually http://localhost:5173) in your browser.
  • In the Title field, enter NextWork.
  • In the URL field, enter https://nextwork.org.
  • In the Tag field, enter learning.
  • Click Add Bookmark.

The bookmark appears in the list below the form. Now refresh the page to verify it persists.

  • Press Cmd+R (macOS) or Ctrl+R (Windows) to refresh the page.

The bookmark is still there. Your data survived the refresh because it lives in MongoDB now, not just in React state.

  • Click the red Delete button next to your bookmark.

The bookmark disappears from the list and is permanently removed from the database.

What is the full request cycle?

When you click Add Bookmark, this is what happens: React calls addBookmark, which sends a POST request to /api/bookmarks. Vite proxies that to Express on port 5000. Express runs the route handler, which creates a Mongoose document and saves it to MongoDB. MongoDB returns the saved document. Express sends it back as JSON. React receives the response and updates state, which re-renders the list.

  • Verify the data in Atlas by returning to your browser tab with MongoDB Atlas open.
  • Click Browse Collections (or Data Explorer) on your cluster.
  • You should see a test database with a bookmarks collection. If you added bookmarks without deleting them all, you will see the documents listed here.

Not seeing bookmarks appear?

Check your first terminal. If it does not show Connected to MongoDB, your .env file may have an incorrect connection string or the wrong password. Double check the MONGODB_URI value in server/.env.

If the Express server is running but bookmarks still do not appear, open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and look for network errors. A 500 error means the server crashed. A 404 means the proxy is not forwarding correctly.

help me debug why my bookmarks are not showing up in the browser

  • Commit your progress and push to GitHub by running these commands in a new terminal:
cd ~/Desktop/bookmark-manager
git add .
git commit -m "Connect frontend to backend with fetch calls"
git push

Git push rejected?

If you have not set up a remote repository yet, create one on GitHub first. Then link it by running:

git remote add origin https://github.com/YOUR_USERNAME/bookmark-manager.git
git push -u origin main

help me push my project to GitHub for the first time

Your fullstack app is working end to end. Data flows from the React form, through the Express API, into MongoDB, and back to the browser. You now have a complete CRUD application with a professional project structure.

Secret mission

Add Tag Filtering

Your bookmark list is growing, but every bookmark shows up at once. What if you could click a tag and instantly see only the bookmarks that match? In this secret mission, you'll add client-side filtering so users can narrow their view by tag with a single click.

Clean Up Your Resources

Clean Up Your Resources

This project uses a free MongoDB Atlas cluster with no ongoing costs. Decide whether to keep your resources, pause them, or delete them entirely.

Resources you used:

  • MongoDB Atlas free M0 cluster (free forever, auto-pauses after 30 days of inactivity).
  • Local project folder on your Desktop with server/ and client/ directories.
  • GitHub repository containing your project code.

Keep everything running

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

  • Your Atlas M0 cluster stays free forever. It auto-pauses after 30 days of inactivity and resumes when you reconnect.
  • Your local project folder remains on your Desktop.
  • Your GitHub repository stays in your account.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and cloud data stay intact.

  • Stop the Express server by pressing Ctrl+C in the terminal where it is running.
  • Stop the Vite dev server by pressing Ctrl+C in the other terminal.
  • Your Atlas cluster auto-pauses after 30 days of inactivity. No manual action needed.
  • To resume later, start the servers again and your data will still be there.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Stop both running servers by pressing Ctrl+C in each terminal.
  • Delete your local project folder. On macOS/Linux, run:
rm -rf ~/Desktop/[[PROJECT_FOLDER="your-project-folder-name"]]

On Windows, run:

Remove-Item -Recurse -Force ~\Desktop\[[PROJECT_FOLDER="your-project-folder-name"]]
  • Delete your MongoDB Atlas cluster:
  • Go to the Atlas console and click Clusters in the sidebar under Database.
  • Click the ... (ellipsis) next to your cluster name.
  • Click Terminate from the drop-down menu.
  • Enter your cluster name to confirm, then click Terminate.
  • Delete your GitHub repository:
  • Go to your repository page on GitHub.
  • Click Settings in the top navigation.
  • Scroll to the bottom to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

That's a Wrap!

That's a Wrap!

Nice work. You just built a complete fullstack web application from scratch, covering every layer from the database to the UI.

You've learned how to:

  • Built a React frontend with components, state management, and event handling.
  • Built a Node.js/Express REST API with GET, POST, and DELETE endpoints.
  • Connected the full request cycle end-to-end: React component to fetch call to Express route to Mongoose query to MongoDB and back to a state update in the browser.
  • Secret Mission: Added tag-based filtering to the UI so users can view bookmarks by category.

Ready to quiz yourself?