Build an Expense Tracker with MERN

Build a full-stack expense tracker with React, Express, and MongoDB Atlas.

Introduction

30 Second Summary

You know that feeling when you check your bank account and wonder where all your money went? Tracking expenses is one of those tasks that sounds simple but becomes surprisingly useful when you have a custom tool built exactly how you want it.

In this project, you will build a full-stack expense tracker using the MERN stack (MongoDB, Express, React, Node.js). You will add, view, edit, and delete expenses stored in a cloud database, learning how data flows through every layer of a modern web application.

What You'll Build

Picture typing "Coffee - $4.50 - Food" into your app, watching it appear in your expense list instantly, and seeing a running total update as each entry lands.

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

  • A working expense form where you type a description, amount, and category, then see the entry appear in your list without refreshing the page.
  • A running total that recalculates automatically as you add or remove expenses, with all data persisting in a MongoDB Atlas cloud database that survives browser refreshes.
  • Full CRUD operations (Create, Read, Update, Delete) through inline editing and one-click deletion so you can fix mistakes or remove entries instantly.
  • Secret Mission: Add category filtering so you can view just your "Food" or "Transport" expenses and their subtotals.

Are there any prerequisites?

You should have basic JavaScript knowledge (variables, functions, arrays, objects) and Node.js installed on your machine. No prior experience with MongoDB, Express, or React is needed.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

This project takes you through the full journey of data flowing from a React form to an Express API to a MongoDB database and back again.

Scaffold the Project and Connect to MongoDB Atlas

Your expense tracker needs three pieces working together: a React frontend where you interact with the app, an Express API that handles requests, and a MongoDB database that stores your data.

In this step, you will scaffold both the frontend and backend inside a single project folder, create a free cloud database on MongoDB Atlas, and wire everything so one command starts your whole app.

In this step, get ready to:
  • Create the monorepo structure with client/ and server/ folders.
  • Set up a MongoDB Atlas free cluster and connect Express to it.
  • Run both servers simultaneously with a single command.
Create the project folders and install dependencies

You will create a root folder that holds both your React client and your Express server side by side. This monorepo pattern keeps everything in one place so you can start both with a single command.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize a root package.json by running these commands:
mkdir expense-tracker
cd expense-tracker
npm init -y
  • Scaffold the React client using Vite by running this command:
npm create vite@latest client -- --template react

What prompts will I see?

npm will ask to install the create-vite package. Type y and press Enter to proceed.

You may then see a prompt asking about rolldown-vite (Experimental). Select No. If asked Install with npm and start now?, select No as well. You will install dependencies manually in a moment.

  • Install the client dependencies by running these commands:
cd client
npm install
cd ..
  • Confirm the client/ folder exists by running:
ls

You should see client listed alongside package.json and node_modules.

  • Create the server folder, initialize it, and install its dependencies by running these commands:
mkdir server
cd server
npm init -y
npm install express mongoose cors dotenv

You should see output confirming four packages were added.

What are these packages?

  • express is the web framework that handles HTTP requests.
  • mongoose connects to MongoDB and lets you define data schemas.
  • cors allows cross-origin requests between your frontend and backend.
  • dotenv loads environment variables from a .env file so secrets stay out of your code.
  • Open server/package.json in VS Code and replace its entire contents with the following:
{
  "name": "expense-tracker-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node --watch index.js"
  },
  "dependencies": {
    "cors": "^2.8.6",
    "dotenv": "^17.4.2",
    "express": "^5.2.1",
    "mongoose": "^9.7.4"
  }
}

