March 27, 202615 min read

Build a Full-Stack App with React and Node.js (Step by Step)

Build a complete bookmark manager app with a React frontend, Node.js/Express backend, REST API, and learn how to connect and deploy both.

fullstack react nodejs javascript tutorial
Ad 336x280

The gap between "I can build a React app" and "I can build a full-stack app" is smaller than you think. It's mostly about understanding how the frontend and backend talk to each other. In this tutorial, we'll build a complete bookmark manager app from scratch -- a React frontend with Vite, a Node.js/Express backend with a REST API, and we'll connect them together.

By the end, you'll have a working full-stack application and a mental model for how every full-stack app works, regardless of the specific technologies.

What We're Building

A bookmark manager where you can:


  • Add bookmarks (URL, title, category)

  • View all bookmarks

  • Filter by category

  • Delete bookmarks


Simple enough to build in a tutorial, complex enough to teach you real patterns.

Project Structure

Here's how we'll organize the code:

bookmark-manager/
  client/          # React frontend (Vite)
  server/          # Node.js backend (Express)

Keeping the frontend and backend in separate directories inside one repo is the most common approach for small-to-medium projects. Larger projects sometimes use separate repos, but a monorepo is easier to develop locally.

Part 1: The Backend

Setting up Express

mkdir bookmark-manager
cd bookmark-manager
mkdir server
cd server
npm init -y
npm install express cors
npm install --save-dev nodemon

A quick explanation of each package:


  • express -- the web framework for Node.js

  • cors -- middleware that lets our React app (on port 5173) talk to our API (on port 3001)

  • nodemon -- restarts the server automatically when you change files


Update server/package.json scripts:

{
  "scripts": {
    "dev": "nodemon index.js",
    "start": "node index.js"
  }
}

Creating the API

Create server/index.js:

const express = require('express');
const cors = require('cors');

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

// Middleware
app.use(cors());
app.use(express.json());

// In-memory data store (replace with a database in production)
let bookmarks = [
{
id: 1,
url: 'https://developer.mozilla.org',
title: 'MDN Web Docs',
category: 'reference',
createdAt: new Date().toISOString()
},
{
id: 2,
url: 'https://javascript.info',
title: 'JavaScript.info',
category: 'learning',
createdAt: new Date().toISOString()
}
];

let nextId = 3;

// GET all bookmarks
app.get('/api/bookmarks', (req, res) => {
const { category } = req.query;

if (category) {
const filtered = bookmarks.filter(b => b.category === category);
return res.json(filtered);
}

res.json(bookmarks);
});

// GET a single bookmark
app.get('/api/bookmarks/:id', (req, res) => {
const bookmark = bookmarks.find(b => b.id === parseInt(req.params.id));

if (!bookmark) {
return res.status(404).json({ error: 'Bookmark not found' });
}

res.json(bookmark);
});

// POST a new bookmark
app.post('/api/bookmarks', (req, res) => {
const { url, title, category } = req.body;

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

const bookmark = {
id: nextId++,
url,
title,
category: category || 'uncategorized',
createdAt: new Date().toISOString()
};

bookmarks.push(bookmark);
res.status(201).json(bookmark);
});

// DELETE a bookmark
app.delete('/api/bookmarks/:id', (req, res) => {
const index = bookmarks.findIndex(b => b.id === parseInt(req.params.id));

if (index === -1) {
return res.status(404).json({ error: 'Bookmark not found' });
}

bookmarks.splice(index, 1);
res.status(204).send();
});

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

Let's break down what's happening:

Middleware -- cors() allows cross-origin requests (so our React app on a different port can call this API). express.json() parses JSON request bodies automatically. Routes -- We defined four endpoints following REST conventions:
  • GET /api/bookmarks -- list all (with optional category filter)
  • GET /api/bookmarks/:id -- get one
  • POST /api/bookmarks -- create one
  • DELETE /api/bookmarks/:id -- delete one
In-memory storage -- We're using a plain array for simplicity. In a real app, you'd use a database (PostgreSQL, MongoDB, SQLite). The API structure would be identical -- you'd just swap the array operations for database queries.

Testing the API

Start the server:

cd server
npm run dev

Test with curl (or use a tool like Postman or Thunder Client in VS Code):

# Get all bookmarks
curl http://localhost:3001/api/bookmarks

# Add a bookmark
curl -X POST http://localhost:3001/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://react.dev", "title": "React Docs", "category": "reference"}'

# Filter by category
curl http://localhost:3001/api/bookmarks?category=reference

# Delete a bookmark
curl -X DELETE http://localhost:3001/api/bookmarks/1

