Build a MERN Expense Tracker
Build a full-stack expense tracker with React, Express, and MongoDB Atlas.
Introduction
30 Second Summary
You track expenses mentally, in a notes app, or on a spreadsheet. But what if you could build your own tracker that saves everything to a real database, accessible from any browser?
In this project, you will build a full-stack expense tracker using React, Express, and MongoDB Atlas. You will create a REST API, connect it to a cloud database, and wire up a React frontend that lets you add, view, and delete expenses in real time.
What You'll Build
You will type an expense into a form, hit Add, and watch it appear instantly in a live list with a running total that recalculates every time you add or remove an entry.
By the end of this project, you'll have:
- A working Add Expense form where you type a name, amount, and category, then save it to a cloud database with one click.
- A live-updating expense list that shows every entry with its details the moment it is created.
- A running total that recalculates automatically when you add or delete any expense.
- Secret Mission: Add an "Edit Expense" feature so you can update existing entries without deleting and re-adding them.
Are there any prerequisites?
You should have basic JavaScript familiarity (variables, functions, arrays). You also need Node.js installed on your machine. No prior experience with React, Express, or databases is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you. When you're wiring up multiple pieces of a full-stack app, having a clear picture of the end goal keeps you focused.
Set Up the Project
A full-stack app has three layers: a frontend the user sees, a backend that handles logic, and a database that stores data.
In this step, you will scaffold a React frontend with Vite, set up an Express backend, and connect it to a MongoDB Atlas database. Once all three are running, the rest of the project is pure building.
In this step, get ready to:
- Scaffold the React frontend and Express backend with all dependencies installed.
- Create a free MongoDB Atlas cluster and configure access.
- Connect Express to MongoDB and verify the connection works.
Scaffold the React frontend and Express backend
You need a project folder on your Desktop that holds both the frontend and backend code side by side.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir expense-tracker
cd expense-tracker
- Confirm your folder was created by running:
pwd
You should see a path ending in expense-tracker.
Now scaffold the React frontend. Vite creates React projects instantly with hot module reloading built in.
- Scaffold the React app in a client folder by running:
npm create vite@latest client -- --template react
What does this command do?
This tells npm to run the Vite project creator and scaffold a new React project in a folder called client. The --template react flag skips the interactive prompts and picks plain JavaScript React for you.
- Install the frontend dependencies by running:
cd client
npm install
- Start the Vite dev server to confirm the frontend works:
npm run dev
You should see output showing a local URL at http://localhost:5173.
- Open http://localhost:5173 in your browser. You should see the Vite + React starter page with spinning logos.
- Go back to your terminal and press Ctrl+C to stop the dev server.
- Move back to the project root by running:
cd ..
Don't see the Vite page?
- Make sure you ran npm install inside the client folder before npm run dev.
- Check that no other process is using port 5173. Close any other running dev servers.
- help me debug why my Vite dev server is not starting
Now set up the Express backend.
- Create the backend folder and initialise it by running:
mkdir server
cd server
npm init -y
This creates a server folder with a default package.json file.
- Open the expense-tracker folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Select the expense-tracker folder on your Desktop, then click Open.
- In the Explorer sidebar, expand the server folder and open package.json.
- Replace the entire contents of package.json with:
{
"name": "expense-tracker-server",
"version": "1.0.0",
"description": "Expense tracker API",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js"
}
}
Why add type module?
Node.js defaults to CommonJS (require()) for .js files. Adding "type": "module" tells Node.js to use modern import/export syntax instead. All the server code in this project uses ES modules.
- Save package.json, then install the backend dependencies by running this in your VS Code terminal (open one with Ctrl+`):
cd server
npm install express@5.2.1 mongoose@9.7.4 cors@2.8.6 dotenv@17.4.2
What are these packages?
- express is the web framework that handles HTTP requests and responses.
- mongoose is an ODM (Object Data Modeling) library that gives you schemas and validation for MongoDB.
- cors enables cross-origin requests so the frontend can talk to the backend in production.
- dotenv loads environment variables from a .env file into process.env.
- Confirm the packages installed by running:
ls node_modules
You should see a long list of folder names. This confirms the dependencies downloaded successfully.
Install command failed?
- Make sure you are inside the server folder. Run pwd to check your current location.
- Check you have an internet connection. npm needs to download packages from the registry.
- help me fix my npm install error
Set up MongoDB Atlas (free tier)
Your expenses need a place to live permanently. MongoDB Atlas gives you a free cloud database that persists data between sessions.
- Open mongodb.com/cloud/atlas in your browser.
- Click Try Free and sign up for a free account (no credit card required).
- After signing in, click Create to start deploying a new cluster.
- Select the M0 (Free) option. This gives you 512 MB of storage at no cost, forever.
- Pick a cloud provider and region close to you.
- Click Create to deploy. Your cluster is ready in under 15 seconds.
What is an M0 cluster?
M0 is MongoDB Atlas's free-forever tier. It provides 512 MB of storage with shared resources. It is perfect for learning projects and prototypes.
After deployment, Atlas shows a Security Quickstart wizard for database access.
- Create a database user. Enter a username and a password you will remember.
- Click Create Database User.
- Under the network access section, click Add My Current IP Address.
- Click Finish and Close.
Save your credentials
Write down your database username and password somewhere safe. You will need them in the next substep when building your connection string.
Now grab the connection string so your Express server can reach this database.
- On the cluster overview page, click Connect.
- Select Drivers (the option for connecting your application).
- Copy the connection string. It looks something like mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/.
- Replace the password placeholder in the string with the actual password you created for your database user.
Cannot find the Connect button?
- Your cluster may still be provisioning (look for a spinning icon). Wait 1-2 minutes for it to finish.
- Make sure you completed the security quickstart prompts. Atlas shows the Connect button only after at least one database user and one IP entry exist.
- help me find my MongoDB Atlas connection string
Connect Express to MongoDB
You have the frontend scaffolded, the backend dependencies installed, and a MongoDB cluster ready. Now wire them together with a connection file.
- In VS Code, right-click the server folder in the Explorer sidebar and select New File.
- Name it .env and paste the following:
MONGODB_URI=your-mongodb-atlas-connection-string-here
PORT=5000
- Replace your-mongodb-atlas-connection-string-here with the actual connection string you copied from Atlas (the one with your real password already inserted).
- Save the file.
Why use a .env file?
Environment variables keep sensitive values like database credentials out of your source code. The dotenv package reads this file and makes the values available through process.env. Never commit .env files to version control.
Now create the Express server that connects to MongoDB.
- Right-click the server folder again and select New File.
- Name it server.js and paste the following code:
// Load environment variables from .env file
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import cors from 'cors';
// Create the Express application
const app = express();
const PORT = process.env.PORT || 5000;
// Enable CORS and JSON body parsing
app.use(cors());
app.use(express.json());
// Connect to MongoDB, then start the server
mongoose.connect(process.env.MONGODB_URI)
.then(() => {
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((error) => {
console.error('MongoDB connection error:', error);
});
What does this code do?
- import 'dotenv/config' reads your .env file and loads the values into process.env.
- app.use(cors()) allows the React frontend to make requests to this server in production.
- app.use(express.json()) parses incoming JSON request bodies so you can read form data.
- mongoose.connect() opens a connection to your Atlas database. The server only starts listening for requests after the database connection succeeds.
- Save server.js.
Before you run the server, what do you expect to see in the terminal if your connection string is correct?
- In the VS Code terminal, make sure you are in the server folder, then start the server by running:
node server.js
You should see two lines printed:
Connected to MongoDB
Server running on port 5000
Seeing a connection error?
- Open your .env file and confirm the connection string has your real password (not a placeholder like <password>).
- Check that your IP address is whitelisted in Atlas under Network Access. If you changed networks, add your new IP.
- Make sure your package.json has "type": "module" in it. Without this, Node.js cannot process the import statements and you will see a syntax error instead.
- help me fix my MongoDB connection error in Express
✔️ Awesome, I've got everything!
Great. Make sure you have saved both .env and server.js.
ⓧ I'd like to double check the full code
Here is what your server/.env should look like:
MONGODB_URI=your-mongodb-atlas-connection-string-here
PORT=5000
Here is what your server/server.js should look like:
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import cors from 'cors';
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGODB_URI)
.then(() => {
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((error) => {
console.error('MongoDB connection error:', error);
});
Your environment is ready. You have a React frontend, an Express backend connected to MongoDB, and a live database in the cloud. Next up, you will build the API that creates, reads, and deletes expenses.
Build the Expense API
Your server connects to MongoDB, but it cannot actually do anything with expense data yet. There are no endpoints for the frontend to talk to.
In this step, you will build a REST API that handles creating, reading, and deleting expenses. Once these endpoints work, any client (your React app, or even a simple curl command) can store and retrieve expenses from MongoDB.
In this step, get ready to:
- Create a Mongoose model that defines the shape of an expense document.
- Build API routes for GET, POST, and DELETE operations.
- Test every route with curl to confirm the API works before wiring up the frontend.
Create the Expense Mongoose model
Before you can store expenses, Mongoose needs a schema that defines what an expense document looks like. The schema tells MongoDB which fields to expect and which ones are required.
- In your terminal, stop the running server by pressing Ctrl+C.
- Create a models folder inside server by running this command:
mkdir models
- Create a new file called Expense.js inside server/models and add the following code:
import mongoose from 'mongoose';
const expenseSchema = new mongoose.Schema({
name: {
type: String,
required: true
},
amount: {
type: Number,
required: true
},
category: {
type: String,
required: true
},
date: {
type: Date,
default: Date.now
}
});
const Expense = mongoose.model('Expense', expenseSchema);
export default Expense;
What does this code do?
The schema defines four fields for each expense document. name, amount, and category are all required, meaning MongoDB will reject any expense missing one of these.
The date field has a default value of Date.now, so every expense automatically gets a timestamp when created. mongoose.model('Expense', expenseSchema) registers the model so you can use it to create, find, and delete documents.
- Save server/models/Expense.js.
- Confirm the file exists by running:
ls models
You should see Expense.js listed in the output.
Don't see the file?
Make sure you are in the server directory when running these commands. Run pwd to check your current directory. If you are not in server, run cd ~/Desktop/expense-tracker/server.
help me find my server folder
Create the API routes and wire them up
With the model defined, you can now build the route handlers that respond to HTTP requests. You will start with the GET route so you can verify the wiring works immediately.
- Create a routes folder inside server by running:
mkdir routes
- Create a new file called expenses.js inside server/routes and add the following code:
import { Router } from 'express';
import Expense from '../models/Expense.js';
const router = Router();
// GET all expenses, sorted newest first
router.get('/', async (req, res) => {
try {
const expenses = await Expense.find().sort({ date: -1 });
res.json(expenses);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
export default router;
What does this code do?
Router from Express creates a mini-app that handles requests at a specific path. The GET route uses Expense.find().sort({ date: -1 }) to fetch every expense from MongoDB, sorted so the newest appears first.
The try/catch block handles database errors gracefully. If something goes wrong, the route returns a 500 status with the error message instead of crashing the server.
- Save server/routes/expenses.js.
Now you need to tell server.js to use this router for all requests to /api/expenses.
- Open server/server.js and find this line near the top:
import cors from 'cors';
- Add this import directly below it:
import expenseRoutes from './routes/expenses.js';
- Now find this line:
app.use(express.json());
- Add this line directly below it to mount the routes:
app.use('/api/expenses', expenseRoutes);
What does mounting do?
app.use('/api/expenses', expenseRoutes) tells Express to forward any request starting with /api/expenses to your router. Inside the router, the GET / route matches the full path /api/expenses because the prefix is already handled by the mount point.
- Save server.js.
- Restart the server by running:
node server.js
You should see "Connected to MongoDB" and "Server running on port 5000" again.
- Open a second terminal tab or window (keep the server running in the first one).
- Test the GET route by running:
curl http://localhost:5000/api/expenses
You should see an empty array [] returned. That confirms the route is wired up and talking to MongoDB.
Seeing 'Cannot GET' or connection refused?
Check that the server is still running in your other terminal. If it crashed, look at the error message. A common cause is a typo in the import path (./routes/expenses.js must include the .js extension for ES modules).
help me debug my Express route
Now add the POST route so you can create expenses.
- In server/routes/expenses.js, find the line:
export default router;
- Add the following POST route directly above that line:
// CREATE a new expense
router.post('/', async (req, res) => {
const expense = new Expense({
name: req.body.name,
amount: req.body.amount,
category: req.body.category
});
try {
const newExpense = await expense.save();
res.status(201).json(newExpense);
} catch (error) {
res.status(400).json({ message: error.message });
}
});
What does this code do?
The POST route creates a new Expense document from the request body (the JSON sent by the client). It pulls out name, amount, and category, then saves the document to MongoDB.
On success, it returns a 201 status (meaning "created") with the full expense object, including the _id that MongoDB generated. On failure (like a missing required field), it returns a 400 status with the error message.
- Save server/routes/expenses.js.
- Switch to the terminal running your server and stop it with Ctrl+C.
- Restart the server by running:
node server.js
- Switch to your second terminal and test the POST route by running:
curl -X POST http://localhost:5000/api/expenses -H "Content-Type: application/json" -d '{"name":"Coffee","amount":4.50,"category":"Food"}'
You should see the created expense returned as JSON, including an _id field and a date timestamp.
Getting a 400 error?
A 400 response means a required field is missing. Double-check that your curl command includes all three fields: name, amount, and category. On Windows, you may need to escape the double quotes differently or use PowerShell's Invoke-WebRequest instead.
help me fix my POST request
Now add the DELETE route to complete the API.
- In server/routes/expenses.js, find the line:
export default router;
- Add the following DELETE route directly above that line:
// DELETE an expense by ID
router.delete('/:id', async (req, res) => {
try {
const expense = await Expense.findByIdAndDelete(req.params.id);
if (!expense) {
return res.status(404).json({ message: 'Expense not found' });
}
res.json({ message: 'Expense deleted' });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
What does this code do?
The /:id in the route path is a URL parameter. When someone sends a DELETE request to /api/expenses/abc123, Express captures abc123 as req.params.id.
findByIdAndDelete removes the document from MongoDB in a single operation. If no document matches the ID, it returns null, and the route responds with a 404 status.
- Save server/routes/expenses.js.
Test the API with curl
All three routes are in place. Time to restart the server and confirm the full CRUD cycle works end to end.
- Switch to the terminal running your server and stop it with Ctrl+C.
- Restart the server by running:
node server.js
- Switch to your second terminal.
- First, confirm your earlier expense is still there by running:
curl http://localhost:5000/api/expenses
You should see an array containing the "Coffee" expense you created earlier.
Before you run the next command, what do you think will happen when you DELETE the expense using its _id?
- Copy the _id value from the JSON output (it looks like a long string of letters and numbers).
- Delete the expense by running this command, replacing the ID with your actual value:
curl -X DELETE http://localhost:5000/api/expenses/[[EXPENSE_ID="the _id value from your GET response"]]
You should see {"message":"Expense deleted"} returned.
- Confirm it is gone by running GET again:
curl http://localhost:5000/api/expenses
You should see an empty array [] again. The expense was successfully removed from MongoDB.
Getting 'Expense not found' on DELETE?
Make sure you copied the full _id string with no extra spaces. The ID is the value after "_id":" in the JSON (e.g. 6693a1b2c3d4e5f678901234). If you already deleted it, POST a new expense first, then try DELETE with the new ID.
help me delete an expense
✔️ Awesome, I've got everything!
Great. Double check you have saved both server/routes/expenses.js and server/server.js.
ⓧ I'd like to double check the full code
Here is what server/models/Expense.js should look like:
import mongoose from 'mongoose';
const expenseSchema = new mongoose.Schema({
name: {
type: String,
required: true
},
amount: {
type: Number,
required: true
},
category: {
type: String,
required: true
},
date: {
type: Date,
default: Date.now
}
});
const Expense = mongoose.model('Expense', expenseSchema);
export default Expense;
Here is what server/server.js should look like:
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import cors from 'cors';
import expenseRoutes from './routes/expenses.js';
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
app.use('/api/expenses', expenseRoutes);
mongoose.connect(process.env.MONGODB_URI)
.then(() => {
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((error) => {
console.error('MongoDB connection error:', error);
});
For server/routes/expenses.js, confirm the following chunks are present in order: the imports and Router() setup at the top, then the GET route, then the POST route, then the DELETE route, then export default router; at the bottom. Scroll up through the chunks above and compare each one against your file.
Your API is fully functional. You can create expenses, list them, and delete them, all backed by MongoDB Atlas. Next up, you will connect your React frontend to this API so expenses appear in the browser.
Display Expenses in React
Your API handles Create, Read, and Delete operations, and you proved it works by sending requests with curl. But right now, nobody can actually see the data without running terminal commands.
In this step, you'll connect the React frontend to your Express API so expenses appear in the browser as a live list with a running total. This is the moment the app starts to feel real.
In this step, get ready to:
- Set up the Vite proxy to forward API requests to your Express server.
- Replace the default React starter page with an expense-fetching component.
- Style the expense list so it is readable and organized.
Set up the Vite proxy and create the App structure
Right now, the Vite dev server runs on port 5173 and your Express API runs on port 5000. When your React code calls /api/expenses, Vite does not know where to forward that request.
A Vite proxy solves this by intercepting any request that starts with /api and sending it to http://localhost:5000 behind the scenes.
- Open client/vite.config.js in VS Code and replace its entire contents with this proxy configuration:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
});
What does this config do?
The server.proxy option tells Vite's dev server to intercept any request whose path starts with /api and forward it to your Express backend at http://localhost:5000.
The changeOrigin: true setting updates the request's origin header so Express treats it as a same-origin request. This eliminates CORS issues during development.
Now you need to replace the default Vite starter page with a component that fetches your expense data.
- Open client/src/App.jsx and delete everything inside it.
- Add the following imports and data-fetching logic at the top of the empty file:
import { useState, useEffect } from 'react';
import './App.css';
function App() {
// Store all expenses fetched from the API
const [expenses, setExpenses] = useState([]);
// Fetch expenses once when the component first renders
useEffect(() => {
fetchExpenses();
}, []);
async function fetchExpenses() {
const response = await fetch('/api/expenses');
const data = await response.json();
setExpenses(data);
}
What does this code do?
- useState([]) creates an expenses variable that starts as an empty array. React re-renders the page whenever this array changes.
- useEffect(..., []) runs fetchExpenses() once when the component first appears on screen. The empty [] means it does not re-run on every render.
- fetchExpenses() calls your Express API at /api/expenses, converts the response to JSON, and stores the result in state.
- Still in the same file, add the total calculation and the JSX return directly below the fetchExpenses function:
// Calculate running total from all expense amounts
const total = expenses.reduce((sum, exp) => sum + exp.amount, 0);
return (
<div className="app">
<h1>Expense Tracker</h1>
<div className="total">Total: ${total.toFixed(2)}</div>
<ul className="expense-list">
{expenses.map(expense => (
<li key={expense._id} className="expense-item">
<div className="expense-display">
<span className="expense-name">{expense.name}</span>
<span className="expense-category">{expense.category}</span>
<span className="expense-amount">${expense.amount.toFixed(2)}</span>
</div>
</li>
))}
</ul>
</div>
);
}
export default App;
What does this code do?
- expenses.reduce(...) adds up every expense amount into a single total number.
- expenses.map(...) loops over the array and creates a list item for each expense, showing its name, category, and amount formatted as currency.
- key={expense._id} gives React a unique identifier for each list item so it can efficiently update the DOM when items change.
- Save client/vite.config.js and client/src/App.jsx.
- Make sure your Express server is still running in its terminal (the one showing "Server running on port 5000").
- Open a second terminal, navigate to the client folder, and start the Vite dev server by running:
cd client
npm run dev
Before you open the browser, think about what you expect to see. Will it show styled content, raw unstyled text, or an error?
- Open http://localhost:5173 in your browser.
You should see "Expense Tracker" as a heading, a total amount, and the expenses you added via curl in the previous step listed on the page. The text will look plain and unstyled for now.
Don't see your expenses?
- Check that your Express server is running in another terminal. The Vite proxy forwards requests to port 5000, so Express must be listening.
- Open your browser's developer console (F12) and look for network errors. A 500 error means your MongoDB connection might have dropped.
- Confirm you saved vite.config.js before starting the dev server. The proxy config only loads at startup.
Help me debug why my React app is not showing expenses from the API.
✔️ Awesome, I've got everything!
Great. Double check you have saved both client/vite.config.js and client/src/App.jsx.
ⓧ I'd like to double check the full code
Your client/vite.config.js should look exactly like this:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
});
Your client/src/App.jsx should contain these two sections in order:
- The imports, useState, useEffect, and fetchExpenses function (first code block above).
- The total calculation, JSX return with the expense list, and export default App (second code block above).
Scroll up and confirm both sections are present in your file, in that exact order.
Style the expense list
The expenses are on the page, but they are plain unstyled text. A few CSS rules will make the list readable and organized.
- Open client/src/App.css and delete everything inside it.
- Add the following layout and heading styles at the top of the empty file:
.app {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #333;
}
.total {
text-align: center;
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1.5rem;
color: #2d6a4f;
}
What do these styles do?
- .app centers the content and caps the width at 600px so it does not stretch across the full screen.
- .total makes the running total stand out with a larger font size and green color.
- Continue in the same file by adding the expense list styles below the .total block:
.expense-list {
list-style: none;
padding: 0;
}
.expense-item {
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.expense-display {
display: flex;
align-items: center;
gap: 0.75rem;
}
.expense-name {
flex: 2;
font-weight: 500;
}
.expense-category {
flex: 1;
color: #666;
font-size: 0.85rem;
}
.expense-amount {
font-weight: bold;
color: #d00;
}
What do these styles do?
- .expense-display uses flexbox to lay out the name, category, and amount in a horizontal row.
- .expense-name takes up twice the space (flex: 2) so it gets the most room.
- .expense-amount is bold and red so the dollar figure catches your eye immediately.
- Save client/src/App.css.
- Switch back to your browser at http://localhost:5173. Vite's hot module replacement will refresh the page automatically.
You should see your expenses in a clean, centered list. Each row shows the expense name on the left, category in the middle, and amount in red on the right. The green total at the top sums all your expenses.
Styles not applying?
- Confirm the import at the top of App.jsx says import './App.css' exactly. A wrong path means the CSS never loads.
- Check that the class names in your CSS (.expense-list, .expense-item, etc.) match the className attributes in your JSX exactly.
Help me figure out why my CSS styles are not showing up in my React expense tracker.
✔️ Awesome, I've got everything!
Great. Double check you have saved client/src/App.css.
ⓧ I'd like to double check the full code
Your client/src/App.css should contain these two sections in order:
- The .app, h1, and .total rules (first CSS block above).
- The .expense-list, .expense-item, .expense-display, .expense-name, .expense-category, and .expense-amount rules (second CSS block above).
Scroll up and confirm both sections are present in your file, in that exact order.
Your expense data is now visible in the browser. Next up, you will build a form to add and delete expenses directly from the UI so you never have to touch curl again.
Add and Delete Expenses from the UI
Your expense tracker can display data, but the only way to add or remove expenses is through curl commands in the terminal. That is not a real app.
In this step, you will add a form that lets you type in an expense and save it to the database, plus a Delete button on each row to remove entries. This closes the loop and turns your read-only display into an interactive tool.
In this step, get ready to:
- Build an Add Expense form that saves to your database without refreshing the page.
- Add delete functionality so you can remove expenses and see the total recalculate instantly.
Add form state and the submit handler
The form needs three pieces of React state to track what the user types: the expense name, amount, and category. It also needs a function that sends this data to your API when the user clicks submit.
- In client/src/App.jsx, find this line inside the App function:
const [expenses, setExpenses] = useState([]);
- Add the following state variables and submit handler directly below that line:
const [name, setName] = useState('');
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('Food');
// Sends new expense to the API and adds it to state
async function handleSubmit(e) {
e.preventDefault();
const response = await fetch('/api/expenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, amount: parseFloat(amount), category })
});
const newExpense = await response.json();
setExpenses([newExpense, ...expenses]);
setName('');
setAmount('');
setCategory('Food');
}
What does this code do?
The three useState calls track each form field. React re-renders the form whenever any of these values change, keeping the inputs in sync with state.
The handleSubmit function prevents the default page refresh, POSTs the data to your Express API, prepends the returned expense to the list (so it shows at the top), and resets the form fields back to empty.
- Save App.jsx.
- Check your browser at http://localhost:5173. The app should still render without errors (no visible change yet since the form JSX comes next).
Seeing a blank page or console error?
- Make sure the new useState lines are inside the App function, not outside it.
- Check that every parenthesis and bracket is matched. A missing ) or } after the new code will break the entire component.
- Why is my App.jsx showing a syntax error after adding state?
Add the form JSX to the page
Now you need the actual HTML form that the user interacts with. This goes between the total display and the expense list.
- In App.jsx, find the closing </div> of the total display:
<div className="total">Total: ${total.toFixed(2)}</div>
- Add the following form directly below that line, above the <ul> element:
<form onSubmit={handleSubmit} className="expense-form">
<input
type="text"
placeholder="Expense name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="number"
placeholder="Amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
step="0.01"
required
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="Food">Food</option>
<option value="Transport">Transport</option>
<option value="Entertainment">Entertainment</option>
<option value="Bills">Bills</option>
<option value="Other">Other</option>
</select>
<button type="submit">Add Expense</button>
</form>
What does this form do?
- Each input's value is bound to a state variable and onChange updates that variable on every keystroke. This is called a controlled component in React.
- The required attribute on the inputs prevents submission with empty fields.
- The <select> gives users five category options. Its default is Food because the state initialises to that value.
- Save App.jsx and check your browser.
You should see a form appear above your expense list with a text input, a number input, a category dropdown, and an **Add Expense** button.
- Type Lunch in the name field.
- Enter 12.50 in the amount field.
- Leave the category as **Food**.
- Click **Add Expense**.
The expense appears at the top of the list, the total updates, and the form clears itself. No page refresh needed.
Expense not appearing after clicking Add?
- Make sure your Express server is still running in a separate terminal. The form POSTs to /api/expenses which needs the backend to respond.
- Open your browser's DevTools console (F12) and check for network errors. A 500 response means the server could not save the expense to MongoDB.
- Why isn't my Add Expense form saving to the database?
Style the form
The form works but the inputs are stacked vertically with no spacing. A few CSS rules will lay them out in a horizontal row that wraps on smaller screens.
- In client/src/App.css, find the closing brace of the .total rule. Add the following styles directly below it:
.expense-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.expense-form input,
.expense-form select {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
}
.expense-form input[type="text"] {
flex: 2;
}
.expense-form input[type="number"] {
flex: 1;
}
What do these styles do?
The display: flex and flex-wrap: wrap lay the inputs out in a row that wraps if the screen is too narrow. The flex: 2 on the text input gives it double the width of the number input, since expense names are usually longer than amounts.
- Now add the button styles below the code you just pasted:
.expense-form button {
padding: 0.5rem 1rem;
background-color: #2d6a4f;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.expense-form button:hover {
background-color: #1b4332;
}
What does this style?
This gives the Add Expense button a green background that matches the total color and darkens on hover to signal interactivity.
- Save App.css and check your browser.
The form inputs now sit in a neat horizontal row with rounded borders, and the Add Expense button is green with white text.
Form still looks unstyled?
- Confirm the class name in your JSX is expense-form (with a hyphen). CSS class names are case-sensitive and must match exactly.
- Make sure you saved both App.css and App.jsx.
- Why aren't my CSS styles showing up on the form?
Add delete functionality
You can add expenses, but you cannot remove them from the browser. A delete handler and a button on each row will complete the loop.
- In App.jsx, find the closing brace of the handleSubmit function. Add the following function directly below it:
// Removes an expense from the database and state
async function handleDelete(id) {
await fetch(`/api/expenses/${id}`, { method: 'DELETE' });
setExpenses(expenses.filter(expense => expense._id !== id));
}
What does this code do?
The function sends a DELETE request to your Express API with the expense's MongoDB _id. Then it filters that expense out of the state array, which makes React re-render the list without the deleted item. The total recalculates automatically because it derives from the expenses array.
- Now scroll down to the expense list JSX. Find this line inside the .expense-display div:
<span className="expense-amount">${expense.amount.toFixed(2)}</span>
- Add a Delete button directly below that line, still inside the .expense-display div:
<button onClick={() => handleDelete(expense._id)}>Delete</button>
- Save App.jsx and check your browser.
Each expense row now has a Delete button on the right side. Click it on any expense and watch it disappear from the list with the total recalculating instantly.
Delete button not removing the expense?
- Check the browser console for errors. A 404 means the expense ID is not reaching the server correctly.
- Make sure you used expense._id (with an underscore). MongoDB uses _id as its identifier, not id.
- Why isn't my delete button removing the expense from the list?
Style the buttons
The Delete button works but it looks like a plain browser default. A quick style pass will make it match the rest of the UI.
- In App.css, scroll to the bottom of the file and add these styles after the .expense-amount rule:
.expense-display button {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.expense-display button:hover {
background: #f0f0f0;
}
What does this style?
The Delete button gets a subtle border and a light gray hover effect. It stays small and unobtrusive so it does not compete visually with the expense data.
- Save App.css and check your browser.
The Delete button now has a clean border, rounded corners, and a subtle hover highlight.
Before you call this done, predict what will happen when you add a new expense and then delete it. Will the total update both times?
- Add a test expense (e.g. Coffee, 4.50, Food) and confirm the total increases.
- Click **Delete** on that expense and confirm the total decreases back.
Both work instantly because the total is derived from the expenses state array. Every time you add or remove from that array, React recalculates and re-renders.
✔️ 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
Scroll up and confirm your App.jsx contains each of these pieces in order:
- The import line and four useState declarations (expenses, name, amount, category).
- The useEffect calling fetchExpenses.
- The fetchExpenses, handleSubmit, and handleDelete functions.
- The total calculation using reduce.
- The return block with the heading, total, form, and expense list (including the Delete button in each row).
For App.css, confirm these rule blocks appear in order: .app, h1, .total, .expense-form (and its input/button variants), .expense-list, .expense-item, .expense-display, name/category/amount spans, and .expense-display button with its hover.
Your expense tracker is now a fully interactive CRUD app. You can add, view, and delete expenses from the browser with the total recalculating in real time. Next up, you will take on a Secret Mission to add editing so you can fix mistakes without deleting and re-adding.
Secret mission
Add an Edit Expense Feature
Right now, if you mistype an expense name or enter the wrong amount, you have to delete it and re-add it from scratch. Real apps let you fix mistakes in place. Can you add inline editing so clicking an expense transforms it into an editable form?
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 on a free MongoDB Atlas cluster and local files, so there are no ongoing costs.
Resources you used:
- MongoDB Atlas M0 cluster (stores your expense documents).
- Local project files in the expense-tracker folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to return to your expense tracker later.
- Your Atlas M0 cluster is free forever. It stays active at no cost.
- To use your tracker again, start both servers. Run node server.js in the server folder and npm run dev in the client folder.
- Atlas will automatically pause your free cluster after 30 days of inactivity. You can resume it at any time from the Atlas dashboard.
Pause - I'll come back to this later
Shut down running processes to free up local resources. Your data stays safe in Atlas.
- Stop the Express server and Vite dev server by pressing Ctrl+C in each terminal window.
- Your Atlas free cluster has no idle charges. It will pause itself after 30 days of inactivity and can be resumed anytime.
- Your local project files remain on your Desktop. No further action needed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your expense data.
- Terminate your Atlas cluster:
- Go to your MongoDB Atlas dashboard and navigate to the Clusters page.
- Click the ... (ellipsis) next to your cluster.
- Click Terminate from the drop-down menu.
- Enter your cluster name to confirm, then click Terminate.
- Delete the local project files by running this command (macOS/Linux):
cd ~/Desktop
rm -rf expense-tracker
- On Windows, run this command instead:
cd ~\Desktop
rmdir /s /q expense-tracker
Nice Work!
Nice Work!
Nice work! You just built a full-stack expense tracker from scratch using the MERN stack. You can add, view, and delete expenses in the browser, with every change persisted in a cloud database.
You've learned how to:
- Built a REST API with Express and Mongoose that handles Create, Read, and Delete operations.
- Connected a React frontend to a backend API using fetch and a Vite dev proxy.
- Managed React state to keep the UI in sync with the database without page refreshes.
- Secret Mission: Added inline editing with PUT requests and conditional rendering.
Ready to quiz yourself?