What does this change?

  • "type": "module" enables ES module syntax (import/export) instead of require.
  • "dev": "node --watch index.js" uses Node's built-in file watcher so the server restarts automatically when you save changes.
  • Save server/package.json.
  • Create a new file called index.js inside the server/ folder and paste the following code:
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import mongoose from 'mongoose'

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('MongoDB connected')
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`)
    })
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err)
  })

What does this code do?

  • import 'dotenv/config' loads your .env file automatically so process.env.MONGODB_URI is available.
  • express.json() parses incoming JSON request bodies so your routes can read them.
  • mongoose.connect() connects to your MongoDB database. Only after that succeeds does app.listen() start the server on port 5000.
  • Save server/index.js.
  • Navigate back to the project root and install concurrently as a dev dependency by running:
cd ..
npm install -D concurrently

You should see concurrently added to the root package.json devDependencies.

Seeing permission errors on install?

On macOS, try prefixing the install command with sudo. On Windows, open your terminal as Administrator. If you see network errors, check your internet connection and try again.

help me fix npm install permission errors

Set up MongoDB Atlas and create the environment file

Your Express server needs a real database to connect to. MongoDB Atlas provides a free cloud database that takes minutes to set up.

  • Open your browser and go to mongodb.com/cloud/atlas/register.
  • Create a free account. You can click Sign up with Google for the fastest option, or fill in your details and click Create your Atlas account.
  • Once logged in, click Create to start a new cluster.
  • Select the Free cluster tier (M0).
  • Choose any cloud provider (AWS, Google Cloud, or Azure) and select a region close to you.
  • Give your cluster a name (for example, expense-tracker) and click Create.

What is MongoDB Atlas?

MongoDB Atlas is a cloud-hosted database service. The free tier gives you 512MB of storage that never expires. Your data lives on their servers so you do not need to install MongoDB locally.

  • Atlas will show a Security Quickstart. Create a database user by entering a Username and Password. Remember these credentials.
  • Click Create Database User.
  • Under IP Access, click Add My Current IP Address.
  • Click Finish and Close, then click Go to Overview.
  • On the cluster overview page, click Connect.
  • Select Drivers as your connection method.
  • Copy the connection string. It looks like mongodb+srv://username:<password>@cluster-name.xxxxx.mongodb.net/.
  • Replace <password> in the string with the database user password you created moments ago.
  • In VS Code, create a new file called .env inside the server/ folder and paste the following:
MONGODB_URI=your-mongodb-atlas-connection-string-here
PORT=5000
  • Replace your-mongodb-atlas-connection-string-here with the connection string you copied from Atlas (the one with your real password filled in).
  • Save server/.env.

Never commit your .env file

The .env file contains your database password. If you use Git, add .env to your .gitignore so it never gets pushed to a public repository.

Now test that your server can connect to MongoDB. Run this from the server/ folder:

  • Start the Express server by running these commands:
cd server
node --watch index.js

You should see two lines printed in your terminal:

MongoDB connected
Server running on port 5000

Seeing a MongoDB connection error?

  • Check that your connection string in server/.env has <password> replaced with your actual database user password.
  • Make sure you added your IP address in the Atlas Security Quickstart. If you skipped it, go to Network Access in Atlas and click Add My Current IP Address.
  • Confirm the cluster has finished provisioning (green circle next to the name in Atlas).

help me fix my MongoDB Atlas connection error

  • Stop the server by pressing Ctrl+C in your terminal.
  • Navigate back to the project root by running:
cd ..
  • Now configure the Vite proxy. Open client/vite.config.js in VS Code and replace its entire contents with:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

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

Why add a proxy?

Your React app runs on port 5173 and your Express server runs on port 5000. Without the proxy, the browser would block requests between different ports due to CORS rules. The proxy tells Vite to forward any request starting with /api to your Express server automatically.

  • Save client/vite.config.js.
Add the dev script and run both servers

The final piece is a root-level script that starts both your React dev server and your Express server with a single command using concurrently.

  • Open the root package.json (the one in the expense-tracker/ folder, not inside client/ or server/) and replace its entire contents with:
{
  "name": "expense-tracker",
  "version": "1.0.0",
  "scripts": {
    "dev": "concurrently \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
    "install-all": "npm install && cd client && npm install && cd ../server && npm install"
  },
  "devDependencies": {
    "concurrently": "^10.0.3"
  }
}

What does the dev script do?

concurrently runs two commands at the same time: npm run dev --prefix server starts Express, and npm run dev --prefix client starts the Vite dev server. One terminal, both servers.

  • Save the root package.json.

Before you run this, what do you expect to see in your terminal? Think about which two servers should start and what messages they print.

  • Start both servers by running this command from the project root:
npm run dev

You should see output from both servers. Look for these two key lines:

  • Server running on port 5000 and MongoDB connected from Express.
  • A Vite URL showing Local: http://localhost:5173/ from the React dev server.

Only one server starts?

  • Make sure you are running npm run dev from the expense-tracker/ root folder, not from inside client/ or server/.
  • Check that concurrently appears in the root package.json under devDependencies.
  • Ensure server/package.json has the "dev": "node --watch index.js" script.

help me fix my concurrently script not starting both servers

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files, then leave both servers running for the next step.

ⓧ I'd like to double check the full code

Here is each file you should have at this point. Compare yours against these:

server/index.js:

import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import mongoose from 'mongoose'

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('MongoDB connected')
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`)
    })
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err)
  })

server/package.json:

{
  "name": "expense-tracker-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node --watch index.js"
  },
  "dependencies": {
    "cors": "^2.8.6",
    "dotenv": "^17.4.2",
    "express": "^5.2.1",
    "mongoose": "^9.7.4"
  }
}

server/.env:

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

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:5000',
        changeOrigin: true,
      },
    },
  },
})

Root package.json:

{
  "name": "expense-tracker",
  "version": "1.0.0",
  "scripts": {
    "dev": "concurrently \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
    "install-all": "npm install && cd client && npm install && cd ../server && npm install"
  },
  "devDependencies": {
    "concurrently": "^10.0.3"
  }
}

Your project skeleton is alive. Both servers start with one command, and Express is connected to your cloud database. Next up, you will build the API that creates, reads, updates, and deletes expenses.

Build the Express API

Your servers are running and MongoDB is connected. Now you need a way for the frontend to actually talk to that database.

The API is the bridge between React and MongoDB. Without it, your frontend has no way to store or retrieve expenses. In this step, you will build a complete REST API with endpoints for creating, reading, updating, and deleting expenses.

In this step, get ready to:
  • Create a Mongoose data model that defines the shape of an expense.
  • Build Express routes for all four CRUD operations.
  • Wire the routes into your server and verify the API responds.
Create the Mongoose model