Always test your API independently before building the frontend. If something's broken, you want to know whether the problem is in the backend or the frontend. Testing them separately makes debugging much easier.

Part 2: The Frontend

Setting up React with Vite

From the root bookmark-manager directory:

npm create vite@latest client -- --template react
cd client
npm install

Vite gives you a fast development server with hot module replacement. It's the modern standard for new React projects -- much faster than Create React App.

Project structure

Clean up the default files and create this structure:

client/src/
  components/
    BookmarkList.jsx
    BookmarkForm.jsx
    CategoryFilter.jsx
  api/
    bookmarks.js
  App.jsx
  App.css
  main.jsx

The API Layer

Create client/src/api/bookmarks.js:

const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';

export async function getBookmarks(category) {
const url = category
? ${API_BASE}/api/bookmarks?category=${category}
: ${API_BASE}/api/bookmarks;

const response = await fetch(url);

if (!response.ok) {
throw new Error('Failed to fetch bookmarks');
}

return response.json();
}

export async function createBookmark(bookmark) {
const response = await fetch(${API_BASE}/api/bookmarks, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bookmark)
});

if (!response.ok) {
throw new Error('Failed to create bookmark');
}

return response.json();
}

export async function deleteBookmark(id) {
const response = await fetch(${API_BASE}/api/bookmarks/${id}, {
method: 'DELETE'
});

if (!response.ok) {
throw new Error('Failed to delete bookmark');
}
}

A few things to notice:

Centralized API layer. Instead of writing fetch calls directly in components, we have a dedicated module. This keeps components focused on UI and makes it easy to change the API later (swap fetch for axios, add authentication headers, etc.). Environment variable for the API URL. import.meta.env.VITE_API_URL lets us configure the backend URL differently in development vs. production. In Vite, environment variables must be prefixed with VITE_ to be exposed to the browser. Error handling. We check response.ok and throw errors. The components will catch these and show appropriate messages.

The Components

Create client/src/components/BookmarkForm.jsx:

import { useState } from 'react';

export default function BookmarkForm({ onAdd }) {
const [url, setUrl] = useState('');
const [title, setTitle] = useState('');
const [category, setCategory] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);

async function handleSubmit(e) {
e.preventDefault();
setError('');

if (!url || !title) {
setError('URL and title are required');
return;
}

setLoading(true);

try {
await onAdd({ url, title, category: category || 'uncategorized' });
setUrl('');
setTitle('');
setCategory('');
} catch (err) {
setError('Failed to add bookmark. Please try again.');
} finally {
setLoading(false);
}
}

return (
<form onSubmit={handleSubmit} className="bookmark-form">
<h2>Add Bookmark</h2>

{error && <p className="error">{error}</p>}

<div className="form-group">
<label htmlFor="url">URL</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
required
/>
</div>

<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Example Website"
required
/>
</div>

<div className="form-group">
<label htmlFor="category">Category</label>
<select
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">Uncategorized</option>
<option value="reference">Reference</option>
<option value="learning">Learning</option>
<option value="tools">Tools</option>
<option value="inspiration">Inspiration</option>
</select>
</div>

<button type="submit" disabled={loading}>
{loading ? 'Adding...' : 'Add Bookmark'}
</button>
</form>
);
}

Create client/src/components/CategoryFilter.jsx:

const CATEGORIES = ['all', 'reference', 'learning', 'tools', 'inspiration', 'uncategorized'];

export default function CategoryFilter({ active, onChange }) {
return (
<div className="category-filter">
{CATEGORIES.map(cat => (
<button
key={cat}
className={active === cat ? 'active' : ''}
onClick={() => onChange(cat)}
>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</button>
))}
</div>
);
}

Create client/src/components/BookmarkList.jsx:

export default function BookmarkList({ bookmarks, onDelete, loading }) {
  if (loading) {
    return <p className="loading">Loading bookmarks...</p>;
  }

if (bookmarks.length === 0) {
return <p className="empty">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-category">{bookmark.category}</span>
<span className="bookmark-url">{bookmark.url}</span>
</div>
<button
onClick={() => onDelete(bookmark.id)}
className="delete-btn"
aria-label={Delete ${bookmark.title}}
>
Delete
</button>
</li>
))}
</ul>
);
}

Putting It Together

Replace client/src/App.jsx:

import { useState, useEffect } from 'react';
import { getBookmarks, createBookmark, deleteBookmark } from './api/bookmarks';
import BookmarkForm from './components/BookmarkForm';
import BookmarkList from './components/BookmarkList';
import CategoryFilter from './components/CategoryFilter';
import './App.css';

