MERN Expense Tracker: MVP Features
Add auth, search, filters, and receipt OCR to your expense tracker.
Introduction
30 Second Summary
Everyone tracks expenses, but most apps feel basic. What separates a real product from a homework project? Features like user accounts, smart search, and the ability to snap a photo of a receipt instead of typing everything manually.
In this project, you will add four production-quality features to your existing MERN expense tracker: a date field, JWT-based authentication, search and filtering, and receipt OCR that auto-fills your expense form from a photo.
What You'll Build
A feature-complete expense tracker MVP with authentication, searchable history, and receipt scanning layered onto your existing MERN stack app.
By the end of this project, you'll have:
- JWT authentication that isolates each user's expenses behind protected routes with password hashing.
- Search and filter controls powered by MongoDB query operators for title search, category filtering, and date range selection.
- Receipt OCR that accepts a photo upload, extracts text with Tesseract.js, and auto-fills your expense form.
- Secret Mission: Build an expense statistics dashboard using MongoDB aggregation pipelines.
Are there any prerequisites?
Yes. This project assumes you already have a working MERN expense tracker with an Express backend, a MongoDB connection, a Mongoose Expense model, and functioning CRUD operations.
If your existing tracker can create, read, update, and delete expenses through your API, you're ready to start.
Add a Date Field to Expenses
Your expense tracker stores titles, amounts, and categories. But without a date on each expense, you can't sort expenses chronologically or build date range filters later.
In this step, you'll add a date field to your Mongoose schema and connect it to an HTML date input on your React form. This is the smallest schema change in the project, and doing it first means the date is in place before you add authentication or filters.
In this step, get ready to:
- Add a Date field to your Expense schema with a default of today.
- Add an HTML date input to your React expense form.
- Verify that dates are stored as ISODate objects in MongoDB.
Update the Expense schema with a date field
MongoDB stores dates as ISODate objects internally. When you define a field with type: Date in Mongoose, it automatically converts ISO strings from the frontend into proper Date objects for storage.
- Open models/Expense.js in your code editor.
- Add the following date field to your existing schema, alongside your other fields (like title, amount, category):
date: {
type: Date,
default: Date.now,
},
What does this code do?
type: Date tells Mongoose to cast any value assigned to this field into a JavaScript Date object. MongoDB stores it as an ISODate, which supports date range queries later.
default: Date.now is a function reference (no parentheses). Mongoose calls it at document creation time to set today's date when no date is provided.
Why Date.now without parentheses?
Writing Date.now passes the function itself to Mongoose. Mongoose executes it fresh each time a document is created. If you wrote Date.now() with parentheses, it would run once when the server starts, and every expense would get that same timestamp.
- Save models/Expense.js.
You won't see a visible change yet. The next chunk connects this field to the frontend form.
Do I need to change my controller?
If your createExpense controller uses Expense.create(req.body) or spreads req.body into the create call, the date will pass through automatically when the frontend sends it. No controller changes needed for this step.
Add a date input to the React expense form
HTML's native type="date" input gives users a calendar picker. You'll default it to today's date so the user doesn't have to fill it in manually for same-day expenses.
- Open your expense form component file in the frontend.
- Add a date state variable near your other form state (like title and amount). Add this line with your other useState calls:
// Default the date input to today's date (YYYY-MM-DD format)
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
What does this code do?
new Date().toISOString() produces a string like "2026-05-29T14:30:00.000Z". Splitting on 'T' and taking the first part gives you "2026-05-29", which is the exact format HTML date inputs require.
- Add the date input to your form's JSX, alongside your other input fields:
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
- Update your form's submit handler to include date in the request body you send to the backend. Add date alongside your existing fields (title, amount, category) in the POST/PUT request body:
// Include date in the body sent to the API
const expenseData = { title, amount, category, date };
What happens with this date string?
When you send an ISO date string like "2026-05-29" in the request body, Mongoose automatically converts it into a proper Date object before saving to MongoDB. You don't need any manual parsing on the backend.
- Save your expense form component file.
- Refresh your browser. You should see a new date picker input in your expense form, pre-filled with today's date.
Don't see the date input?
Make sure you saved both files. Check your browser's developer console for any React errors.
If the input appears but shows no default date, double check that your useState initializer uses new Date().toISOString().split('T')[0] exactly. Missing the [0] at the end passes a full datetime string, which the date input can't display.
Help me debug my date input not showing up.
- Submit a new expense through the form to test the full flow.
- Open Postman or MongoDB Compass and check the newly created expense document.
- Verify that the date field shows as an ISODate object (e.g. ISODate("2026-05-29T00:00:00.000Z")), not a plain string.
Why does the type matter?
If your date were stored as a plain String (like "2026-05-29"), MongoDB's date comparison operators ($gte, $lte) would not work correctly for date range filtering. Storing as a Date type means your filter queries will work properly in a later step.
Date stored as a string instead of ISODate?
Make sure your schema uses type: Date (capital D). Using type: String would store whatever string the frontend sends without converting it.
If you had existing expenses before adding the date field, they will have null for the date. Only newly created expenses will get the default. This is expected.
Help me figure out why my date is not saving as ISODate.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files and that a newly created expense shows an ISODate in MongoDB.
ⓧ I'd like to double check the full code
Here is the date field you should have in your Expense schema (models/Expense.js):
// Add this field inside your existing expenseSchema definition
date: {
type: Date,
default: Date.now,
},
Here are the key additions for your expense form component:
// State: default the date to today
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
// JSX: date input in your form
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
// Submit handler: include date in request body
const expenseData = { title, amount, category, date };
Your expense tracker now records when each expense happened. Next up, you'll add user authentication so each person's expenses are private and isolated.
Build JWT Authentication
Your expense tracker stores dates properly and the CRUD operations work. But right now, anyone can access anyone's expenses. There's no concept of "who is making this request."
In this step, you'll add JWT-based authentication to your backend. Every expense will belong to a specific user, and every API request will require a valid token to prove identity.
In this step, get ready to:
- Create a User model with secure password hashing.
- Build signup and login endpoints that issue JWTs.
- Protect all expense routes so each user only sees their own data.
Create the User model with password hashing
Before you can authenticate users, you need a way to store their credentials securely. The User model will hash passwords before saving them to MongoDB, so plaintext passwords never touch the database.
- Install the authentication packages by running this command in your backend project directory:
npm install bcryptjs jsonwebtoken
What are these packages?
bcryptjs is a pure JavaScript library for hashing passwords. It adds random "salt" to each password before hashing, making them resistant to brute-force attacks.
jsonwebtoken creates and verifies JSON Web Tokens. A JWT is a signed string your server issues on login that the client sends back with every request to prove identity.
- Create a new file called models/User.js and add the following code:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: 6,
},
}, { timestamps: true });
// Hash password before saving to database
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Compare a candidate password against the stored hash
userSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
What does this code do?
The schema defines three fields: name, email (forced to lowercase for consistent lookups), and password (minimum 6 characters).
The pre('save') hook intercepts every save operation. The isModified('password') check is critical. Without it, the password would get re-hashed every time you update any user field, corrupting the stored hash and breaking login.
The comparePassword instance method lets you check a login attempt against the stored hash without ever decrypting it.
- Save models/User.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: 6,
},
}, { timestamps: true });
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
userSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
Build the auth controller and routes
Now you need endpoints for users to sign up and log in. Each endpoint will return a JWT that the client stores and sends with future requests.
- First, add a JWT_SECRET to your .env file. This secret is used to sign and verify tokens:
JWT_SECRET=your_secret_key_here_replace_with_a_long_random_string
What makes a good JWT secret?
Use a long random string (32+ characters) that only your server knows. Anyone with this secret can forge valid tokens. In production, you would generate this with a cryptographic random generator.
- Create a new file called controllers/authController.js and add the signup handler:
const jwt = require('jsonwebtoken');
const User = require('../models/User');
// Generate a signed JWT with the user's ID
const generateToken = (userId) => {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, { expiresIn: '7d' });
};
exports.signup = async (req, res, next) => {
try {
const { name, email, password } = req.body;
// Check if email is already taken
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ success: false, message: 'Email already registered' });
}
// Create user (password gets hashed by the pre-save hook)
const user = await User.create({ name, email, password });
const token = generateToken(user._id);
res.status(201).json({
success: true,
data: { token, user: { id: user._id, name: user.name, email: user.email } },
});
} catch (error) {
next(error);
}
};
What does this code do?
The generateToken helper creates a JWT containing the user's ID, signed with your secret. It expires after 7 days.
The signup handler checks for duplicate emails, creates the user (the pre-save hook automatically hashes the password), generates a token, and returns both. Notice the response never includes the password.
- Add the login handler below the signup function in the same file:
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ success: false, message: 'Invalid email or password' });
}
// Verify password against stored hash
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({ success: false, message: 'Invalid email or password' });
}
const token = generateToken(user._id);
res.json({
success: true,
data: { token, user: { id: user._id, name: user.name, email: user.email } },
});
} catch (error) {
next(error);
}
};
Why use the same error message for both cases?
Both "user not found" and "wrong password" return the identical message: Invalid email or password. This is intentional. If you returned different messages, an attacker could probe which emails exist in your database.
- Save controllers/authController.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const generateToken = (userId) => {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, { expiresIn: '7d' });
};
exports.signup = async (req, res, next) => {
try {
const { name, email, password } = req.body;
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ success: false, message: 'Email already registered' });
}
const user = await User.create({ name, email, password });
const token = generateToken(user._id);
res.status(201).json({
success: true,
data: { token, user: { id: user._id, name: user.name, email: user.email } },
});
} catch (error) {
next(error);
}
};
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ success: false, message: 'Invalid email or password' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({ success: false, message: 'Invalid email or password' });
}
const token = generateToken(user._id);
res.json({
success: true,
data: { token, user: { id: user._id, name: user.name, email: user.email } },
});
} catch (error) {
next(error);
}
};
Now you need routes that map HTTP requests to these controller functions.
- Create a new file called routes/authRoutes.js and add the following:
const express = require('express');
const router = express.Router();
const { signup, login } = require('../controllers/authController');
// POST /api/auth/signup - register a new user
router.post('/signup', signup);
// POST /api/auth/login - authenticate and get token
router.post('/login', login);
module.exports = router;
- Save routes/authRoutes.js.
- Open your main app file (app.js or server.js) and add these two lines to mount the auth routes:
const authRoutes = require('./routes/authRoutes');
// Mount auth routes (add near your other route mounts)
app.use('/api/auth', authRoutes);
What does this code do?
This mounts your auth routes at the /api/auth path prefix. Combined with the routes you defined, this creates POST /api/auth/signup and POST /api/auth/login.
- Save your main app file.
- Start your server (or let nodemon restart it) and test the signup endpoint in Postman.
- Send a POST request to http://localhost:5000/api/auth/signup (adjust the port if yours differs) with this JSON body:
{
"name": "Test User",
"email": "test@example.com",
"password": "password123"
}
You should see a response containing a token string and the user object with id, name, and email.
Getting a connection or 500 error?
Make sure your .env file has JWT_SECRET defined and that your server loaded it (restart if you added the variable after starting). Also confirm your MongoDB connection string is still valid.
help me debug my signup endpoint returning a 500 error
Protect expense routes with auth middleware
Your auth endpoints work, but the expense routes are still wide open. You need middleware that sits in front of every expense request, checks for a valid token, and rejects anything unauthorized.
- Create a new file called middleware/auth.js and add the following:
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
// Look for the Authorization header
const authHeader = req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'No token, authorization denied' });
}
// Extract the token after "Bearer "
const token = authHeader.split(' ')[1];
try {
// Verify token and attach user payload to the request
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ success: false, message: 'Token is not valid' });
}
};
module.exports = auth;
What does this code do?
This middleware runs before any protected route handler. It reads the Authorization header, expects the format Bearer <token>, and verifies the token's signature using your secret.
If valid, it attaches the decoded payload (which contains id) to req.user so downstream handlers know which user is making the request. If invalid or missing, it returns a 401 immediately.
- Save middleware/auth.js.
Now apply this middleware to your expense routes and update the Expense schema to track which user owns each expense.
- Open models/Expense.js and add a userId field to your schema:
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
}
What does this field do?
This creates a relationship between each expense and its owner. The ObjectId type references a document in the User collection. Making it required ensures no expense can exist without an owner.
- Make sure mongoose is required at the top of models/Expense.js if it is not already (you need mongoose.Schema.Types.ObjectId).
- Save models/Expense.js.
Next, update your expense routes file to require authentication on every route.
- Replace the contents of routes/expenseRoutes.js with the following:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const {
createExpense,
getExpenses,
updateExpense,
deleteExpense,
} = require('../controllers/expenseController');
// Apply auth middleware to ALL routes below
router.use(auth);
router.post('/', createExpense);
router.get('/', getExpenses);
router.put('/:id', updateExpense);
router.delete('/:id', deleteExpense);
module.exports = router;
What does router.use(auth) do?
Placing router.use(auth) before all route definitions means every request hitting this router must pass through the auth middleware first. This is cleaner than adding auth as a second argument to each individual route.
- Save routes/expenseRoutes.js.
Finally, update your expense controller so every operation is scoped to the authenticated user.
- Replace the contents of controllers/expenseController.js with the following:
const Expense = require('../models/Expense');
exports.createExpense = async (req, res, next) => {
try {
// Attach the authenticated user's ID to the new expense
const expense = await Expense.create({ ...req.body, userId: req.user.id });
res.status(201).json({ success: true, data: expense });
} catch (error) {
next(error);
}
};
exports.getExpenses = async (req, res, next) => {
try {
// Only return expenses belonging to this user
const expenses = await Expense.find({ userId: req.user.id }).sort({ date: -1 });
res.json({ success: true, data: expenses });
} catch (error) {
next(error);
}
};
exports.updateExpense = async (req, res, next) => {
try {
// Match both the expense ID and the user's ID
const expense = await Expense.findOneAndUpdate(
{ _id: req.params.id, userId: req.user.id },
req.body,
{ new: true, runValidators: true }
);
if (!expense) {
return res.status(404).json({ success: false, message: 'Expense not found' });
}
res.json({ success: true, data: expense });
} catch (error) {
next(error);
}
};
exports.deleteExpense = async (req, res, next) => {
try {
// Only delete if the expense belongs to this user
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.user.id });
if (!expense) {
return res.status(404).json({ success: false, message: 'Expense not found' });
}
res.json({ success: true, message: 'Expense deleted' });
} catch (error) {
next(error);
}
};
What changed in each function?
- createExpense now spreads req.body and adds userId: req.user.id so every expense is tagged with its creator.
- getExpenses filters by userId so users only see their own data, sorted newest first.
- updateExpense uses findOneAndUpdate with both _id and userId in the filter. A user cannot update someone else's expense.
- deleteExpense uses findOneAndDelete with the same dual filter for the same protection.
- Save controllers/expenseController.js.
- Test in Postman: send a GET request to your expenses endpoint without an Authorization header. You should receive a 401 response with the message No token, authorization denied.
- Now add a header Authorization with the value Bearer <your_token_from_signup> and send the request again. You should see your expenses (or an empty array if this is a new user).
Still getting 401 even with the token?
Check that your Authorization header value starts with Bearer (with a space after it), followed immediately by the token string. Also make sure the JWT_SECRET in your .env is the same value that was used when the token was generated. If you changed the secret after signing up, old tokens become invalid.
help me debug why my JWT token keeps returning 401 unauthorized
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before moving on.
ⓧ I'd like to double check the full code
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
const authHeader = req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'No token, authorization denied' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ success: false, message: 'Token is not valid' });
}
};
module.exports = auth;
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const {
createExpense,
getExpenses,
updateExpense,
deleteExpense,
} = require('../controllers/expenseController');
router.use(auth);
router.post('/', createExpense);
router.get('/', getExpenses);
router.put('/:id', updateExpense);
router.delete('/:id', deleteExpense);
module.exports = router;
const Expense = require('../models/Expense');
exports.createExpense = async (req, res, next) => {
try {
const expense = await Expense.create({ ...req.body, userId: req.user.id });
res.status(201).json({ success: true, data: expense });
} catch (error) {
next(error);
}
};
exports.getExpenses = async (req, res, next) => {
try {
const expenses = await Expense.find({ userId: req.user.id }).sort({ date: -1 });
res.json({ success: true, data: expenses });
} catch (error) {
next(error);
}
};
exports.updateExpense = async (req, res, next) => {
try {
const expense = await Expense.findOneAndUpdate(
{ _id: req.params.id, userId: req.user.id },
req.body,
{ new: true, runValidators: true }
);
if (!expense) {
return res.status(404).json({ success: false, message: 'Expense not found' });
}
res.json({ success: true, data: expense });
} catch (error) {
next(error);
}
};
exports.deleteExpense = async (req, res, next) => {
try {
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.user.id });
if (!expense) {
return res.status(404).json({ success: false, message: 'Expense not found' });
}
res.json({ success: true, message: 'Expense deleted' });
} catch (error) {
next(error);
}
};
Your backend now authenticates users and isolates their data. Next up, you'll connect this to your React frontend so users can sign up, log in, and access their expenses through the UI.
Integrate Auth in the Frontend
Your backend now knows how to create users, verify passwords, and issue tokens. It also rejects any expense request that doesn't carry a valid JWT. The problem is that your React frontend has no idea any of this exists.
In this step, you'll wire up the frontend so users can sign up, log in, and have the token attached to every API call automatically. By the end, two separate users will each see only their own expenses.
In this step, get ready to:
- Build Login and Signup components with auth state management.
- Create an API helper that attaches the auth token to every request.
- Test user isolation end-to-end with two separate accounts.
Create the auth context and Login/Signup components
React's useContext hook lets you share state across components without passing props down manually. You'll use it to track whether the user is logged in and store their info globally.
- Create a new file called src/context/AuthContext.js in your frontend project, then add the following code:
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
// Check localStorage for an existing token on load
const [token, setToken] = useState(localStorage.getItem('token'));
const [user, setUser] = useState(
JSON.parse(localStorage.getItem('user'))
);
// Store token and user info after successful login or signup
const login = (newToken, userInfo) => {
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(userInfo));
setToken(newToken);
setUser(userInfo);
};
// Remove token and user info on logout
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ token, user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
What does this code do?
The AuthProvider wraps your entire app and provides auth state to any child component. On first load, it checks localStorage for an existing token so the user stays logged in across page refreshes.
The login function saves the token to both React state and localStorage. The logout function clears both. The custom useAuth hook gives any component easy access to the auth values.
- Save src/context/AuthContext.js.
Now build the Login component. This form collects email and password, then calls your backend's login endpoint.
- Create a new file called src/components/Login.js and add this code:
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
function Login({ onSwitch }) {
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
// POST credentials to the login endpoint
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || 'Login failed');
return;
}
// Store token and user info via auth context
login(data.data.token, data.data.user);
} catch (err) {
setError('Something went wrong. Please try again.');
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Login</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Login</button>
<p>
Don't have an account?{' '}
<button type="button" onClick={onSwitch}>Sign Up</button>
</p>
</form>
);
}
export default Login;
What does this code do?
The form POSTs the email and password to /api/auth/login. On success, it calls the login function from your auth context, which stores the token in localStorage and updates React state.
The onSwitch prop lets the parent component toggle between Login and Signup views.
- Save src/components/Login.js.
The Signup component is almost identical but includes a name field and hits a different endpoint.
- Create src/components/Signup.js and add this code:
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
function Signup({ onSwitch }) {
const { login } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
// POST name, email, and password to the signup endpoint
const res = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || 'Signup failed');
return;
}
// Automatically log the user in after successful signup
login(data.data.token, data.data.user);
} catch (err) {
setError('Something went wrong. Please try again.');
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Sign Up</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password (min 6 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<button type="submit">Sign Up</button>
<p>
Already have an account?{' '}
<button type="button" onClick={onSwitch}>Login</button>
</p>
</form>
);
}
export default Signup;
What does this code do?
This POSTs name, email, and password to /api/auth/signup. On success, it calls the same login context function. The user is automatically logged in right after signing up.
- Save src/components/Signup.js.
Keep the password minlength in sync
Your User model requires a minimum of 6 characters for the password. The minLength={6} attribute on the form input enforces the same rule on the frontend so users get immediate feedback before the request hits your server.
Create an API helper and wire up conditional rendering
Every API call to your expense endpoints now requires the Authorization: Bearer <token> header. Instead of adding it manually to every fetch call, you'll create a single helper function that handles this.
- Create a new file called src/utils/api.js and add this code:
// Centralized fetch wrapper that attaches the auth token
export async function apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
// Merge auth header into any existing headers
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(url, { ...options, headers });
// If the server says our token is invalid, clear it and reload
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return;
}
return res;
}
What does this code do?
The apiFetch function wraps the native fetch. It reads the token from localStorage and attaches it as a Bearer token header on every request.
If any response returns a 401 status, it means your token is expired or invalid. The helper clears the stored token and reloads the page, which triggers the login screen.
- Save src/utils/api.js.
Why a wrapper instead of axios interceptors?
Axios interceptors do the same thing. A plain wrapper around fetch keeps your dependency list shorter and teaches the underlying pattern. If you prefer axios, you can swap this for an interceptor later.
Now update your App component to conditionally show Login/Signup when there's no token, or the expense tracker when the user is authenticated.
- Open your main src/App.js file and replace its content with this updated version:
import { useState } from 'react';
import { AuthProvider, useAuth } from './context/AuthContext';
import Login from './components/Login';
import Signup from './components/Signup';
// Import your existing expense tracker component(s) here
// import ExpenseTracker from './components/ExpenseTracker';
function AppContent() {
const { token, user, logout } = useAuth();
const [showLogin, setShowLogin] = useState(true);
// If no token exists, show auth forms
if (!token) {
return showLogin ? (
<Login onSwitch={() => setShowLogin(false)} />
) : (
<Signup onSwitch={() => setShowLogin(true)} />
);
}
// User is authenticated - show the expense tracker
return (
<div>
<header>
<span>Welcome, {user?.name}</span>
<button onClick={logout}>Logout</button>
</header>
{/* Replace this with your existing expense tracker component */}
{/* <ExpenseTracker /> */}
<p>Your expense tracker goes here.</p>
</div>
);
}
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}
export default App;
What does this code do?
The AuthProvider wraps everything so auth state is accessible to all children. AppContent reads the token from context. If no token exists, it shows the Login or Signup form. If a token exists, it shows the expense tracker with a logout button.
You will need to replace the placeholder comment with your actual expense tracker component. The exact import depends on how you structured your frontend in previous steps.
- Replace the placeholder comment with your existing expense tracker component import and usage.
- Save src/App.js.
- Open your browser. You should see the Login form instead of your expense tracker.
Still seeing the old expense tracker?
If your browser still shows the expense list without any login screen, check that you saved App.js and that your dev server reloaded. Also confirm there is no leftover token key in localStorage from testing. Open DevTools, go to the Application tab, and clear localStorage.
help me debug why my login page isn't showing
Finally, update your existing expense-related components to use apiFetch instead of the plain fetch calls you had before.
- In every component that calls your expense API (creating, reading, updating, or deleting expenses), replace fetch('/api/expenses'...) with apiFetch('/api/expenses'...).
- Add the import at the top of each file:
import { apiFetch } from '../utils/api';
Why replace every fetch call?
Your backend's auth middleware rejects any request without a valid Bearer token. By routing all API calls through apiFetch, you guarantee the token is always included. You also get automatic logout if the token expires.
- Save all modified component files.
Getting 401 errors on every expense request?
Make sure you are importing and calling apiFetch (not plain fetch) in every component that talks to your expense API. Also confirm that your backend is running and the JWT_SECRET in your .env matches between when you issued the token and when you verify it.
help me figure out why my API requests return 401
Test the full auth flow end-to-end
The real proof that authentication works is user isolation. Two different users should each see only their own expenses and never each other's data.
- In your browser, click **Sign Up** and create a test account (User A) with any name, email, and password.
- After signup, confirm you are automatically logged in and can see the expense tracker.
- Create 1-2 test expenses as User A.
- Click the **Logout** button.
- Sign up a second account (User B) with a different email.
- Verify that User B sees zero expenses. User A's data should not appear.
- Log out, then log back in as User A. Verify User A still sees only their own expenses.
Why does this prove isolation?
Each JWT contains the user's ID. Your auth middleware decodes it and attaches it to req.user. Every expense query then filters by userId: req.user.id. A user can never accidentally see or modify another user's data because the filter is applied at the database query level.
Now test that expired or cleared tokens redirect to login.
- While logged in, open your browser's DevTools.
- Go to the **Application** tab, then select **Local Storage** in the sidebar.
- Delete the token entry manually.
- Refresh the page. You should be sent back to the Login form.
Page doesn't redirect after clearing the token?
Make sure you deleted the correct key. The key should be exactly token in localStorage. Also confirm your AuthProvider initializes state from localStorage.getItem('token'). If it reads null (because the key was removed), the conditional rendering should show the Login form.
help me debug my token expiry redirect
✔️ Awesome, I've got everything!
Great. Make sure all files are saved and both users confirmed working before moving on.
ⓧ I'd like to double check the full code
Here are the complete frontend auth files for this step.
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
// Check localStorage for an existing token on load
const [token, setToken] = useState(localStorage.getItem('token'));
const [user, setUser] = useState(
JSON.parse(localStorage.getItem('user'))
);
// Store token and user info after successful login or signup
const login = (newToken, userInfo) => {
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(userInfo));
setToken(newToken);
setUser(userInfo);
};
// Remove token and user info on logout
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ token, user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
// Centralized fetch wrapper that attaches the auth token
export async function apiFetch(url, options = {}) {
const token = localStorage.getItem('token');
// Merge auth header into any existing headers
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const res = await fetch(url, { ...options, headers });
// If the server says our token is invalid, clear it and reload
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return;
}
return res;
}
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
function Login({ onSwitch }) {
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
// POST credentials to the login endpoint
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || 'Login failed');
return;
}
// Store token and user info via auth context
login(data.data.token, data.data.user);
} catch (err) {
setError('Something went wrong. Please try again.');
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Login</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Login</button>
<p>
Don't have an account?{' '}
<button type="button" onClick={onSwitch}>Sign Up</button>
</p>
</form>
);
}
export default Login;
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
function Signup({ onSwitch }) {
const { login } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
// POST name, email, and password to the signup endpoint
const res = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || 'Signup failed');
return;
}
// Automatically log the user in after successful signup
login(data.data.token, data.data.user);
} catch (err) {
setError('Something went wrong. Please try again.');
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Sign Up</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password (min 6 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<button type="submit">Sign Up</button>
<p>
Already have an account?{' '}
<button type="button" onClick={onSwitch}>Login</button>
</p>
</form>
);
}
export default Signup;
import { useState } from 'react';
import { AuthProvider, useAuth } from './context/AuthContext';
import Login from './components/Login';
import Signup from './components/Signup';
// Import your existing expense tracker component(s) here
// import ExpenseTracker from './components/ExpenseTracker';
function AppContent() {
const { token, user, logout } = useAuth();
const [showLogin, setShowLogin] = useState(true);
// If no token exists, show auth forms
if (!token) {
return showLogin ? (
<Login onSwitch={() => setShowLogin(false)} />
) : (
<Signup onSwitch={() => setShowLogin(true)} />
);
}
// User is authenticated - show the expense tracker
return (
<div>
<header>
<span>Welcome, {user?.name}</span>
<button onClick={logout}>Logout</button>
</header>
{/* Replace this with your existing expense tracker component */}
{/* <ExpenseTracker /> */}
<p>Your expense tracker goes here.</p>
</div>
);
}
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}
export default App;
Your users can now sign up, log in, and each see only their own expenses. Next up, you'll add search and filter capabilities so users can actually find specific expenses in their growing list.
Add Search and Filtering
Your expense tracker now authenticates users and scopes every query to the logged-in person.
As your expense list grows, scrolling through every entry to find a specific purchase becomes painful. In this step, you'll build a search and filter system that lets users narrow down expenses by title, category, or date range.
In this step, get ready to:
- Update the backend GET endpoint to accept search and filter query parameters.
- Add filter UI controls (search input, category dropdown, date range) to your frontend.
- Implement debouncing on the search input and add a clear filters button.
Update the backend to accept filter query parameters
Right now, your getExpenses controller fetches every expense for the logged-in user. You need it to read optional query parameters from the URL and build a MongoDB query dynamically.
- In controllers/expenseController.js, find your existing getExpenses function.
- Replace the entire getExpenses function with the following code:
exports.getExpenses = async (req, res, next) => {
try {
// Read optional filter params from the query string
const { search, category, startDate, endDate } = req.query;
// Always scope to the authenticated user
const query = { userId: req.user.id };
// Add title search with case-insensitive regex
if (search) {
query.title = { $regex: search, $options: 'i' };
}
// Add exact category match (skip if 'all')
if (category && category !== 'all') {
query.category = category;
}
// Add date range bounds
if (startDate || endDate) {
query.date = {};
if (startDate) query.date.$gte = new Date(startDate);
if (endDate) query.date.$lte = new Date(endDate);
}
const expenses = await Expense.find(query).sort({ date: -1 });
res.json({ success: true, data: expenses });
} catch (error) {
next(error);
}
};
What does this code do?
The function starts with a base query that always includes userId so one user can never see another's expenses.
- $regex with $options: 'i' performs a case-insensitive substring match on the expense title.
- $gte (greater than or equal) and $lte (less than or equal) create a date range window.
- Each filter is only added to the query object when the parameter is present and non-empty, so missing params return all results.
Why filter on the backend instead of the frontend?
MongoDB handles regex and date comparisons efficiently on the server. As the expense dataset grows, sending all expenses to the browser and filtering with JavaScript becomes slow and wasteful.
Backend filtering is the real-world pattern you will see in production APIs. It also reduces network payload because only matching records travel over the wire.
- Save controllers/expenseController.js.
- Test the updated endpoint in Postman by sending a GET request to /api/expenses?search=coffee with your Bearer token. You should see only expenses whose title contains "coffee" (case-insensitive).
Getting all expenses back regardless of the search param?
- Make sure you are reading from req.query, not req.body. Query parameters come from the URL, not the request body.
- Check that your filter field name matches title in your Expense schema. If your schema uses a different field for the expense name, update the regex line to match.
Why is my search filter not narrowing results?
Add filter controls to the frontend
With the backend ready, you need UI controls that let the user type a search term, pick a category, and select a date range. Each control stores its value in component state, and any change triggers a fresh API call with the matching query params.
- In your expense list component (or create a new FilterBar component), add the following state variables and filter UI:
import { useState, useEffect, useCallback } from 'react';
function ExpenseList() {
// Expense data state
const [expenses, setExpenses] = useState([]);
// Filter state
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// Fetch expenses with current filters
const fetchExpenses = useCallback(async () => {
const params = new URLSearchParams();
if (search) params.append('search', search);
if (category && category !== 'all') params.append('category', category);
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
const queryString = params.toString();
const url = `/api/expenses${queryString ? '?' + queryString : ''}`;
const response = await fetch(url, {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') },
});
const data = await response.json();
if (data.success) setExpenses(data.data);
}, [search, category, startDate, endDate]);
// Re-fetch whenever filters change
useEffect(() => {
fetchExpenses();
}, [fetchExpenses]);
return (
<div>
{/* Search input */}
<input
type="text"
placeholder="Search expenses..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* Category dropdown */}
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All Categories</option>
<option value="food">Food</option>
<option value="transport">Transport</option>
<option value="entertainment">Entertainment</option>
<option value="utilities">Utilities</option>
<option value="other">Other</option>
</select>
{/* Date range inputs */}
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
{/* Expense list or no-results message */}
{expenses.length === 0 ? (
<p>No expenses match your filters.</p>
) : (
expenses.map((expense) => (
<div key={expense._id}>
<span>{expense.title}</span>
<span>${expense.amount}</span>
</div>
))
)}
</div>
);
}
What does this code do?
- Four state variables (search, category, startDate, endDate) track the current filter selections.
- fetchExpenses builds a query string from non-empty filter values and calls the backend with the auth header.
- useEffect re-runs fetchExpenses whenever any filter state changes, keeping the displayed list in sync.
- When the result array is empty, a "no results" message appears instead of a blank list.
- Save your file and refresh the browser.
- You should see the search input, category dropdown, and two date pickers above your expense list. Try selecting a category to confirm only matching expenses appear.
Seeing a blank page or console error?
- Check the browser console for a 401 response. If the token is missing or expired, your API helper should redirect to the login screen.
- Confirm the <option> values in your category dropdown match the exact enum values in your Expense schema.
Help me debug my filter component
Add debouncing and a clear filters button
Right now, every keystroke in the search box fires an API call. Typing "coffee" sends six requests in rapid succession. Debouncing waits until the user stops typing for 300ms before triggering the fetch.
- Add a debounced search value and a clear filters handler to your component by updating it with the following code:
import { useState, useEffect, useCallback } from 'react';
function ExpenseList() {
const [expenses, setExpenses] = useState([]);
// Filter state
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [category, setCategory] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// Debounce the search input by 300ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// Fetch expenses using the debounced search value
const fetchExpenses = useCallback(async () => {
const params = new URLSearchParams();
if (debouncedSearch) params.append('search', debouncedSearch);
if (category && category !== 'all') params.append('category', category);
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
const queryString = params.toString();
const url = `/api/expenses${queryString ? '?' + queryString : ''}`;
const response = await fetch(url, {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') },
});
const data = await response.json();
if (data.success) setExpenses(data.data);
}, [debouncedSearch, category, startDate, endDate]);
useEffect(() => {
fetchExpenses();
}, [fetchExpenses]);
// Reset all filters to their default values
const clearFilters = () => {
setSearch('');
setDebouncedSearch('');
setCategory('all');
setStartDate('');
setEndDate('');
};
return (
<div>
{/* Search input */}
<input
type="text"
placeholder="Search expenses..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* Category dropdown */}
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All Categories</option>
<option value="food">Food</option>
<option value="transport">Transport</option>
<option value="entertainment">Entertainment</option>
<option value="utilities">Utilities</option>
<option value="other">Other</option>
</select>
{/* Date range inputs */}
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
{/* Clear filters button */}
<button onClick={clearFilters}>Clear filters</button>
{/* Expense list or no-results message */}
{expenses.length === 0 ? (
<p>No expenses match your filters.</p>
) : (
expenses.map((expense) => (
<div key={expense._id}>
<span>{expense.title}</span>
<span>${expense.amount}</span>
</div>
))
)}
</div>
);
}
export default ExpenseList;
How does the debounce work?
Every time search changes, a 300ms timer starts. If the user types another character before the timer finishes, the old timer is cleared and a new one begins.
Only when the user pauses for 300ms does debouncedSearch update, which triggers fetchExpenses. Category and date changes still trigger immediately because they are single-action selections, not rapid keystrokes.
- Save your file and refresh the browser.
- Type a search term slowly and watch the network tab in DevTools. You should see a single request fire 300ms after you stop typing, instead of one per keystroke.
- Click the Clear filters button. All inputs should reset and the full expense list should reappear.
Search still fires on every keystroke?
- Make sure fetchExpenses depends on debouncedSearch in its useCallback dependency array, not search. Using search directly bypasses the debounce.
- Check that your debounce useEffect returns the cleanup function (return () => clearTimeout(timer)). Without the cleanup, old timers still fire.
Help me fix my debounce implementation
✔️ Awesome, I've got everything!
Great. Double check you have saved both controllers/expenseController.js and your frontend expense list component.
ⓧ I'd like to double check the full code
Here is the complete controllers/expenseController.js at this point:
const Expense = require('../models/Expense');
exports.createExpense = async (req, res, next) => {
try {
const expense = await Expense.create({ ...req.body, userId: req.user.id });
res.status(201).json({ success: true, data: expense });
} catch (error) {
next(error);
}
};
exports.getExpenses = async (req, res, next) => {
try {
const { search, category, startDate, endDate } = req.query;
const query = { userId: req.user.id };
if (search) {
query.title = { $regex: search, $options: 'i' };
}
if (category && category !== 'all') {
query.category = category;
}
if (startDate || endDate) {
query.date = {};
if (startDate) query.date.$gte = new Date(startDate);
if (endDate) query.date.$lte = new Date(endDate);
}
const expenses = await Expense.find(query).sort({ date: -1 });
res.json({ success: true, data: expenses });
} catch (error) {
next(error);
}
};
exports.updateExpense = async (req, res, next) => {
try {
const expense = await Expense.findOneAndUpdate(
{ _id: req.params.id, userId: req.user.id },
req.body,
{ new: true, runValidators: true }
);
if (!expense) {
return res.status(404).json({ success: false, message: 'Expense not found' });
}
res.json({ success: true, data: expense });
} catch (error) {
next(error);
}
};
exports.deleteExpense = async (req, res, next) => {
try {
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.user.id });
if (!expense) {
return res.status(404).json({ success: false, message: 'Expense not found' });
}
res.json({ success: true, message: 'Expense deleted' });
} catch (error) {
next(error);
}
};
Your expense tracker now lets users search by title, filter by category, and narrow by date range. Next up, you'll add the most impressive feature: receipt scanning with OCR that auto-fills the expense form from a photo.
Build Receipt OCR Upload
Your expense tracker now has user authentication, date tracking, and a full search and filter system. The last feature brings it all together: scanning receipt photos to auto-fill expense entries.
Instead of manually typing every coffee or grocery purchase, you will upload a photo and let your backend extract the amount, date, and merchant name automatically. This teaches the complete upload-process-respond pattern used in production apps.
In this step, get ready to:
- Build a file upload middleware with multer that accepts only images.
- Create an OCR endpoint that extracts text from receipt photos using Tesseract.js.
- Add an upload button to your frontend that auto-fills the expense form from scanned receipts.
Install multer and tesseract.js
Your OCR feature needs two packages. Multer handles multipart file uploads in Express. Tesseract.js is a pure JavaScript OCR engine that extracts text from images.
- Install both packages by running this command in your backend project folder:
npm install multer tesseract.js
- Confirm both packages were added by checking your package.json dependencies.
- You should see multer and tesseract.js listed.
Install failed?
If you see permission errors, try running the command with sudo on macOS/Linux or run your terminal as Administrator on Windows.
If you see network errors, check your internet connection and try again. Help me troubleshoot my npm install error.
Create the upload middleware
Multer needs a configuration file that defines where to store uploads, what file types to accept, and how large files can be. You will use memory storage so the file stays in RAM as a Buffer, ready to pass directly to Tesseract for processing.
- Create a new file called middleware/upload.js and add the following code:
const multer = require('multer');
// Store uploaded files in memory as Buffer objects
const storage = multer.memoryStorage();
// Only accept image files (jpeg, png, etc.)
const fileFilter = (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
};
// Configure multer with storage, filter, and 5MB size limit
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 },
});
module.exports = upload;
What does this code do?
- multer.memoryStorage() keeps the uploaded file in RAM as a Buffer instead of writing it to disk. This is ideal because you need to pass the buffer directly to Tesseract for OCR processing.
- fileFilter checks the file's MIME type and only allows images through. Non-image uploads get rejected with an error.
- limits caps upload size at 5MB to prevent memory issues. Receipt photos rarely exceed this.
Why memory storage instead of disk?
Disk storage writes the file to a folder and gives you a file path. Memory storage gives you the raw file bytes directly. Since Tesseract can accept a Buffer as input, memory storage skips the write-to-disk step entirely. This makes the OCR pipeline faster and avoids leaving temporary files behind.
- Save middleware/upload.js.
You won't see a change yet. The next chunk wires this middleware into your routes.
- Now update routes/expenseRoutes.js to import the upload middleware and add the scan-receipt route. Replace the entire file with this code:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const upload = require('../middleware/upload');
const {
createExpense,
getExpenses,
updateExpense,
deleteExpense,
scanReceipt,
} = require('../controllers/expenseController');
// Protect all expense routes with auth
router.use(auth);
// Receipt scanning route (must come before /:id to avoid matching 'scan-receipt' as an id)
router.post('/scan-receipt', upload.single('receipt'), scanReceipt);
router.post('/', createExpense);
router.get('/', getExpenses);
router.put('/:id', updateExpense);
router.delete('/:id', deleteExpense);
module.exports = router;
What does this code do?
- upload.single('receipt') tells multer to expect one file in a form field named receipt. After processing, the file is available at req.file.
- The /scan-receipt route is placed BEFORE /:id. Express matches routes in order. If /:id came first, Express would treat the string "scan-receipt" as an ID parameter.
- scanReceipt is the controller function you will build in the next substep.
- Save routes/expenseRoutes.js.
Your server will throw an error if you restart it now because scanReceipt does not exist yet. You will add it in the next substep.
Seeing a 'scanReceipt is not defined' error?
This is expected if your server auto-restarts after saving. The controller function does not exist yet. Continue to the next substep to add it. Help me understand why my server crashed after updating routes.
Build the OCR controller
The scan-receipt endpoint receives an image buffer from multer, passes it to Tesseract.js for text recognition, then parses the raw OCR text with regex to find the amount, date, and merchant name. It returns these extracted values so the frontend can auto-fill the expense form.
- In controllers/expenseController.js, find this line at the top of the file:
const Expense = require('../models/Expense');
- Add the Tesseract.js import directly below it, so the top of your file looks like this:
const Expense = require('../models/Expense');
const { createWorker } = require('tesseract.js');
- Now scroll to the bottom of controllers/expenseController.js (after the deleteExpense function) and add the scanReceipt controller:
exports.scanReceipt = async (req, res, next) => {
try {
// Check that multer successfully attached a file
if (!req.file) {
return res.status(400).json({ success: false, message: 'No image uploaded' });
}
// Create a Tesseract worker for English text recognition
const worker = await createWorker('eng');
// Run OCR on the image buffer from memory storage
const { data: { text } } = await worker.recognize(req.file.buffer);
// Always terminate the worker to free memory
await worker.terminate();
// Parse the raw OCR text for structured data
const amount = extractAmount(text);
const date = extractDate(text);
const merchant = extractMerchant(text);
res.json({
success: true,
data: { amount, date, merchant, rawText: text },
});
} catch (error) { next(error); }
};
What does this code do?
- createWorker('eng') spins up a Tesseract OCR instance configured for English. On the first run, it downloads language data (about 2MB) which is cached for future requests.
- worker.recognize(req.file.buffer) passes the in-memory image directly to the OCR engine. It returns an object containing the recognized text.
- worker.terminate() frees the WebAssembly memory. Without this, each request would leak memory.
- The endpoint returns extracted fields plus rawText so you can debug OCR quality. It does NOT auto-create an expense. The user reviews and confirms first.
- Below the scanReceipt function (still in controllers/expenseController.js), add the three regex helper functions that parse the raw OCR text:
// Extract dollar amounts from receipt text
function extractAmount(text) {
const patterns = [
/total[:\s]*\$?([\d]+\.[\d]{2})/i,
/amount[:\s]*\$?([\d]+\.[\d]{2})/i,
/\$\s*([\d]+\.[\d]{2})/,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) return parseFloat(match[1]);
}
return null;
}
// Extract dates in MM/DD/YYYY or similar formats
function extractDate(text) {
const pattern = /(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/;
const match = text.match(pattern);
if (match) return match[0];
return new Date().toISOString().split('T')[0];
}
// Extract merchant name (typically the first line of a receipt)
function extractMerchant(text) {
const lines = text.split('\n').filter(line => line.trim().length > 0);
return lines.length > 0 ? lines[0].trim() : '';
}
How do the regex helpers work?
- extractAmount tries three patterns in priority order: "Total: $12.50", "Amount: $12.50", or any standalone dollar amount. It returns the first match as a float, or null if nothing is found.
- extractDate looks for date patterns like 05/29/2026 or 5-29-26. If no date is found, it falls back to today's date.
- extractMerchant takes the first non-empty line of the OCR output. Receipts typically print the store name at the very top.
These regex patterns are MVP-level. Thermal receipt paper produces notoriously inconsistent OCR results. The auto-fill is a convenience, not guaranteed accuracy.
- Save controllers/expenseController.js.
- Restart your backend server. It should start without errors now that scanReceipt exists.
How to test with Postman
Open Postman and create a POST request to http://localhost:5000/api/expenses/scan-receipt (adjust the port to match your server). Set the body to form-data, add a key called receipt with type File, and upload any receipt photo. Include your JWT token in the Authorization header.
You should get back a JSON response with amount, date, merchant, and rawText fields. The first request may take a few seconds while Tesseract downloads language data.
Getting an empty rawText response?
Make sure you are sending the request as form-data (not JSON). The field name must be exactly receipt to match upload.single('receipt') in your route.
If OCR returns text but extractAmount returns null, your test image may not have a recognizable dollar pattern. Try a clearer receipt photo with visible "Total" text. Help me debug my receipt OCR endpoint.
✔️ Awesome, I've got everything!
Great. Double check you have saved both controllers/expenseController.js and routes/expenseRoutes.js.
ⓧ I'd like to double check the full code
Here is the full middleware/upload.js:
const multer = require('multer');
const storage = multer.memoryStorage();
const fileFilter = (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 },
});
module.exports = upload;
Here is the full routes/expenseRoutes.js:
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const upload = require('../middleware/upload');
const {
createExpense,
getExpenses,
updateExpense,
deleteExpense,
scanReceipt,
} = require('../controllers/expenseController');
router.use(auth);
router.post('/scan-receipt', upload.single('receipt'), scanReceipt);
router.post('/', createExpense);
router.get('/', getExpenses);
router.put('/:id', updateExpense);
router.delete('/:id', deleteExpense);
module.exports = router;
Here is the full controllers/expenseController.js:
const Expense = require('../models/Expense');
const { createWorker } = require('tesseract.js');
exports.createExpense = async (req, res, next) => {
try {
const expense = await Expense.create({ ...req.body, userId: req.user.id });
res.status(201).json({ success: true, data: expense });
} catch (error) { next(error); }
};
exports.getExpenses = async (req, res, next) => {
try {
const { search, category, startDate, endDate } = req.query;
const query = { userId: req.user.id };
if (search) { query.title = { $regex: search, $options: 'i' }; }
if (category && category !== 'all') { query.category = category; }
if (startDate || endDate) {
query.date = {};
if (startDate) query.date.$gte = new Date(startDate);
if (endDate) query.date.$lte = new Date(endDate);
}
const expenses = await Expense.find(query).sort({ date: -1 });
res.json({ success: true, data: expenses });
} catch (error) { next(error); }
};
exports.updateExpense = async (req, res, next) => {
try {
const expense = await Expense.findOneAndUpdate(
{ _id: req.params.id, userId: req.user.id }, req.body, { new: true, runValidators: true }
);
if (!expense) return res.status(404).json({ success: false, message: 'Expense not found' });
res.json({ success: true, data: expense });
} catch (error) { next(error); }
};
exports.deleteExpense = async (req, res, next) => {
try {
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.user.id });
if (!expense) return res.status(404).json({ success: false, message: 'Expense not found' });
res.json({ success: true, message: 'Expense deleted' });
} catch (error) { next(error); }
};
exports.scanReceipt = async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No image uploaded' });
}
const worker = await createWorker('eng');
const { data: { text } } = await worker.recognize(req.file.buffer);
await worker.terminate();
const amount = extractAmount(text);
const date = extractDate(text);
const merchant = extractMerchant(text);
res.json({ success: true, data: { amount, date, merchant, rawText: text } });
} catch (error) { next(error); }
};
function extractAmount(text) {
const patterns = [
/total[:\s]*\$?([\d]+\.[\d]{2})/i,
/amount[:\s]*\$?([\d]+\.[\d]{2})/i,
/\$\s*([\d]+\.[\d]{2})/,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) return parseFloat(match[1]);
}
return null;
}
function extractDate(text) {
const pattern = /(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/;
const match = text.match(pattern);
if (match) return match[0];
return new Date().toISOString().split('T')[0];
}
function extractMerchant(text) {
const lines = text.split('\n').filter(line => line.trim().length > 0);
return lines.length > 0 ? lines[0].trim() : '';
}
Add receipt upload to the frontend
Your backend can now extract text from receipt images. The final piece is connecting it to your React frontend so users can upload a photo and have the expense form auto-fill with the scanned data.
- Open your expense form component and add the following features:
- Add a hidden file input with accept="image/*" and an "Upload Receipt" button that triggers it.
- When a file is selected, create a FormData object, append the file with the key receipt, and POST it to /api/expenses/scan-receipt.
- On success, populate the form fields: set amount from data.amount, date from data.date, and title from data.merchant.
- Show an image preview using URL.createObjectURL(file) so the user can see what they uploaded.
- Let the user review and edit the auto-filled values before submitting through the normal create expense flow.
Here is an example implementation of the upload handler logic to add to your expense form component:
// State for image preview
const [preview, setPreview] = useState(null);
const [scanning, setScanning] = useState(false);
// Handle receipt file selection
const handleReceiptUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Show image preview
setPreview(URL.createObjectURL(file));
setScanning(true);
// Send file to backend as FormData
const formData = new FormData();
formData.append('receipt', file);
try {
const response = await fetch('/api/expenses/scan-receipt', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token'),
},
body: formData,
});
const result = await response.json();
if (result.success) {
// Auto-fill form fields with extracted data
if (result.data.amount) setAmount(result.data.amount);
if (result.data.date) setDate(result.data.date);
if (result.data.merchant) setTitle(result.data.merchant);
}
} catch (error) {
console.error('Receipt scan failed:', error);
} finally {
setScanning(false);
}
};
What does this code do?
- FormData is a browser API that creates multipart/form-data requests. This is required because multer only processes multipart uploads. Do NOT set a Content-Type header manually. The browser sets it automatically with the correct boundary string.
- URL.createObjectURL(file) creates a temporary browser URL pointing to the selected file so you can display a preview without uploading it to a server.
- The form auto-fills but does NOT auto-submit. The user reviews the extracted values, corrects any OCR mistakes, and then submits through your existing create expense flow.
- Add the corresponding JSX to your form's return statement. Include a button that triggers the hidden file input and conditionally renders the image preview:
// Add this inside your form JSX
<input
type="file"
accept="image/*"
onChange={handleReceiptUpload}
style={{ display: 'none' }}
id="receipt-upload"
/>
<label htmlFor="receipt-upload" style={{ cursor: 'pointer' }}>
<button type="button" onClick={() => document.getElementById('receipt-upload').click()}>
{scanning ? 'Scanning...' : 'Upload Receipt'}
</button>
</label>
{preview && (
<img src={preview} alt="Receipt preview" style={{ maxWidth: '200px', marginTop: '10px' }} />
)}
Why a hidden file input?
HTML file inputs have browser-default styling that is difficult to customize. The pattern of hiding the actual input and triggering it from a styled button gives you full control over the appearance while keeping the native file picker behavior.
- Save your expense form component.
- Open your app in the browser and click the Upload Receipt button. Select a receipt photo. After a few seconds, the amount, date, and title fields should auto-fill with extracted values.
Upload button does nothing?
Check that the id on the file input matches what document.getElementById references. Also verify your API helper is NOT setting Content-Type: application/json on this request. FormData requires the browser to set its own Content-Type with the multipart boundary.
If the scan returns but fields don't fill, check your browser console for the response shape. The data is nested at result.data.amount, not result.amount. Help me fix my receipt upload frontend.
Your expense tracker now has full receipt OCR. Users can snap a photo of any receipt and have the form pre-populated with the amount, date, and merchant name. Next up, the Secret Mission challenges you to build an expense statistics dashboard with MongoDB aggregation.
Secret mission
Expense Statistics Dashboard
Your expense tracker has all the data it needs to answer questions like 'How much am I spending this month?' and 'Which category eats my budget?' Build a real-time statistics endpoint using MongoDB's aggregation framework and display the results in a dashboard component.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a local MongoDB connection, so there are no ongoing cloud costs.
Resources you used:
- Local project files (backend controllers, models, middleware, routes, frontend components).
- MongoDB collections (Users collection, Expenses collection with date and userId fields).
- npm packages installed in your project (bcryptjs, jsonwebtoken, multer, tesseract.js).
- Environment variable (JWT_SECRET in your .env file).
Keep everything running
No action needed. Choose this if you're still actively building on your expense tracker or plan to add more features soon.
- Your MongoDB data persists between sessions. Next time you start your backend server, your users and expenses will still be there.
- Your JWT_SECRET stays in your .env file. Existing tokens issued with that secret remain valid for 7 days.
- The Tesseract.js language data is cached locally after the first OCR request. This speeds up future receipt scans.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files and database stay intact for when you return.
- Stop your backend server by pressing Ctrl+C in the terminal where it is running.
- Stop your React development server by pressing Ctrl+C in its terminal as well.
- If MongoDB is running as a local process (not a system service), you can stop it too. Otherwise, it runs in the background with minimal resource usage.
- When you come back, restart your backend with npm run dev (or your start script) and your frontend with its start command. Everything picks up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, database data, and installed packages permanently.
- Stop both your backend and frontend servers by pressing Ctrl+C in each terminal.
- Drop the MongoDB collections by opening a Mongo shell and running:
mongosh
use your-database-name
db.users.drop()
db.expenses.drop()
- Delete your project folder. On macOS/Linux:
cd ~/Desktop
rm -rf your-project-folder
On Windows:
cd ~\Desktop
rmdir /s /q your-project-folder
- This removes all source code, the node_modules folder (which contains bcryptjs, jsonwebtoken, multer, and tesseract.js), and your .env file with the JWT_SECRET.
That's a Wrap!
That's a Wrap!
Nice work! You have transformed a basic CRUD expense tracker into a full-featured MVP with authentication, intelligent search, and receipt scanning.
You've learned how to:
- Build JWT-based authentication with password hashing, protected routes, and user-scoped data isolation across the full MERN stack.
- Construct dynamic MongoDB queries using $regex, $gte, and $lte operators for search and filtering with a debounced React frontend.
- Implement the complete file upload pattern from frontend FormData through Multer middleware to Tesseract.js OCR processing, with regex-based text extraction that auto-fills expense forms.
- Build a statistics dashboard powered by MongoDB aggregation pipelines for real-time analytics including monthly totals, daily averages, and category breakdowns.
Ready to quiz yourself?