Before Express can handle expense data, it needs to know what an expense looks like. Mongoose schemas define the structure and validation rules for documents stored in MongoDB.

  • In VS Code, create a new folder inside server/ called models.
  • Inside server/models/, create a new file called Expense.js.
  • Add the following schema and model code to Expense.js:
import mongoose from 'mongoose'

const expenseSchema = new mongoose.Schema(
  {
    description: { type: String, required: true },
    amount: { type: Number, required: true },
    category: { type: String, required: true },
  },
  { timestamps: true }
)

export default mongoose.model('Expense', expenseSchema)

What does this code do?

This file defines the shape of every expense document in your MongoDB database.

  • expenseSchema declares three required fields: description (text), amount (number), and category (text).
  • { timestamps: true } tells Mongoose to automatically add createdAt and updatedAt fields to every document.
  • mongoose.model('Expense', expenseSchema) creates a reusable model that your routes will use to query and save data.
  • Save server/models/Expense.js.
  • Check your terminal. The server restarts automatically (thanks to --watch). You should still see "MongoDB connected" and "Server running on port 5000" with no errors.

Seeing an error in the terminal?

  • Check that your file is named exactly Expense.js (capital E) inside a models folder.
  • Make sure the file is inside server/models/, not at the root of the project.
  • Confirm the file starts with import mongoose from 'mongoose' and has no typos in the field definitions.

Why is my model file causing a server error?

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import mongoose from 'mongoose'

const expenseSchema = new mongoose.Schema(
  {
    description: { type: String, required: true },
    amount: { type: Number, required: true },
    category: { type: String, required: true },
  },
  { timestamps: true }
)

export default mongoose.model('Expense', expenseSchema)
Create the Express routes

With the model ready, you can now build the endpoints that handle HTTP requests. Each endpoint maps to one CRUD operation: Create, Read, Update, Delete.

  • Inside server/, create a new folder called routes.
  • Inside server/routes/, create a new file called expenses.js.
  • Add all four CRUD route handlers to expenses.js:
import express from 'express'
import Expense from '../models/Expense.js'

const router = express.Router()

// GET all expenses, newest first
router.get('/', async (req, res) => {
  const expenses = await Expense.find().sort({ createdAt: -1 })
  res.json(expenses)
})

// POST a new expense
router.post('/', async (req, res) => {
  const expense = new Expense(req.body)
  const saved = await expense.save()
  res.status(201).json(saved)
})

// PUT (update) an expense by ID
router.put('/:id', async (req, res) => {
  const updated = await Expense.findByIdAndUpdate(req.params.id, req.body, { new: true })
  res.json(updated)
})

// DELETE an expense by ID
router.delete('/:id', async (req, res) => {
  await Expense.findByIdAndDelete(req.params.id)
  res.json({ message: 'Expense deleted' })
})

export default router

What does this code do?

This file creates four API endpoints, all mounted under /api/expenses (you will wire that prefix in the next substep).

  • GET / fetches every expense from MongoDB, sorted newest first.
  • POST / takes a JSON body, creates a new expense document, and returns it with a 201 status.
  • PUT /:id finds an expense by its MongoDB ID, applies the updated fields, and returns the new version.
  • DELETE /:id removes an expense permanently and returns a confirmation message.
  • Save server/routes/expenses.js.

Seeing a syntax error?

  • Make sure the import path is '../models/Expense.js' with the .js extension. ES modules in Node.js require the full file extension.
  • Confirm there are no mismatched parentheses or brackets in the route handlers.

Help me fix a syntax error in my routes file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import express from 'express'
import Expense from '../models/Expense.js'

const router = express.Router()

router.get('/', async (req, res) => {
  const expenses = await Expense.find().sort({ createdAt: -1 })
  res.json(expenses)
})

router.post('/', async (req, res) => {
  const expense = new Expense(req.body)
  const saved = await expense.save()
  res.status(201).json(saved)
})

router.put('/:id', async (req, res) => {
  const updated = await Expense.findByIdAndUpdate(req.params.id, req.body, { new: true })
  res.json(updated)
})

router.delete('/:id', async (req, res) => {
  await Expense.findByIdAndDelete(req.params.id)
  res.json({ message: 'Expense deleted' })
})

export default router
Wire the routes into your server

The routes file exists, but Express does not know about it yet. You need to import it and register it with a URL prefix.

  • In server/index.js, find your import lines at the top of the file. Locate this line:
import mongoose from 'mongoose'
  • Add this new import directly below it:
import expenseRoutes from './routes/expenses.js'
  • Now find this line further down:
app.use(express.json())
  • Add this route registration directly below it:
app.use('/api/expenses', expenseRoutes)

What does this line do?

app.use('/api/expenses', expenseRoutes) tells Express to forward any request starting with /api/expenses to your routes file. The / in each route handler is relative to this prefix.

So router.get('/') handles GET /api/expenses, and router.delete('/:id') handles DELETE /api/expenses/abc123.

  • Save server/index.js.
  • Check your terminal. The server should restart and still show "MongoDB connected" and "Server running on port 5000".

Server crashing after saving?

  • If you see an error mentioning the module cannot be found, confirm the import path is './routes/expenses.js' with the .js extension.
  • Make sure server/routes/expenses.js exists and is saved. If the file is missing, the import will fail.
  • Check that the new lines are outside the mongoose.connect() block, not inside it.