function App() {
const [bookmarks, setBookmarks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeCategory, setActiveCategory] = useState('all');

useEffect(() => {
loadBookmarks();
}, [activeCategory]);

async function loadBookmarks() {
setLoading(true);
setError('');

try {
const category = activeCategory === 'all' ? null : activeCategory;
const data = await getBookmarks(category);
setBookmarks(data);
} catch (err) {
setError('Failed to load bookmarks. Is the server running?');
} finally {
setLoading(false);
}
}

async function handleAdd(bookmark) {
const newBookmark = await createBookmark(bookmark);
setBookmarks(prev => [newBookmark, ...prev]);
}

async function handleDelete(id) {
await deleteBookmark(id);
setBookmarks(prev => prev.filter(b => b.id !== id));
}

return (
<div className="app">
<header>
<h1>Bookmark Manager</h1>
<p>Save and organize your favorite links</p>
</header>

<main>
<BookmarkForm onAdd={handleAdd} />

{error && <p className="error">{error}</p>}

<section className="bookmarks-section">
<h2>Your Bookmarks</h2>
<CategoryFilter
active={activeCategory}
onChange={setActiveCategory}
/>
<BookmarkList
bookmarks={bookmarks}
onDelete={handleDelete}
loading={loading}
/>
</section>
</main>
</div>
);
}

export default App;

Styling

Replace client/src/App.css with something clean:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}

.app {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1rem;
}

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

header h1 {
font-size: 1.8rem;
margin-bottom: 0.25rem;
}

header p {
color: #666;
}

.bookmark-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

.bookmark-form h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
}

.form-group {
margin-bottom: 1rem;
}

.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
font-size: 0.9rem;
}

.form-group input,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}

button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}

.bookmark-form button[type="submit"] {
background: #2563eb;
color: white;
width: 100%;
padding: 0.75rem;
font-size: 1rem;
}

.bookmark-form button[type="submit"]:hover {
background: #1d4ed8;
}

.bookmark-form button[type="submit"]:disabled {
background: #93c5fd;
cursor: not-allowed;
}

.category-filter {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}

.category-filter button {
background: white;
border: 1px solid #ddd;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
}

.category-filter button.active {
background: #2563eb;
color: white;
border-color: #2563eb;
}

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

.bookmark-item {
background: white;
padding: 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

.bookmark-info a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
display: block;
}

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

.bookmark-category {
display: inline-block;
font-size: 0.75rem;
background: #e5e7eb;
padding: 0.15rem 0.5rem;
border-radius: 10px;
margin-top: 0.25rem;
}

.bookmark-url {
display: block;
font-size: 0.8rem;
color: #888;
margin-top: 0.25rem;
}

.delete-btn {
background: #fee2e2;
color: #dc2626;
font-size: 0.8rem;
}

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

.error {
color: #dc2626;
background: #fee2e2;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}

.loading, .empty {
text-align: center;
color: #888;
padding: 2rem;
}

Part 3: Connecting Frontend to Backend

Understanding CORS

When your React app at http://localhost:5173 makes a request to your API at http://localhost:3001, the browser blocks it by default. This is the Same-Origin Policy -- a security feature that prevents websites from making requests to other domains.

CORS (Cross-Origin Resource Sharing) is the mechanism that relaxes this restriction. When we added app.use(cors()) in our Express server, we told it to accept requests from any origin. In production, you'd want to restrict this:
// Production CORS configuration
app.use(cors({
  origin: 'https://your-frontend-domain.com',
  methods: ['GET', 'POST', 'DELETE'],
  credentials: true
}));

Environment Variables

Create client/.env for development:

VITE_API_URL=http://localhost:3001

And client/.env.production for production:

VITE_API_URL=https://your-api-domain.com

Vite automatically loads the right .env file based on the mode. During npm run dev, it uses .env. During npm run build, it uses .env.production.

Never put secrets in frontend environment variables. Everything prefixed with VITE_ gets bundled into the client-side JavaScript and is visible to anyone who opens DevTools. API keys, database passwords, and secrets belong on the server only.

Running Both Together

You need two terminal windows during development:

Terminal 1 (backend):
cd server
npm run dev
# Server running on http://localhost:3001
Terminal 2 (frontend):
cd client
npm run dev
# Frontend running on http://localhost:5173

Open http://localhost:5173 in your browser. You should see the bookmark manager with the two seed bookmarks loaded from the API. Try adding a new bookmark -- the form sends a POST request to the backend, gets the response, and updates the UI.

Proxy Setup (Alternative to CORS)

Instead of using CORS, you can proxy API requests through Vite's dev server. Add this to client/vite.config.js:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
});

