Build a CRUD App with React and Express: The Full-Stack Starter Project
Build a full-stack CRUD app with React, Express, and MongoDB -- REST API design, React forms, data fetching, error handling, and a contact manager.
Every full-stack developer needs to build at least one CRUD app from scratch. Not because CRUD apps are exciting, but because every real application is, at its core, some variation of Create, Read, Update, Delete. Social media? CRUD for posts. E-commerce? CRUD for products and orders. Project management? CRUD for tasks.
This tutorial builds a contact manager -- a practical application that covers all four operations, form handling, data validation, and the full request lifecycle from React frontend to Express backend to MongoDB database. No boilerplate generators, no magic. You'll understand every line.
Project Structure
We'll have two separate directories -- one for the backend API, one for the React frontend:
contact-manager/
backend/
server.js
models/Contact.js
routes/contacts.js
package.json
frontend/
src/
components/
App.jsx
package.json
Setting Up the Backend
mkdir contact-manager && cd contact-manager
mkdir backend && cd backend
npm init -y
npm install express mongoose cors dotenv
npm install -D nodemon
Update package.json:
{
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
}
}
Create a .env file:
PORT=5000
MONGODB_URI=mongodb://localhost:27017/contact-manager
The Server
// backend/server.js
const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");
require("dotenv").config();
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// Connect to MongoDB
mongoose
.connect(process.env.MONGODB_URI)
.then(() => console.log("Connected to MongoDB"))
.catch((err) => console.error("MongoDB connection error:", err));
// Routes
app.use("/api/contacts", require("./routes/contacts"));
// Health check
app.get("/api/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: "Something went wrong",
message: process.env.NODE_ENV === "development" ? err.message : undefined,
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(Server running on port ${PORT}));
The Model
// backend/models/Contact.js
const mongoose = require("mongoose");
const contactSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, "Name is required"],
trim: true,
maxlength: [100, "Name cannot exceed 100 characters"],
},
email: {
type: String,
required: [true, "Email is required"],
trim: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, "Please provide a valid email"],
},
phone: {
type: String,
trim: true,
default: "",
},
company: {
type: String,
trim: true,
default: "",
},
notes: {
type: String,
trim: true,
maxlength: [500, "Notes cannot exceed 500 characters"],
default: "",
},
},
{
timestamps: true, // Adds createdAt and updatedAt
}
);
module.exports = mongoose.model("Contact", contactSchema);
The Routes
// backend/routes/contacts.js
const express = require("express");
const router = express.Router();
const Contact = require("../models/Contact");
// GET /api/contacts -- List all contacts
router.get("/", async (req, res) => {
try {
const { search, sort = "-createdAt" } = req.query;
let query = {};
if (search) {
query = {
$or: [
{ name: { $regex: search, $options: "i" } },
{ email: { $regex: search, $options: "i" } },
{ company: { $regex: search, $options: "i" } },
],
};
}
const contacts = await Contact.find(query).sort(sort);
res.json(contacts);
} catch (err) {
res.status(500).json({ error: "Failed to fetch contacts" });
}
});
// GET /api/contacts/:id -- Get a single contact
router.get("/:id", async (req, res) => {
try {
const contact = await Contact.findById(req.params.id);
if (!contact) {
return res.status(404).json({ error: "Contact not found" });
}
res.json(contact);
} catch (err) {
if (err.name === "CastError") {
return res.status(400).json({ error: "Invalid contact ID" });
}
res.status(500).json({ error: "Failed to fetch contact" });
}
});
// POST /api/contacts -- Create a new contact
router.post("/", async (req, res) => {
try {
const contact = new Contact(req.body);
await contact.save();
res.status(201).json(contact);
} catch (err) {
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({ error: "Validation failed", details: errors });
}
res.status(500).json({ error: "Failed to create contact" });
}
});
// PUT /api/contacts/:id -- Update a contact
router.put("/:id", async (req, res) => {
try {
const contact = await Contact.findByIdAndUpdate(req.params.id, req.body, {
new: true, // Return the updated document
runValidators: true, // Run schema validation on update
});
if (!contact) {
return res.status(404).json({ error: "Contact not found" });
}
res.json(contact);
} catch (err) {
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({ error: "Validation failed", details: errors });
}
if (err.name === "CastError") {
return res.status(400).json({ error: "Invalid contact ID" });
}
res.status(500).json({ error: "Failed to update contact" });
}
});
// DELETE /api/contacts/:id -- Delete a contact
router.delete("/:id", async (req, res) => {
try {
const contact = await Contact.findByIdAndDelete(req.params.id);
if (!contact) {
return res.status(404).json({ error: "Contact not found" });
}
res.json({ message: "Contact deleted", contact });
} catch (err) {
if (err.name === "CastError") {
return res.status(400).json({ error: "Invalid contact ID" });
}
res.status(500).json({ error: "Failed to delete contact" });
}
});
module.exports = router;
Test the API:
npm run dev
# Create a contact
curl -X POST http://localhost:5000/api/contacts \
-H "Content-Type: application/json" \
-d '{"name":"Alice Johnson","email":"alice@example.com","phone":"555-0101","company":"Acme Corp"}'
# List contacts
curl http://localhost:5000/api/contacts
# Search
curl "http://localhost:5000/api/contacts?search=alice"
Setting Up the Frontend
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install axios
API Client
Create a clean API layer so components don't make HTTP calls directly:
// frontend/src/api/contacts.js
import axios from "axios";
const API_URL = "http://localhost:5000/api/contacts";
const api = axios.create({
baseURL: API_URL,
headers: { "Content-Type": "application/json" },
});
export async function getContacts(search = "") {
const params = search ? { search } : {};
const { data } = await api.get("/", { params });
return data;
}
export async function getContact(id) {
const { data } = await api.get(/${id});
return data;
}
export async function createContact(contact) {
const { data } = await api.post("/", contact);
return data;
}
export async function updateContact(id, contact) {
const { data } = await api.put(/${id}, contact);
return data;
}
export async function deleteContact(id) {
const { data } = await api.delete(/${id});
return data;
}
The Contact Form Component
This handles both creating and editing contacts:
// frontend/src/components/ContactForm.jsx
import { useState, useEffect } from "react";
const emptyForm = {
name: "",
email: "",
phone: "",
company: "",
notes: "",
};
export default function ContactForm({ contact, onSubmit, onCancel }) {
const [form, setForm] = useState(emptyForm);
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const isEditing = Boolean(contact);
useEffect(() => {
if (contact) {
setForm({
name: contact.name || "",
email: contact.email || "",
phone: contact.phone || "",
company: contact.company || "",
notes: contact.notes || "",
});
} else {
setForm(emptyForm);
}
setErrors({});
}, [contact]);
function validate() {
const newErrors = {};
if (!form.name.trim()) newErrors.name = "Name is required";
if (!form.email.trim()) newErrors.email = "Email is required";
else if (!/^\S+@\S+\.\S+$/.test(form.email)) newErrors.email = "Invalid email";
return newErrors;
}
async function handleSubmit(e) {
e.preventDefault();
const newErrors = validate();
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setSubmitting(true);
setErrors({});
try {
await onSubmit(form);
if (!isEditing) setForm(emptyForm);
} catch (err) {
const message =
err.response?.data?.details?.[0] ||
err.response?.data?.error ||
"Something went wrong";
setErrors({ submit: message });
} finally {
setSubmitting(false);
}
}
function handleChange(e) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
}
return (
<form onSubmit={handleSubmit} className="contact-form">
<h2>{isEditing ? "Edit Contact" : "Add Contact"}</h2>
{errors.submit && <div className="error-banner">{errors.submit}</div>}
<div className="form-group">
<label htmlFor="name">Name *</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
placeholder="John Doe"
className={errors.name ? "input-error" : ""}
/>
{errors.name && <span className="error-text">{errors.name}</span>}
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
placeholder="john@example.com"
className={errors.email ? "input-error" : ""}
/>
{errors.email && <span className="error-text">{errors.email}</span>}
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input
id="phone"
name="phone"
value={form.phone}
onChange={handleChange}
placeholder="555-0100"
/>
</div>
<div className="form-group">
<label htmlFor="company">Company</label>
<input
id="company"
name="company"
value={form.company}
onChange={handleChange}
placeholder="Acme Corp"
/>
</div>
<div className="form-group">
<label htmlFor="notes">Notes</label>
<textarea
id="notes"
name="notes"
value={form.notes}
onChange={handleChange}
placeholder="Any additional notes..."
rows={3}
/>
</div>
<div className="form-actions">
<button type="submit" disabled={submitting}>
{submitting ? "Saving..." : isEditing ? "Update" : "Add Contact"}
</button>
{isEditing && (
<button type="button" onClick={onCancel} className="btn-secondary">
Cancel
</button>
)}
</div>
</form>
);
}
The Contact List Component
// frontend/src/components/ContactList.jsx
export default function ContactList({ contacts, onEdit, onDelete, loading }) {
if (loading) {
return <div className="loading">Loading contacts...</div>;
}
if (contacts.length === 0) {
return (
<div className="empty-state">
<p>No contacts yet. Add one above.</p>
</div>
);
}
return (
<div className="contact-list">
<h2>Contacts ({contacts.length})</h2>
<div className="contacts-grid">
{contacts.map((contact) => (
<div key={contact._id} className="contact-card">
<div className="contact-info">
<h3>{contact.name}</h3>
<p className="email">{contact.email}</p>
{contact.phone && <p className="phone">{contact.phone}</p>}
{contact.company && (
<p className="company">{contact.company}</p>
)}
{contact.notes && (
<p className="notes">{contact.notes}</p>
)}
</div>
<div className="contact-actions">
<button onClick={() => onEdit(contact)} className="btn-edit">
Edit
</button>
<button
onClick={() => {
if (window.confirm(Delete ${contact.name}?)) {
onDelete(contact._id);
}
}}
className="btn-delete"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
);
}
The Search Component
// frontend/src/components/SearchBar.jsx
import { useState } from "react";
export default function SearchBar({ onSearch }) {
const [query, setQuery] = useState("");
function handleSubmit(e) {
e.preventDefault();
onSearch(query);
}
function handleClear() {
setQuery("");
onSearch("");
}
return (
<form onSubmit={handleSubmit} className="search-bar">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search contacts..."
/>
<button type="submit">Search</button>
{query && (
<button type="button" onClick={handleClear} className="btn-secondary">
Clear
</button>
)}
</form>
);
}
Wiring It All Together
// frontend/src/App.jsx
import { useState, useEffect, useCallback } from "react";
import ContactForm from "./components/ContactForm";
import ContactList from "./components/ContactList";
import SearchBar from "./components/SearchBar";
import {
getContacts,
createContact,
updateContact,
deleteContact,
} from "./api/contacts";
import "./App.css";
function App() {
const [contacts, setContacts] = useState([]);
const [editingContact, setEditingContact] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const fetchContacts = useCallback(async (search = "") => {
try {
setLoading(true);
setError(null);
const data = await getContacts(search);
setContacts(data);
} catch (err) {
setError("Failed to load contacts. Is the server running?");
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchContacts();
}, [fetchContacts]);
async function handleCreate(formData) {
await createContact(formData);
fetchContacts(searchQuery);
}
async function handleUpdate(formData) {
await updateContact(editingContact._id, formData);
setEditingContact(null);
fetchContacts(searchQuery);
}
async function handleDelete(id) {
try {
await deleteContact(id);
fetchContacts(searchQuery);
} catch (err) {
setError("Failed to delete contact");
}
}
function handleSearch(query) {
setSearchQuery(query);
fetchContacts(query);
}
return (
<div className="app">
<header>
<h1>Contact Manager</h1>
</header>
<main>
<div className="sidebar">
<ContactForm
contact={editingContact}
onSubmit={editingContact ? handleUpdate : handleCreate}
onCancel={() => setEditingContact(null)}
/>
</div>
<div className="content">
<SearchBar onSearch={handleSearch} />
{error && <div className="error-banner">{error}</div>}
<ContactList
contacts={contacts}
onEdit={setEditingContact}
onDelete={handleDelete}
loading={loading}
/>
</div>
</main>
</div>
);
}
export default App;
Basic Styling
/ frontend/src/App.css /
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header h1 {
font-size: 1.8rem;
margin-bottom: 24px;
color: #1a1a2e;
}
main {
display: grid;
grid-template-columns: 380px 1fr;
gap: 24px;
align-items: start;
}
/ Form /
.contact-form {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.contact-form h2 {
margin-bottom: 16px;
font-size: 1.2rem;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
font-size: 0.9rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.95rem;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #4361ee;
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.1);
}
.input-error {
border-color: #e63946 !important;
}
.error-text {
color: #e63946;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.error-banner {
background: #fee2e2;
color: #991b1b;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
}
.form-actions {
display: flex;
gap: 8px;
}
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 0.95rem;
cursor: pointer;
background: #4361ee;
color: white;
font-weight: 500;
}
button:hover {
background: #3651d4;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: #e5e7eb;
color: #333;
}
.btn-secondary:hover {
background: #d1d5db;
}
/ Search /
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.search-bar input {
flex: 1;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.95rem;
}
/ Contact cards /
.contact-list h2 {
margin-bottom: 16px;
font-size: 1.2rem;
}
.contacts-grid {
display: grid;
gap: 12px;
}
.contact-card {
background: white;
padding: 16px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: start;
}
.contact-info h3 {
font-size: 1.05rem;
margin-bottom: 4px;
}
.contact-info .email {
color: #4361ee;
font-size: 0.9rem;
}
.contact-info .phone,
.contact-info .company {
color: #666;
font-size: 0.85rem;
margin-top: 2px;
}
.contact-info .notes {
color: #888;
font-size: 0.85rem;
margin-top: 8px;
font-style: italic;
}
.contact-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.btn-edit {
background: #e0e7ff;
color: #4361ee;
padding: 6px 12px;
font-size: 0.85rem;
}
.btn-delete {
background: #fee2e2;
color: #e63946;
padding: 6px 12px;
font-size: 0.85rem;
}
.loading,
.empty-state {
text-align: center;
padding: 40px;
color: #888;
}
@media (max-width: 768px) {
main {
grid-template-columns: 1fr;
}
}
Running the Full Stack
Terminal 1 -- Start MongoDB (if using local installation):
mongod
Or use Docker:
docker run -d -p 27017:27017 --name mongo mongo:7
Terminal 2 -- Start the backend:
cd backend
npm run dev
Terminal 3 -- Start the frontend:
cd frontend
npm run dev
Open http://localhost:5173 and you have a working full-stack CRUD application.
Error Handling Deep Dive
Good error handling is what separates a demo from a real application. Here's what we handle:
Backend validation errors -- Mongoose validates data and returns structured error messages. The routes catchValidationError and send them as 400 responses with specific field-level messages.
Network errors -- If the backend is down, axios throws a network error. The fetchContacts function catches this and shows a user-friendly message instead of crashing.
Optimistic vs pessimistic updates -- Our approach is pessimistic: we wait for the server to confirm before updating the UI. For a snappier feel, you could update the UI immediately and roll back if the server returns an error (optimistic updates). Start pessimistic, add optimism when needed.
Race conditions -- If the user searches while a previous search is still loading, both responses might arrive, with the stale one arriving last. In production, you'd cancel the previous request with an AbortController:
useEffect(() => {
const controller = new AbortController();
async function fetch() {
try {
const data = await getContacts(searchQuery, controller.signal);
setContacts(data);
} catch (err) {
if (err.name !== "AbortError") setError("Failed to load");
}
}
fetch();
return () => controller.abort();
}, [searchQuery]);
Common Mistakes
CORS issues. The most common error when building full-stack apps. The backend must includecors() middleware, and the frontend must send requests to the correct URL. Check the browser console for CORS errors -- they're descriptive.
Not validating on both sides. Client-side validation improves UX (instant feedback). Server-side validation ensures data integrity (can't be bypassed). You need both. Never trust the client.
Hardcoding URLs. http://localhost:5000 works in development but not in production. Use environment variables. Vite exposes them with import.meta.env.VITE_API_URL.
Not handling loading states. Without a loading indicator, the UI looks broken when data takes time to load. Always show loading state, even if your API is fast locally -- it won't be in production.
Mutating state directly. React state must be treated as immutable. Don't push to arrays or modify objects. Use spread operators or map/filter to create new references.
Storing derived state. Don't store filtered contacts in a separate state variable. Filter the contacts array during render. Derived state creates sync bugs.
What's Next
You now have a working full-stack CRUD app. Here's how to extend it:
- Authentication -- Add user accounts with JWT so contacts are private
- Pagination -- Implement server-side pagination for large datasets
- File uploads -- Add profile photos with Multer on the backend
- Testing -- Add Jest + React Testing Library for the frontend, Supertest for the API
- Deployment -- Deploy the backend to Railway or Render, frontend to Vercel or Netlify
- TypeScript -- Add type safety to both frontend and backend
- React Query / SWR -- Replace manual fetching with a data-fetching library for caching, revalidation, and optimistic updates
For more full-stack tutorials, check out CodeUp.