Help me fix my server crash after adding routes.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files before testing.

ⓧ I'd like to double check the full code

import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import mongoose from 'mongoose'
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('MongoDB connected')
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`)
    })
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err)
  })

Time to test the API. Before you open your browser, what do you think you will see when you visit the expenses endpoint on an empty database?

  • Open your browser and visit http://localhost:5000/api/expenses.

You should see an empty JSON array: []. That is your GET endpoint responding successfully. There are no expenses in the database yet, so it returns an empty list.

Not seeing the empty array?

  • If the page says "Cannot GET /api/expenses", the route is not registered. Make sure app.use('/api/expenses', expenseRoutes) appears in server/index.js and that you saved the file.
  • If the page does not load at all, confirm the server is still running in your terminal. Look for "Server running on port 5000".
  • If you see a connection error, double check that your .env file has a valid MONGODB_URI connection string.

Why is my API endpoint not returning the empty array?

Your API is live and responding. Next up, you will build the React frontend that fetches from this endpoint and displays expenses on screen.

Display Expenses in React

Your Express API is running and ready to serve expense data. Now you need a frontend that actually fetches and displays that data.

This is where you see the full MERN data flow come alive for the first time. React calls your Express API, Express queries MongoDB, and the result flows back to the browser.

In this step, get ready to:
  • Clear the Vite boilerplate and write an App component that fetches expenses from your API.
  • Render an expenses list with a running total, or a message when the list is empty.
  • Style the app with clean, minimal CSS.
Clear the boilerplate and fetch expenses on load

The Vite scaffold generated demo content in App.jsx and App.css with logos, a counter button, and animations. You need a blank slate to build your expense tracker.

  • In VS Code, open client/src/App.css from the Explorer sidebar.
  • Select all the content and delete it so the file is completely empty.
  • Open client/src/App.jsx from the same sidebar.
  • Select all the content and replace it with the following code:
import { useState, useEffect } from 'react'
import './App.css'

function App() {
  // State to hold all expenses from the database
  const [expenses, setExpenses] = useState([])

  // Fetch expenses when the component first loads
  useEffect(() => {
    fetchExpenses()
  }, [])

  // Get all expenses from the API
  async function fetchExpenses() {
    const res = await fetch('/api/expenses')
    const data = await res.json()
    setExpenses(data)
  }

  // Calculate running total from all expense amounts
  const total = expenses.reduce((sum, e) => sum + e.amount, 0)

  return (
    <div className="app">
      <h1>Expense Tracker</h1>
      <p className="total">Total: ${total.toFixed(2)}</p>
    </div>
  )
}

export default App

What does this code do?

  • useState([]) creates a state variable called expenses that starts as an empty array. When this array changes, React re-renders the component.
  • useEffect(() => { fetchExpenses() }, []) runs fetchExpenses once when the component first appears on screen. The empty [] means it only runs on the initial load.
  • fetch('/api/expenses') sends a GET request to your Express API. The Vite proxy forwards this to localhost:5000 automatically.
  • .reduce() adds up all expense.amount values to produce a running total.
  • Save App.jsx.
  • Check your browser at localhost:5173. You should see the heading "Expense Tracker" and "Total: $0.00" on a plain white background.

Seeing a blank page or error?

  • Check that your npm run dev terminal is still running. If you stopped it, run it again from the expense-tracker root folder.
  • Make sure you deleted ALL the old boilerplate from App.jsx before pasting the new code. Leftover imports or JSX cause syntax errors.
  • Open the browser console (F12 then Console tab) and look for red error messages.

Why is my React component not showing anything?

Render the expenses list and style the app

The heading and total are showing, but you need to render actual expense items below them. You also need styling to make the page readable.

  • In App.jsx, find the <p className="total"> line inside the return statement.
  • Add the following <ul> block directly below it, before the closing </div>:
      <ul className="expense-list">
        {expenses.length === 0 && <p>No expenses yet</p>}
        {expenses.map((expense) => (
          <li key={expense._id} className="expense-item">
            <div className="display-row">
              <span className="description">{expense.description}</span>
              <span className="amount">${expense.amount.toFixed(2)}</span>
              <span className="category">{expense.category}</span>
            </div>
          </li>
        ))}
      </ul>

What does this list rendering do?

  • expenses.length === 0 && <p>No expenses yet</p> conditionally shows a message only when the array is empty.
  • expenses.map(...) loops through each expense and renders a list item. The key={expense._id} prop helps React efficiently update the list when items change.
  • Each item displays three fields: description, amount (formatted to 2 decimal places), and category.
  • Save App.jsx and check your browser.
  • You should see "No expenses yet" appear below the total. The database is empty, so the map produces nothing and only the empty-state message shows.

Not seeing the empty message?

  • Make sure the <ul> block is placed between the <p className="total"> line and the closing </div>. If it's outside the <div className="app">, you'll get a React error about adjacent JSX elements.
  • Check the browser console for any red errors mentioning unexpected tokens or missing brackets.

Why does my React list render nothing or throw an error?

Now add the styling. Open client/src/App.css (which should be empty) and paste the base layout styles:

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

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #f5f5f5;
  padding: 2rem;
}

.app {
  max-width: 600px;
  margin: 0 auto;
}

h1 {
  margin-bottom: 0.5rem;
}

.total {
  font-size: 1.5rem;
  font-weight: bold;
  margin-bottom: 1rem;
  color: #2d6a4f;
}

What do these styles do?

The * reset removes default browser margins and padding so everything starts from zero. The .app class centers the content in a 600px column. The .total class makes the running total large and green so it stands out.

  • Save App.css and check your browser.
  • The page now has a light gray background, centered content, and a green "Total: $0.00" heading.
  • Add the following styles below the existing CSS in the same App.css file to style the expense list items:
.expense-list {
  list-style: none;
}

.expense-item {
  background: white;
  padding: 0.75rem;
  margin-bottom: 0.5rem;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.display-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.description {
  flex: 2;
}

.amount {
  font-weight: bold;
  color: #d62828;
}

.category {
  background: #e9ecef;
  padding: 0.2rem 0.5rem;
  border-radius: 4px;
  font-size: 0.85rem;
}

What do these list styles do?

Each .expense-item gets a white card with a subtle shadow, making items visually distinct. The .display-row uses flexbox to arrange the description, amount, and category side by side. Amounts appear in red and categories get a gray badge.

  • Save App.css.

✔️ Awesome, I've got everything!

Great. Confirm you have saved both client/src/App.jsx and client/src/App.css.

ⓧ I'd like to double check the full code

Your App.jsx should have the state/fetch logic from the first chunk, plus the <ul className="expense-list"> block you added. Scroll up and confirm both are present in order. Here is the complete file:

client/src/App.jsx should contain:

  • Imports of useState and useEffect from react, plus import './App.css'.
  • The App function with useState([]) for expenses, useEffect calling fetchExpenses(), the async fetchExpenses function, and the total calculation with .reduce().
  • A return block with <div className="app"> containing the heading, total paragraph, and the full <ul className="expense-list"> with the conditional empty message and .map() rendering.
  • export default App at the bottom.

client/src/App.css should contain both CSS chunks in order: the base layout styles (reset, body, .app, h1, .total) followed by the list styles (.expense-list, .expense-item, .display-row, .description, .amount, .category).

Before you check the Network tab, what do you think the GET request to /api/expenses will return? Your database has no expenses in it yet.

  • Open your browser DevTools by pressing F12 (or Cmd+Option+I on macOS).
  • Click the Network tab.
  • Refresh the page.
  • Look for the expenses request in the list. Click it and check the response.

You should see a 200 status and an empty JSON array [] in the response. That confirms your React frontend is successfully talking to your Express API, which is querying MongoDB and returning the result.

Why does it say 'No expenses yet'?

Your database is empty. The component fetched expenses, got back an empty array, and rendered the empty-state message. The list rendering works perfectly. The problem is you have no way to add expenses yet.

This is the gap you will close in the next step by building a form that POSTs new expenses to your API.

Your React frontend is alive and talking to your Express API. The full MERN data flow is working. Next up, you will build a form that lets you actually create expenses so that empty list fills up.

Add New Expenses via a Form

Your expense tracker can display expenses from the database, but right now the app is read-only. There is no way to add a new expense from the browser.

In this step, you will create a form component that sends new expenses to your API and updates the list instantly. This is where the full MERN data loop closes: the user types into React, React talks to Express, Express writes to MongoDB, and the result flows back up.

In this step, get ready to:
  • Create an ExpenseForm component with controlled inputs.
  • Wire the form into App.jsx and style it with CSS.
Create the ExpenseForm component

React forms use controlled inputs: each input's value is tied to a piece of state, and every keystroke updates that state. This gives you full control over form data before it gets sent to the API.

  • Create a new file called ExpenseForm.jsx inside client/src/.
  • Add the component logic by pasting this code into the new file:
import { useState } from 'react'

function ExpenseForm({ onAdd }) {
  // State for each form field
  const [description, setDescription] = useState('')
  const [amount, setAmount] = useState('')
  const [category, setCategory] = useState('')

  function handleSubmit(e) {
    e.preventDefault()
    // Don't submit if any field is empty
    if (!description || !amount || !category) return
    // Call the parent's callback with the form data
    onAdd({ description, amount: Number(amount), category })
    // Clear the form after submitting
    setDescription('')
    setAmount('')
    setCategory('')
  }

What does this code do?

Each input field gets its own state variable (description, amount, category). When the form submits, handleSubmit prevents the default page reload, validates that all fields have values, and passes the data up to the parent via the onAdd prop.

The Number(amount) conversion ensures the amount is stored as a number, not a string. After submitting, all fields reset to empty strings so the form is ready for the next entry.

  • Add the form's JSX and export below the handleSubmit function:
  return (
    <form onSubmit={handleSubmit} className="expense-form">
      <input
        type="text"
        placeholder="Description"
        value={description}
        onChange={(e) => setDescription(e.target.value)}
      />
      <input
        type="number"
        placeholder="Amount"
        step="0.01"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
      />
      <input
        type="text"
        placeholder="Category"
        value={category}
        onChange={(e) => setCategory(e.target.value)}
      />
      <button type="submit">Add Expense</button>
    </form>
  )
}

export default ExpenseForm

What does this JSX do?

Each <input> is a controlled input: its value comes from state, and its onChange updates that state on every keystroke. The step="0.01" on the amount input allows decimal values like 4.50.

The onSubmit={handleSubmit} on the form element means pressing Enter or clicking the button both trigger submission.

  • Save ExpenseForm.jsx.
  • In the VS Code Explorer sidebar, confirm you see ExpenseForm.jsx listed inside client/src/.

Don't see the file?

Make sure you created the file inside client/src/, not in the root expense-tracker folder or inside server/. The filename must be exactly ExpenseForm.jsx with a capital E and capital F.

help me find where my ExpenseForm.jsx file ended up

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your ExpenseForm.jsx should contain, in order:

  • The import { useState } from 'react' line at the top.
  • The function declaration with three useState calls for description, amount, and category.
  • The handleSubmit function that validates, calls onAdd, and clears the fields.
  • The return statement with a <form> containing three inputs and a submit button.
  • The export default ExpenseForm at the bottom.
Wire the form into App.jsx

The form component exists but is not connected to your app yet. You need to import it, define the function that POSTs data to your API, and render the form in the UI.

  • In client/src/App.jsx, find the imports at the top of the file:
import { useState, useEffect } from 'react'
import './App.css'
  • Add the ExpenseForm import between those two lines, so your imports look like this:
import { useState, useEffect } from 'react'
import ExpenseForm from './ExpenseForm'
import './App.css'
  • Next, find the state declaration inside the App function:
  const [expenses, setExpenses] = useState([])
  • Add two new state variables directly below it:
  const [expenses, setExpenses] = useState([])
  const [editingId, setEditingId] = useState(null)
  const [editForm, setEditForm] = useState({ description: '', amount: '', category: '' })

Why add editingId and editForm now?

These two state variables prepare for the edit functionality you will build in the next step. Adding them now keeps the state declarations together in one place.

  • Add the addExpense function below the fetchExpenses function by pasting this code:
  async function addExpense(expense) {
    const res = await fetch('/api/expenses', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(expense),
    })
    const newExpense = await res.json()
    // Prepend new expense to the front of the list
    setExpenses([newExpense, ...expenses])
  }

What does addExpense do?

This function sends a POST request to your Express API with the expense data as JSON. The API saves it to MongoDB and returns the saved document (including its new _id).

The setExpenses([newExpense, ...expenses]) line prepends the new expense to the front of the array. This means it appears at the top of the list immediately without needing a page refresh.

  • Finally, find this line in the JSX return:
      <p className="total">Total: ${total.toFixed(2)}</p>
      <ul className="expense-list">
  • Add the ExpenseForm component between those two lines, so it looks like this:
      <p className="total">Total: ${total.toFixed(2)}</p>
      <ExpenseForm onAdd={addExpense} />
      <ul className="expense-list">
  • Save App.jsx.
  • Switch to your browser at localhost:5173. You should see three input fields and an Add Expense button above the "No expenses yet" message.

Don't see the form inputs?

Check the browser console for errors. A common issue is a typo in the import path. Make sure the import says ./ExpenseForm (capital E, capital F, no file extension).

Also confirm your dev servers are still running. If they stopped, run npm run dev from the root expense-tracker folder.

help me debug why my ExpenseForm is not showing up in the browser

✔️ Awesome, I've got everything!

Great. Double check you have saved App.jsx before continuing.

ⓧ I'd like to double check the full code

Your App.jsx should contain, in order:

  • Three imports: useState/useEffect from React, ExpenseForm from ./ExpenseForm, and ./App.css.
  • Three state declarations: expenses, editingId, and editForm.
  • The useEffect that calls fetchExpenses on mount.
  • The fetchExpenses function (unchanged from before).
  • The new addExpense function that POSTs and prepends to state.
  • The total calculation with .reduce().
  • The return with <ExpenseForm onAdd={addExpense} /> between the total and the <ul>.
Style the form and list

The form works but looks cramped. Adding CSS makes the inputs readable and the buttons clickable.

  • In client/src/App.css, find the .total rule block. Add the following form styles directly below it:
.expense-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
  flex-wrap: wrap;
}

.expense-form input {
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  flex: 1;
  min-width: 100px;
}

.expense-form button {
  padding: 0.5rem 1rem;
  background: #2d6a4f;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

What do these styles do?

The display: flex and flex-wrap: wrap lay the inputs in a row that wraps on smaller screens. Each input gets flex: 1 so they share space equally. The button uses the same green as the total for visual consistency.

  • Next, find the .display-row rule:
.display-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}
  • Replace the selector .display-row with .display-row, .edit-row so both row types share the same layout:
.display-row,
.edit-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}
  • Add the following button and edit-input styles at the very end of App.css:
.display-row button,
.edit-row button {
  padding: 0.3rem 0.6rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: white;
  cursor: pointer;
}

.edit-row input {
  padding: 0.3rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  flex: 1;
  min-width: 80px;
}

Why add edit-row styles now?

These styles prepare for the inline editing feature in the next step. Adding them now means the next step can focus purely on logic without switching back to CSS.

  • Save App.css.
  • Check your browser. The form inputs should now sit neatly in a row with rounded borders, and the green Add Expense button should appear at the end.

Form still looks unstyled?

Confirm the class name in your CSS (.expense-form) matches the class name in your JSX (className="expense-form" in ExpenseForm.jsx). CSS class names are case-sensitive.

help me figure out why my form styles are not applying

Before you submit your first expense, what do you think will happen to the "No expenses yet" message when you add one?

  • Type Coffee in the Description field.
  • Type 4.50 in the Amount field.
  • Type Food in the Category field.
  • Click Add Expense.

The "No expenses yet" message disappears and your new expense appears in the list. The total updates to $4.50.

  • Refresh the page to confirm the expense persists. It reappears because it was saved to MongoDB through your Express API.

Expense not appearing after submit?

Open the browser DevTools Network tab and submit the form again. Look for a POST request to /api/expenses. If you see a 400 or 500 error, check that your Express server is running and that express.json() middleware is in server/index.js.

If the request succeeds (201 status) but the expense does not appear in the list, confirm that addExpense calls setExpenses([newExpense, ...expenses]) after the response.

help me debug why my expense form submission is not working

Your app now handles both reading and creating expenses. The full loop works: type into React, POST to Express, save to MongoDB, update the UI. Next up, you will complete the CRUD cycle by adding edit and delete buttons so you can fix mistakes and remove entries.

Edit and Delete Expenses

Your tracker can add and display expenses, but there is no way to fix a typo or remove an entry you no longer need. That makes the app frustrating to use in practice.

In this step, you will complete the CRUD cycle by wiring up PUT and DELETE requests to your existing API endpoints. This gives you the full create-read-update-delete loop that every production app needs.

In this step, get ready to:
  • Add a delete button that removes expenses from the database and updates the UI.
  • Add an edit mode that lets you update any expense inline and persist the change.
Add delete functionality

Deleting an expense requires two things: a fetch call to the DELETE endpoint you already built, and a state update that removes the item from the array so React re-renders the list.

  • In client/src/App.jsx, find the addExpense function. Add the following deleteExpense function directly below it:
// Delete an expense from the database and remove it from state
async function deleteExpense(id) {
  await fetch(`/api/expenses/${id}`, { method: 'DELETE' })
  setExpenses(expenses.filter((e) => e._id !== id))
}

What does this code do?

This function takes an expense's ID, sends a DELETE request to your Express API, then filters the expenses array to remove the matching item. React sees the new array and re-renders the list without the deleted expense.

Now you need a button in the UI that calls this function. Each expense item needs a Delete button.

  • In the same file, scroll down to the expenses.map section inside the return statement.
  • Find the closing </span> for the category span. It currently looks like this:
<span className="category">{expense.category}</span>
</div>
  • Add a Delete button between the closing category </span> and the closing </div>, so it looks like this:
<span className="category">{expense.category}</span>
<button onClick={() => deleteExpense(expense._id)}>Delete</button>
</div>
  • Save App.jsx.
  • Switch to your browser at localhost:5173. You should see a Delete button next to each expense.
  • Click Delete on one of your expenses. It should disappear from the list and the running total should decrease by that expense's amount.

Delete button not working?

  • Make sure the deleteExpense function is inside the App component (between the opening function App() { and the return statement).
  • Check that you are passing expense._id (with an underscore) to the function. MongoDB uses _id as the default identifier.
  • Open the browser console (F12) and look for network errors. If you see a 404, your server may not be running. Help me debug my delete function.
Add edit mode

Editing is more complex than deleting. When the learner clicks Edit, the expense item should transform into input fields pre-filled with the current values. Clicking Save sends a PUT request and updates the list.

  • In App.jsx, find the deleteExpense function you just added. Add the following two functions directly below it:
// Enter edit mode for a specific expense
function startEdit(expense) {
  setEditingId(expense._id)
  setEditForm({
    description: expense.description,
    amount: expense.amount,
    category: expense.category,
  })
}

// Save edits to the database and update state
async function saveEdit(id) {
  const res = await fetch(`/api/expenses/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      description: editForm.description,
      amount: Number(editForm.amount),
      category: editForm.category,
    }),
  })
  const updated = await res.json()
  setExpenses(expenses.map((e) => (e._id === id ? updated : e)))
  setEditingId(null)
}