Now your frontend can call /api/bookmarks without specifying the full URL, and Vite forwards these requests to your backend. This avoids CORS entirely during development and is how many teams work.

Update your API layer to use relative URLs when proxying:

const API_BASE = import.meta.env.VITE_API_URL || '';

Part 4: Adding a Database (Optional Enhancement)

For a production app, you'd replace the in-memory array with a real database. Here's how you'd do it with SQLite using the better-sqlite3 package (no separate database server needed):

cd server
npm install better-sqlite3

Create server/db.js:

const Database = require('better-sqlite3');
const path = require('path');

const db = new Database(path.join(__dirname, 'bookmarks.db'));

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

module.exports = db;

Then swap the array operations in index.js for database queries:

const db = require('./db');

// GET all bookmarks
app.get('/api/bookmarks', (req, res) => {
const { category } = req.query;

if (category) {
const stmt = db.prepare('SELECT * FROM bookmarks WHERE category = ? ORDER BY created_at DESC');
return res.json(stmt.all(category));
}

const stmt = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC');
res.json(stmt.all());
});

// POST a new bookmark
app.post('/api/bookmarks', (req, res) => {
const { url, title, category } = req.body;

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

const stmt = db.prepare('INSERT INTO bookmarks (url, title, category) VALUES (?, ?, ?)');
const result = stmt.run(url, title, category || 'uncategorized');

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 stmt = db.prepare('DELETE FROM bookmarks WHERE id = ?');
const result = stmt.run(parseInt(req.params.id));

if (result.changes === 0) {
return res.status(404).json({ error: 'Bookmark not found' });
}

res.status(204).send();
});

Same API, same frontend code, but now your data persists when the server restarts.

Part 5: Deploying

Deploying the Backend

The most common options for Node.js backends:

Railway (easiest):
npm install -g @railway/cli
railway login
railway init
railway up
Render:
  1. Push your server code to GitHub
  2. Create a new Web Service on Render
  3. Connect your repo, point to the server directory
  4. Set the build command to npm install and start command to node index.js
DigitalOcean App Platform, Fly.io, and Heroku are also solid options.

After deploying, you'll get a URL like https://your-app.railway.app. Update your frontend's VITE_API_URL production environment variable to point to it.

Deploying the Frontend

Build the frontend:

cd client
npm run build

This creates a dist/ folder with static files. These can go anywhere that serves static files:

Vercel (easiest for React):
npm install -g vercel
cd client
vercel
Netlify:
npm install -g netlify-cli
cd client
netlify deploy --prod --dir=dist
Cloudflare Pages:
npx wrangler pages deploy dist --project-name=bookmark-manager

Set the VITE_API_URL environment variable in your hosting platform's dashboard to your backend's URL.

Production Checklist

Before deploying for real:

  • [ ] Restrict CORS to your frontend's domain
  • [ ] Add input validation and sanitization on the backend
  • [ ] Use a real database (not in-memory storage)
  • [ ] Add rate limiting to prevent abuse
  • [ ] Use HTTPS everywhere
  • [ ] Don't expose stack traces in error responses
  • [ ] Add proper logging
  • [ ] Set up environment variables for all configuration

Key Concepts Recap

REST API design. Resources as nouns (/api/bookmarks), HTTP methods as verbs (GET, POST, DELETE). Status codes communicate outcomes (200 OK, 201 Created, 404 Not Found). Separation of concerns. The backend handles data and business logic. The frontend handles presentation and user interaction. They communicate through a well-defined API. Environment variables. Configuration that changes between environments (dev vs. production) lives in environment variables, not in code. CORS. The browser's security mechanism that controls which origins can access your API. Must be configured explicitly on the backend. API layer pattern. Centralizing all API calls in a dedicated module keeps components clean and makes the API surface easy to change.

Where to Go From Here

This project covers the essential patterns. To level up further:

  • Add authentication -- JWT tokens, login/signup flows, protected routes
  • Add a database -- PostgreSQL with Prisma ORM is a popular choice
  • Add error boundaries -- React error boundaries for graceful failure handling
  • Add testing -- Jest for the backend, React Testing Library for the frontend
  • Try TypeScript -- add type safety to both frontend and backend
  • Add real-time updates -- WebSockets so bookmarks sync across tabs
The architecture stays the same regardless of complexity. You have a frontend that renders UI, a backend that manages data, and an API that connects them. Everything else is refinement.

Build more full-stack projects and explore these patterns on CodeUp -- the more you practice connecting frontends to backends, the more natural it becomes.

Ad 728x90