Build a Full Stack Bookmark Manager
Wire a React frontend to an Express REST API backed by SQLite.
Introduction
30 Second Summary
Every time you find a useful article or tool online, you probably bookmark it in your browser. But what if you could build your own bookmark manager from scratch, controlling exactly how your data is stored and displayed?
In this project, you will build a full stack bookmark manager with a React frontend, an Express REST API, and a SQLite database that persists your data between server restarts.
What You'll Build
A bookmark manager application where a React frontend communicates with an Express backend through REST API endpoints, with data stored in a SQLite database file.
By the end of this project, you'll have:
- A React frontend with a form to add bookmarks and a dynamic list displaying them.
- An Express REST API handling GET, POST, and DELETE requests.
- A SQLite database storing bookmarks with title, URL, and creation date.
- Secret Mission: Add edit functionality with PUT requests and category filtering with query parameters.
Are there any prerequisites?
You should be comfortable with JavaScript fundamentals like variables, functions, arrays, and async/await. Basic HTML and CSS knowledge is also helpful.
You do not need prior experience with React, Express, or databases. This project teaches each of those from scratch.
Set Up the Project Structure
Every full stack app has two main pieces: a frontend that users interact with and a backend server that stores data. You'll set these up as separate folders inside one project directory.
In this step, you'll create the project structure, install server dependencies with npm, and scaffold a React frontend with Vite. By the end, you'll have two environments ready for code.
In this step, get ready to:
- Create the project directory and initialize the server with npm.
- Scaffold the React client using Vite.
- Verify both environments are working.
Create the project root and initialize the server
The project uses a server/ folder for the backend and a client/ folder for the frontend. This separation keeps backend logic completely independent from frontend code.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder, the server subfolder, and move into it by running these commands:
mkdir bookmark-manager
cd bookmark-manager
mkdir server
cd server
- Confirm the folders exist by running:
ls ..
You should see server listed in the output.
- Initialize a new Node.js project inside the server/ folder by running:
npm init -y
What does this command do?
npm init -y creates a package.json file with default values. This file tracks your project's name, version, and dependencies.
The default package.json needs a few edits before it's ready for this project.
- Open server/package.json in your editor and replace its entire contents with this:
{
"name": "bookmark-server",
"version": "1.0.0",
"description": "Bookmark manager REST API",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js"
}
}
What did we change?
- "type": "module" tells Node.js to use modern ES module import/export syntax instead of the older require() style.
- The start script gives us a simple npm start command to run the server later.
- The name and description fields identify this package clearly.
- Save package.json.
Now you'll install the three server dependencies.
- Install Express, better-sqlite3, and cors by running:
npm install express@5.2.1 better-sqlite3@12.10.0 cors@2.8.6 --save-exact
What are these three packages?
- express is a web framework that handles HTTP requests and routes them to your code.
- better-sqlite3 is a fast SQLite database driver. It stores data in a single file on disk.
- cors allows your frontend (running on one port) to make requests to your backend (running on a different port).
- Verify the packages installed correctly by running:
npm ls
You should see express@5.2.1, better-sqlite3@12.10.0, and cors@2.8.6 listed without any errors.
Seeing compilation errors for better-sqlite3?
better-sqlite3 includes native code that sometimes needs to compile during install. On macOS, this requires Xcode Command Line Tools.
- Run xcode-select --install in your terminal, then try npm install again.
Help me fix a better-sqlite3 installation error
Scaffold the React client with Vite
Vite is a build tool that scaffolds a ready-to-run React project in seconds. It handles bundling, hot reload, and development server setup for you.
- Move back to the bookmark-manager root folder by running:
cd ..
- Scaffold a new React project in a folder called client by running:
npm create vite@latest client -- --template react
What will I see?
npm will ask: Need to install the following packages: create-vite@latest. Ok to proceed? (y). Press y then Enter to continue.
This downloads Vite's scaffolding tool, creates the client/ folder, and fills it with React starter files.
- Move into the new client folder and install its dependencies by running:
cd client
npm install
- Confirm the scaffold created the expected files by running:
ls src
You should see App.jsx, App.css, main.jsx, and other starter files.
Don't see the client folder?
Make sure you ran the scaffold command from the bookmark-manager root folder, not from inside server/. If you're unsure where you are, run pwd to see your current directory.
Help me fix my Vite scaffold setup
Verify both environments are working
Before writing any code, confirm that both the server dependencies and the client dev server are set up correctly.
- Start the Vite development server by running:
npm run dev
You should see output showing the dev server running at http://localhost:5173.
- Press Ctrl+C in the terminal to stop the dev server.
What about the server?
The server can't start yet because it has no code file to run. The successful npm install and npm ls output you saw earlier confirms the server packages are ready. You'll write the server code in the next step.
- Open the bookmark-manager folder in VS Code.
- Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette.
- Type File: Open Folder and select it.
- Navigate to your Desktop, select the bookmark-manager folder, and click Open.
You should see both server/ and client/ folders in the VS Code Explorer sidebar.
Only seeing one folder?
Make sure you opened the bookmark-manager parent folder, not the server or client folder directly. The Explorer sidebar should show both subfolders at the top level.
Help me open the right folder in VS Code
✔️ Awesome, I've got everything!
Great. Double check you've saved server/package.json, then you're ready for the next step.
ⓧ I'd like to double check the full code
Here's what server/package.json should look like after installing dependencies:
{
"name": "bookmark-server",
"version": "1.0.0",
"description": "Bookmark manager REST API",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"better-sqlite3": "12.10.0",
"cors": "2.8.6",
"express": "5.2.1"
}
}
Your project structure is ready. Next up, you'll build the REST API that handles creating, reading, and deleting bookmarks.
Build the REST API
Your project structure is ready. Now it's time to build the brains of the application.
A REST API is how your frontend will talk to your backend. It exposes specific URLs (called endpoints) that accept requests and return data. In this step, you'll build three endpoints that let your app create, read, and delete bookmarks.
In this step, get ready to:
- Create an Express server with middleware and a SQLite database.
- Add GET and POST endpoints for listing and creating bookmarks.
- Add a DELETE endpoint and test all three routes with curl.
Set up the Express server with database initialization
Your server needs a foundation before it can handle any requests. This means importing your packages, configuring middleware, and initializing the database.
- Create a new file called index.js inside your server folder.
- Add the server foundation by pasting this code into server/index.js:
import express from 'express';
import cors from 'cors';
import Database from 'better-sqlite3';
const app = express();
const PORT = 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Database setup
const db = new Database('bookmarks.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
What does this code do?
This file sets up the entire server skeleton in a few key sections:
- cors() allows your React frontend (running on a different port) to make requests to this server. Without it, the browser blocks cross-origin requests.
- express.json() parses incoming JSON request bodies so you can access req.body in your route handlers.
- new Database('bookmarks.db') creates (or opens) a SQLite database file. The WAL pragma improves performance for concurrent reads and writes.
- The CREATE TABLE IF NOT EXISTS statement creates the bookmarks table the first time the server runs, then skips it on subsequent starts.
Why SQLite instead of PostgreSQL?
SQLite stores your entire database in a single file with zero configuration. There is no database server to install or manage.
The SQL skills you learn here transfer directly to PostgreSQL and MySQL. For a local development project, SQLite removes all setup friction so you can focus on learning the API architecture.
- Save server/index.js.
- Start the server by switching to your terminal and running:
cd server
node index.js
- You should see Server running at http://localhost:3001 in your terminal.
- Stop the server by pressing Ctrl+C in the terminal. You'll restart it after adding the routes.
Server won't start?
- If you see a syntax error, make sure your server/package.json has "type": "module" in it. Without this, Node.js won't understand import statements.
- If you see an error about better-sqlite3 not being found, make sure you ran npm install inside the server folder in Step 1.
Help me debug my Express server startup error.
Add GET and POST endpoints
With the server foundation in place, you can add the route handlers that read and write bookmarks. Each endpoint maps to a specific HTTP method and URL path.
- In server/index.js, find the closing }); of the db.exec( block (the table creation statement).
- Add the GET endpoint directly below it, before app.listen:
// GET all bookmarks
app.get('/api/bookmarks', (req, res) => {
const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all();
res.json(bookmarks);
});
What does this endpoint do?
- db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all() fetches every row from the bookmarks table, sorted with the newest first.
- res.json(bookmarks) sends the result as a JSON array back to the client.
- Now add the POST endpoint directly below the GET endpoint:
// POST a new bookmark
app.post('/api/bookmarks', (req, res) => {
const { title, url } = req.body;
if (!title || !url) {
return res.status(400).json({ error: 'Title and URL are required' });
}
const stmt = db.prepare('INSERT INTO bookmarks (title, url) VALUES (?, ?)');
const result = stmt.run(title, url);
const newBookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(newBookmark);
});
What does the POST endpoint do?
- It destructures title and url from the request body, then validates that both are present.
- The ? placeholders in the SQL query prevent SQL injection by safely inserting user-provided values.
- After inserting, it fetches the newly created bookmark using result.lastInsertRowid and returns it with a 201 Created status.
- Save server/index.js.
- Start the server again to confirm there are no syntax errors:
node index.js
- You should see Server running at http://localhost:3001 again. Keep it running for the next substep.
Seeing a syntax error?
- Check that your GET and POST route blocks are placed between the db.exec(...) closing }); and the app.listen(...) call. Routes defined after app.listen still work, but placing them before keeps the file organized.
- Make sure each route handler has matching opening and closing braces and parentheses.
Help me fix a syntax error in my Express route handlers.
Add the DELETE endpoint and test all three routes
The last piece of your CRUD API is the ability to remove bookmarks. After adding this endpoint, you'll test all three routes to confirm everything works.
- In server/index.js, add the DELETE endpoint below the POST endpoint (still before app.listen):
// DELETE a bookmark
app.delete('/api/bookmarks/:id', (req, res) => {
const { id } = req.params;
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 the DELETE endpoint do?
- The :id in the route path is a URL parameter. When someone requests DELETE /api/bookmarks/5, Express puts 5 into req.params.id.
- result.changes tells you how many rows were deleted. If zero, the bookmark didn't exist, so you return a 404 error.
- A 204 status means "success, no content to return." This is the standard response for successful deletions.
- Save server/index.js.
- Stop and restart the server (press Ctrl+C, then run node index.js again).
Now test all three endpoints. Open a second terminal window (keep the server running in the first one).
- Test the GET endpoint by running:
curl http://localhost:3001/api/bookmarks
- You should see [] (an empty JSON array, since you haven't added any bookmarks yet).
- Test the POST endpoint by creating a bookmark:
curl -X POST http://localhost:3001/api/bookmarks -H "Content-Type: application/json" -d '{"title":"NextWork","url":"https://www.nextwork.org"}'
- You should see a JSON object returned with an id, title, url, and created_at field.
- Test the DELETE endpoint by removing the bookmark you just created (replace 1 with the id from the POST response if different):
curl -X DELETE http://localhost:3001/api/bookmarks/1
- You won't see any output (that's the 204 No Content response). Confirm it worked by running the GET request again:
curl http://localhost:3001/api/bookmarks
- You should see [] again, confirming the bookmark was deleted.
Curl command not working?
- Make sure your server is still running in the other terminal. If you see "connection refused", the server has stopped.
- On Windows, single quotes in curl commands may not work in Command Prompt. Use double quotes instead and escape the inner quotes: -d "{\"title\":\"NextWork\",\"url\":\"https://www.nextwork.org\"}".
- If curl is not recognized, use PowerShell on Windows (it includes curl as an alias for Invoke-WebRequest). Alternatively, open http://localhost:3001/api/bookmarks in your browser to test the GET endpoint.
Help me test my Express API endpoints with curl.
✔️ Awesome, I've got everything!
Great. Double check you've saved server/index.js and that all three curl tests pass before moving on.
ⓧ I'd like to double check the full code
Here is the complete server/index.js file. Compare it to yours:
import express from 'express';
import cors from 'cors';
import Database from 'better-sqlite3';
const app = express();
const PORT = 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Database setup
const db = new Database('bookmarks.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
// GET all bookmarks
app.get('/api/bookmarks', (req, res) => {
const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all();
res.json(bookmarks);
});
// POST a new bookmark
app.post('/api/bookmarks', (req, res) => {
const { title, url } = req.body;
if (!title || !url) {
return res.status(400).json({ error: 'Title and URL are required' });
}
const stmt = db.prepare('INSERT INTO bookmarks (title, url) VALUES (?, ?)');
const result = stmt.run(title, url);
const newBookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(newBookmark);
});
// DELETE a bookmark
app.delete('/api/bookmarks/:id', (req, res) => {
const { id } = req.params;
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();
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Your REST API is fully functional. It can list, create, and delete bookmarks, all persisted in a SQLite database. Next up, you'll build the React frontend that talks to these endpoints.
Build the React Frontend
Your API is ready. It can receive requests, store bookmarks, and send them back.
Now you need a way for users to actually interact with it. In this step, you will build the React frontend that talks to your API. The frontend fetches bookmarks on load, lets users add new ones through a form, and provides a delete button on each entry.
In this step, get ready to:
- Create a bookmark list component that fetches and displays bookmarks from the API.
- Build an add bookmark form that POSTs new bookmarks and updates the list.
- Add a delete button that removes bookmarks from both the API and the UI.
Create the bookmark list component
The first thing your frontend needs to do is fetch bookmarks from the API when the page loads. You will set up the component's state, call the GET endpoint, and render each bookmark as a list item.
- In VS Code, open client/src/App.jsx.
- Select all the existing scaffold code and replace it with this:
import { useState, useEffect } from 'react';
import './App.css';
const API_URL = 'http://localhost:3001/api/bookmarks';
function App() {
// State for bookmark data and UI
const [bookmarks, setBookmarks] = useState([]);
const [title, setTitle] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
// Fetch bookmarks when the component first renders
useEffect(() => {
fetchBookmarks();
}, []);
// GET all bookmarks from the API
async function fetchBookmarks() {
try {
setLoading(true);
const res = await fetch(API_URL);
if (!res.ok) throw new Error('Failed to fetch bookmarks');
const data = await res.json();
setBookmarks(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
// Show loading state while fetching
if (loading) return <div className="app"><p>Loading bookmarks...</p></div>;
return (
<div className="app">
<h1>Bookmark Manager</h1>
{error && <p className="error">{error}</p>}
<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-url">{bookmark.url}</span>
</div>
</li>
))}
</ul>
{bookmarks.length === 0 && <p className="empty">No bookmarks yet. Add one above!</p>}
</div>
);
}
export default App;
What does this code do?
This component manages your bookmark data using React's useState hook. Five pieces of state track the bookmarks array, form input values, errors, and loading status.
The useEffect hook runs fetchBookmarks() once when the component mounts. This calls your Express API's GET endpoint and stores the response in state.
The JSX maps over the bookmarks array and renders each one as a clickable link with its URL displayed below.
- Save App.jsx.
Now replace the default styles. Open client/src/App.css, select all, and replace with this:
.app {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #1a1a1a;
}
.error {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.add-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.add-form button {
padding: 0.5rem 1rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.add-form button:hover {
background: #1d4ed8;
}
.bookmark-list {
list-style: none;
padding: 0;
}
.bookmark-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.bookmark-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bookmark-info a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}
.bookmark-info a:hover {
text-decoration: underline;
}
.bookmark-url {
font-size: 0.85rem;
color: #666;
}
.delete-btn {
padding: 0.25rem 0.75rem;
background: #fee;
color: #c00;
border: 1px solid #fcc;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #fdd;
}
.empty {
text-align: center;
color: #888;
font-style: italic;
}
What do these styles do?
The .app class centers the layout at 700px wide. Each .bookmark-item uses flexbox to put the bookmark info on the left and action buttons on the right.
You are including styles for the form and delete button now so you do not need to revisit this file later.
- Save App.css.
- Open your terminal, navigate to the client folder, and start the dev server by running:
cd client
npm run dev
- Open http://localhost:5173 in your browser. You should see the heading "Bookmark Manager" and the message "No bookmarks yet. Add one above!" in italic text.
Seeing a blank page or error?
Make sure you saved both App.jsx and App.css. Vite's dev server only picks up saved changes.
Check your terminal for red error text. A missing closing brace or parenthesis in the JSX is the most common cause.
help me debug my React component
Build the add bookmark form
Displaying bookmarks is useful, but you need a way to create them from the browser. You will add a form that sends a POST request to your API and immediately shows the new bookmark in the list.
- In App.jsx, find the closing brace of the fetchBookmarks function. Add this function directly below it:
async function addBookmark(e) {
e.preventDefault();
if (!title.trim() || !url.trim()) return;
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, url }),
});
if (!res.ok) throw new Error('Failed to add bookmark');
const newBookmark = await res.json();
setBookmarks([newBookmark, ...bookmarks]);
setTitle('');
setUrl('');
setError(null);
} catch (err) {
setError(err.message);
}
}
What does this function do?
The addBookmark function prevents the default form submission, then sends a POST request with the title and URL as JSON. When the API responds with the new bookmark, it prepends it to the list so it appears at the top.
After a successful add, it clears both input fields by resetting title and url state to empty strings.
- Now add the form JSX. In the return statement, find this line:
{error && <p className="error">{error}</p>}
- Directly below that line, add this form block:
<form onSubmit={addBookmark} className="add-form">
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button type="submit">Add Bookmark</button>
</form>
What does this form do?
The form's onSubmit attribute connects it to the addBookmark function. Each input is a controlled component. Its value comes from state and its onChange updates state on every keystroke.
The type="url" input gives you free browser validation for URL format.
- Save App.jsx and check your browser.
- You should see two input fields and an "Add Bookmark" button above the bookmark list.
Form not appearing?
Make sure the form code is placed between the error paragraph and the <ul> tag. If it is outside the return statement's parentheses, React will not render it.
help me find where to place my form JSX
Add a delete button to each bookmark
The last piece of CRUD functionality on the frontend is deletion. Each bookmark needs a button that calls your DELETE endpoint and removes the item from the displayed list.
- In App.jsx, find the closing brace of the addBookmark function. Add this function directly below it:
async function deleteBookmark(id) {
try {
const res = await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete bookmark');
setBookmarks(bookmarks.filter((b) => b.id !== id));
setError(null);
} catch (err) {
setError(err.message);
}
}
What does this function do?
The deleteBookmark function sends a DELETE request to /api/bookmarks/:id. On success, it uses filter to remove the deleted bookmark from state. This immediately updates the UI without needing to re-fetch the entire list.
- Now add the delete button to each bookmark. In the JSX, find this closing </div> tag inside the .bookmark-item list item:
</div>
- This is the closing tag of the bookmark-info div. Add the delete button directly below it, before the closing </li>:
<button
onClick={() => deleteBookmark(bookmark.id)}
className="delete-btn"
>
Delete
</button>
- Save App.jsx and check your browser.
- Each bookmark in the list should now have a red "Delete" button on the right side.
Delete button not showing?
Make sure the button is inside the <li> element but outside the .bookmark-info div. The .bookmark-item flexbox layout positions the button to the right only when it is a direct sibling of the div.
help me fix my delete button placement
✔️ Awesome, I've got everything!
Great. Double check you have saved both App.jsx and App.css.
ⓧ I'd like to double check the full code
Here is the complete client/src/App.jsx:
import { useState, useEffect } from 'react';
import './App.css';
const API_URL = 'http://localhost:3001/api/bookmarks';
function App() {
const [bookmarks, setBookmarks] = useState([]);
const [title, setTitle] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBookmarks();
}, []);
async function fetchBookmarks() {
try {
setLoading(true);
const res = await fetch(API_URL);
if (!res.ok) throw new Error('Failed to fetch bookmarks');
const data = await res.json();
setBookmarks(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function addBookmark(e) {
e.preventDefault();
if (!title.trim() || !url.trim()) return;
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, url }),
});
if (!res.ok) throw new Error('Failed to add bookmark');
const newBookmark = await res.json();
setBookmarks([newBookmark, ...bookmarks]);
setTitle('');
setUrl('');
setError(null);
} catch (err) {
setError(err.message);
}
}
async function deleteBookmark(id) {
try {
const res = await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete bookmark');
setBookmarks(bookmarks.filter((b) => b.id !== id));
setError(null);
} catch (err) {
setError(err.message);
}
}
if (loading) return <div className="app"><p>Loading bookmarks...</p></div>;
return (
<div className="app">
<h1>Bookmark Manager</h1>
{error && <p className="error">{error}</p>}
<form onSubmit={addBookmark} className="add-form">
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button type="submit">Add Bookmark</button>
</form>
<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-url">{bookmark.url}</span>
</div>
<button
onClick={() => deleteBookmark(bookmark.id)}
className="delete-btn"
>
Delete
</button>
</li>
))}
</ul>
{bookmarks.length === 0 && <p className="empty">No bookmarks yet. Add one above!</p>}
</div>
);
}
export default App;
And the complete client/src/App.css:
.app {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #1a1a1a;
}
.error {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.add-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.add-form button {
padding: 0.5rem 1rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.add-form button:hover {
background: #1d4ed8;
}
.bookmark-list {
list-style: none;
padding: 0;
}
.bookmark-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.bookmark-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bookmark-info a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}
.bookmark-info a:hover {
text-decoration: underline;
}
.bookmark-url {
font-size: 0.85rem;
color: #666;
}
.delete-btn {
padding: 0.25rem 0.75rem;
background: #fee;
color: #c00;
border: 1px solid #fcc;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #fdd;
}
.empty {
text-align: center;
color: #888;
font-style: italic;
}
Your React frontend is fully wired up. Next, you will run both the server and client together and test the full data flow from browser to database and back.
Connect and Test the Full Stack
You have a complete React frontend and a working Express API. Now it's time to bring them together and prove the full stack works end-to-end.
In this step, you'll run both servers simultaneously, test every CRUD operation through the browser, and verify that your data actually persists in the database.
In this step, get ready to:
- Start the server and client at the same time and test end-to-end data flow.
- Verify error handling displays user-friendly messages when the API is unreachable.
- Confirm data persistence by restarting the server and checking bookmarks survive.
Start both servers and verify end-to-end data flow
A full stack app needs two processes running at the same time: the backend API server and the frontend dev server. You'll need two separate terminal windows for this.
- In VS Code, open a terminal by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Start your Express server by running these commands:
cd server
npm start
You should see Server running at http://localhost:3001 printed in the terminal.
- Open a second terminal by clicking the + icon in the terminal panel (top-right corner of the terminal area).
- Start your Vite dev server by running these commands:
cd client
npm run dev
You should see Vite's output with a local URL at http://localhost:5173.
Why two terminals?
Each server is a long-running process that occupies its terminal. The Express server listens for API requests on port 3001. The Vite dev server serves your React app on port 5173 and hot-reloads changes.
In production, you'd typically serve both from a single server. During development, running them separately lets each tool do its job independently.
- Open your browser and navigate to http://localhost:5173.
- You should see the heading Bookmark Manager with an empty form and the message "No bookmarks yet. Add one above!" below it.
Now test the full data flow by adding a bookmark.
- Type NextWork in the Title field.
- Type https://nextwork.org in the URL field.
- Click Add Bookmark.
- The bookmark should appear instantly in the list below the form, with the title as a clickable link and the URL displayed underneath.
- Add a second bookmark with any title and URL you like.
- Click the Delete button on one of the bookmarks.
- The bookmark should disappear from the list immediately.
Bookmarks not appearing?
Make sure both terminals are still running. If you see an error message in the browser, your Express server on port 3001 may have stopped.
Check that you started the server with npm start from inside the server directory, not the project root.
Help me debug why my bookmarks aren't showing up.
Test error handling when the API is unreachable
A real user might visit your app while the server is down. Your code already includes error handling with try/catch blocks. Let's prove it works.
- Switch to the terminal running your Express server (the first terminal).
- Stop the server by pressing Ctrl+C.
- Switch back to your browser at http://localhost:5173.
- Try adding a new bookmark using the form.
You should see a red error message appear above the form that says "Failed to add bookmark". This is your .error CSS class in action, styled with a red background and red text.
How does the error handling work?
Each function in App.jsx wraps its fetch call in a try/catch block. When the server is unreachable, fetch throws a network error. The catch block saves the error message to the error state variable.
React re-renders the component, and the line {error && <p className="error">{error}</p>} displays the message to the user.
- Now restart the server. Switch to the first terminal and run:
npm start
- Refresh your browser page.
- The error message should disappear and your bookmarks from earlier should load again.
Still seeing the error after restarting?
Make sure you ran npm start from inside the server directory. If you're in the project root or the client directory, the server won't start.
Help me figure out why my error message won't go away.
Verify data persistence across server restarts
The whole point of using a database is that your data survives when the server stops. Let's prove that SQLite is actually persisting your bookmarks to disk.
- In your browser, add two or three bookmarks using the form. Use titles and URLs you'll recognize later.
- Switch to the terminal running your Express server.
- Stop the server by pressing Ctrl+C.
- Start the server again by running:
npm start
- Switch back to your browser and refresh the page.
- All the bookmarks you added before the restart should still be there, in the same order.
Where is the data stored?
Your bookmarks live in a file called bookmarks.db inside the server directory. This is a SQLite database file. When your Express server starts, better-sqlite3 opens this file and reads from it.
As long as bookmarks.db exists on disk, your data persists. The CREATE TABLE IF NOT EXISTS statement in your server code means it only creates the table if it doesn't already exist.
- Confirm the database file exists by running this in the server terminal (stop the server with Ctrl+C first):
ls bookmarks.db
You should see bookmarks.db in the output. This single file holds your entire database.
- Start the server back up again by running:
npm start
Bookmarks gone after restart?
Check that you're running npm start from the same server directory each time. If you run from a different directory, a new bookmarks.db file gets created there instead.
Help me figure out why my bookmarks disappeared after restarting.
✔️ Awesome, I've got everything!
Great. Make sure both your server and client are still running for the next section.
ⓧ I'd like to double check the full code
Here is the complete server/index.js:
import express from 'express';
import cors from 'cors';
import Database from 'better-sqlite3';
const app = express();
const PORT = 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Database setup
const db = new Database('bookmarks.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
// GET all bookmarks
app.get('/api/bookmarks', (req, res) => {
const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all();
res.json(bookmarks);
});
// POST a new bookmark
app.post('/api/bookmarks', (req, res) => {
const { title, url } = req.body;
if (!title || !url) {
return res.status(400).json({ error: 'Title and URL are required' });
}
const stmt = db.prepare('INSERT INTO bookmarks (title, url) VALUES (?, ?)');
const result = stmt.run(title, url);
const newBookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(newBookmark);
});
// DELETE a bookmark
app.delete('/api/bookmarks/:id', (req, res) => {
const { id } = req.params;
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();
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Here is the complete client/src/App.jsx:
import { useState, useEffect } from 'react';
import './App.css';
const API_URL = 'http://localhost:3001/api/bookmarks';
function App() {
const [bookmarks, setBookmarks] = useState([]);
const [title, setTitle] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBookmarks();
}, []);
async function fetchBookmarks() {
try {
setLoading(true);
const res = await fetch(API_URL);
if (!res.ok) throw new Error('Failed to fetch bookmarks');
const data = await res.json();
setBookmarks(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function addBookmark(e) {
e.preventDefault();
if (!title.trim() || !url.trim()) return;
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, url }),
});
if (!res.ok) throw new Error('Failed to add bookmark');
const newBookmark = await res.json();
setBookmarks([newBookmark, ...bookmarks]);
setTitle('');
setUrl('');
setError(null);
} catch (err) {
setError(err.message);
}
}
async function deleteBookmark(id) {
try {
const res = await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete bookmark');
setBookmarks(bookmarks.filter((b) => b.id !== id));
setError(null);
} catch (err) {
setError(err.message);
}
}
if (loading) return <div className="app"><p>Loading bookmarks...</p></div>;
return (
<div className="app">
<h1>Bookmark Manager</h1>
{error && <p className="error">{error}</p>}
<form onSubmit={addBookmark} className="add-form">
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button type="submit">Add Bookmark</button>
</form>
<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-url">{bookmark.url}</span>
</div>
<button
onClick={() => deleteBookmark(bookmark.id)}
className="delete-btn"
>
Delete
</button>
</li>
))}
</ul>
{bookmarks.length === 0 && <p className="empty">No bookmarks yet. Add one above!</p>}
</div>
);
}
export default App;
Here is the complete client/src/App.css:
.app {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #1a1a1a;
}
.error {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.add-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.add-form button {
padding: 0.5rem 1rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.add-form button:hover {
background: #1d4ed8;
}
.bookmark-list {
list-style: none;
padding: 0;
}
.bookmark-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.bookmark-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bookmark-info a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}
.bookmark-info a:hover {
text-decoration: underline;
}
.bookmark-url {
font-size: 0.85rem;
color: #666;
}
.delete-btn {
padding: 0.25rem 0.75rem;
background: #fee;
color: #c00;
border: 1px solid #fcc;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #fdd;
}
.empty {
text-align: center;
color: #888;
font-style: italic;
}
Your full stack application is complete. You've proven that the React frontend talks to the Express API, errors are handled gracefully, and data persists in SQLite. Next up, you'll tackle a bonus challenge that adds editing and filtering to your bookmark manager.
Secret mission
Edit Bookmarks and Filter by Category
Your bookmark manager can create and delete bookmarks. But what about updating them? And what happens when you have hundreds of bookmarks with no way to organize them? Add edit functionality with a PUT endpoint and category filtering with query parameters to complete the full CRUD experience.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The bookmark-manager project folder on your Desktop (contains server/, client/, and the SQLite database file).
- Running Node.js processes (Express server on port 3001 and Vite dev server on port 5173).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your bookmark manager.
- Your Express server continues running on port 3001.
- Your Vite dev server continues running on port 5173.
- All bookmarks remain saved in the bookmarks.db file.
Pause - I'll come back to this later
Shut down the running processes to free up terminal windows and memory. Your project files and database 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 files and bookmarks.db database remain on your Desktop. To resume later, open both terminals, navigate back into server/ and client/, and restart each with npm start and npm run dev.
Delete - I don't want to use this again
Remove all project files, the database, and installed packages. This cannot be undone.
- Stop both running processes by pressing Ctrl+C in each terminal.
- Delete the entire project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/bookmark-manager
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\bookmark-manager
- This removes your server/ directory, client/ directory, bookmarks.db database, and all node_modules packages.
That's a Wrap!
That's a Wrap!
Nice work! You just built a full stack web application from scratch.
You've learned how to:
- Built a REST API with Express handling GET, POST, and DELETE requests for full CRUD operations.
- Created a SQLite database with a proper schema and connected it to your API using better-sqlite3.
- Built a React frontend with state management, forms, and async API communication wired to your backend through CORS.
- Secret Mission: Added edit functionality with PUT requests and category filtering with query parameters.
Ready to quiz yourself?