What do these functions do?

  • startEdit stores the clicked expense's ID in editingId and pre-fills editForm with the current values. This triggers the UI to show input fields for that item.
  • saveEdit sends the updated form data to your PUT endpoint, then replaces the old expense in state with the updated version from the server. Setting editingId back to null exits edit mode.

Now you need to update the JSX so each expense item conditionally renders either edit inputs or the normal display, depending on whether it is being edited.

  • In the return statement, find the existing expenses.map block. It currently looks like this:
{expenses.map((expense) => (
  <li key={expense._id} className="expense-item">
    <div className="display-row">
      <span className="description">{expense.description}</span>
      <span className="amount">${expense.amount.toFixed(2)}</span>
      <span className="category">{expense.category}</span>
      <button onClick={() => deleteExpense(expense._id)}>Delete</button>
    </div>
  </li>
))}
  • Replace that entire expenses.map(...) block with the following code that adds conditional rendering for edit mode:
{expenses.map((expense) => (
  <li key={expense._id} className="expense-item">
    {editingId === expense._id ? (
      <div className="edit-row">
        <input
          value={editForm.description}
          onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
        />
        <input
          type="number"
          value={editForm.amount}
          onChange={(e) => setEditForm({ ...editForm, amount: e.target.value })}
        />
        <input
          value={editForm.category}
          onChange={(e) => setEditForm({ ...editForm, category: e.target.value })}
        />
        <button onClick={() => saveEdit(expense._id)}>Save</button>
        <button onClick={() => setEditingId(null)}>Cancel</button>
      </div>
    ) : (
      <div className="display-row">
        <span className="description">{expense.description}</span>
        <span className="amount">${expense.amount.toFixed(2)}</span>
        <span className="category">{expense.category}</span>
        <button onClick={() => startEdit(expense)}>Edit</button>
        <button onClick={() => deleteExpense(expense._id)}>Delete</button>
      </div>
    )}
  </li>
))}

What is this conditional rendering doing?

The ternary editingId === expense._id ? ... : ... checks whether this specific expense is the one being edited. If it matches, React renders input fields bound to editForm state. Otherwise, it renders the normal display with Edit and Delete buttons.

The Cancel button simply sets editingId back to null, which causes React to re-render that item in display mode without saving anything.

  • Save App.jsx.
  • Switch to your browser. You should now see both an Edit button and a Delete button on each expense item.

Before you test the edit flow, what do you think will happen to the running total when you change an expense's amount?

  • Click Edit on an expense. The row should transform into three input fields pre-filled with the current description, amount, and category.
  • Change the amount to a different value and click Save.
  • Confirm the running total at the top updates immediately to reflect the new amount.
  • Refresh the page to confirm the edit persisted in the database.

Edit not saving or total not updating?

  • If clicking Save does nothing visible, open the browser console and check for network errors. A 404 means the server is not running or the route path is wrong.
  • If the total does not update, make sure you have amount: Number(editForm.amount) in your saveEdit function. Without Number(), the amount is stored as a string and the total calculation breaks.
  • If the inputs appear empty when you click Edit, check that startEdit copies the expense's values into editForm. Help me debug my edit functionality.

✔️ Awesome, I've got everything!

Great. Double check you have saved App.jsx.

ⓧ I'd like to double check the full code

Your App.jsx file is now fairly long. Scroll through it and confirm you have each of these sections in order:

  • Imports (useState, useEffect, ExpenseForm, App.css).
  • Three state variables: expenses, editingId, editForm.
  • useEffect calling fetchExpenses() on mount.
  • Functions in order: fetchExpenses, addExpense, deleteExpense, startEdit, saveEdit.
  • The total calculation using .reduce().
  • The return statement with the expenses.map block containing a ternary that switches between edit-row and display-row.

Your expense tracker now supports all four CRUD operations. You can create, read, update, and delete expenses with everything persisting in your MongoDB Atlas database. Next up, you will take on the Secret Mission and add category filtering to make the tracker even more useful.

Secret mission

Filter Expenses by Category

Your expense tracker works great, but scrolling through dozens of entries to find all your food spending is painful. In this mission, add category filter buttons that let you focus on one category at a time, with the total updating to show only that category's subtotal.

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 uses the MongoDB Atlas free tier (M0), so there are no ongoing costs.

Resources you used:

  • MongoDB Atlas M0 cluster (free tier, 512MB storage).
  • Local expense-tracker project folder on your Desktop.

Keep everything running

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

  • Your Atlas M0 cluster stays free indefinitely. It auto-pauses after 30 days of inactivity and resumes when you reconnect.
  • Your local project folder stays on your Desktop. You can start both servers again anytime with npm run dev from the expense-tracker folder.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact for later.

  • In the terminal where your servers are running, press Ctrl+C to stop both the Vite dev server and the Express server.
  • Your Atlas cluster auto-pauses after 30 days of inactivity. No manual action needed.
  • Your local files stay on your Desktop. When you are ready to resume, open a terminal in the expense-tracker folder and run npm run dev again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your expense data and local code.

  • Terminate your MongoDB Atlas cluster:
  • Open the MongoDB Atlas dashboard and navigate to your project's Clusters page.
  • Click the cluster's actions menu (the three dots icon) next to your cluster name.
  • Select Terminate.
  • Confirm by entering the cluster name and clicking Terminate.

Delete the local project folder by running one of these commands:

macOS

cd ~/Desktop
rm -rf expense-tracker

Windows

cd ~\Desktop
rmdir /s /q expense-tracker

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete full-stack expense tracker from scratch using the MERN stack.

You've learned how to:

  • Built a complete MERN stack application with a React frontend, Express API, and MongoDB database working together.
  • Created a REST API with all four CRUD operations (GET, POST, PUT, DELETE) that stores and retrieves data from a cloud database.
  • Connected React to Express using fetch and a Vite proxy, managing state with useState and useEffect to keep the UI in sync with the database.
  • Secret Mission: Implemented category filtering with derived state and dynamic UI rendering.

Ready to quiz yourself?