Build a Personal Finance Tracker (React + Node)
In this tutorial, you'll learn about Build a Personal Finance Tracker (React + Node). We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a full-stack personal finance tracker application using React, Node.js/Express, and SQLite that manages income, expenses, budgets, and generates spending reports.
What You'll Build
You'll build a browser-based finance dashboard where you can log income and expense transactions, set monthly budgets per category, and view spending reports with charts. Think of a lightweight Mint.com — without the bank connections, but with full control over your own data.
Why a Finance Tracker Matters
Most people don't know where their money goes each month. A personal finance tracker gives you visibility into spending patterns, helps you stay within budget, and surfaces problem areas before they become emergencies. Financial institutions use similar systems internally to detect fraud and analyze Transaction patterns — the same techniques apply in security contexts like Durga Antivirus Pro's log analysis engine.
Prerequisites
- React.js fundamentals (components, state, hooks)
- Node.js and Express basics
- Basic SQL knowledge for database queries
Step 1: Project Setup
mkdir finance-tracker
cd finance-tracker
mkdir server client
cd server && npm init -y
npm install express cors better-sqlite3
cd ../client && npx create-react-app .
npm install axios recharts
Step 2: Build the Database Layer
// server/database.js
const Database = require('better-sqlite3');
const path = require('path');
const db = new Database(path.join(__dirname, 'finance.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category TEXT NOT NULL,
amount REAL NOT NULL,
description TEXT,
date TEXT NOT NULL DEFAULT (date('now'))
);
CREATE TABLE IF NOT EXISTS budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL UNIQUE,
limit_amount REAL NOT NULL
);
`);
module.exports = db;
better-sqlite3 is a synchronous SQLite library — simpler than async alternatives for a project this size. We create two tables: transactions stores every financial entry with a type flag, and budgets holds monthly spending caps per category.
Step 3: Create the Express API
// server/server.js
const express = require('express');
const cors = require('cors');
const db = require('./database');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/transactions', (req, res) => {
const { start, end } = req.query;
let sql = 'SELECT * FROM transactions';
const params = [];
if (start && end) {
sql += ' WHERE date BETWEEN ? AND ?';
params.push(start, end);
}
sql += ' ORDER BY date DESC';
res.json(db.prepare(sql).all(...params));
});
app.post('/api/transactions', (req, res) => {
const { type, category, amount, description } = req.body;
if (!type || !category || !amount) {
return res.status(400).json({ error: 'type, category, and amount required' });
}
const stmt = db.prepare(
'INSERT INTO transactions (type, category, amount, description) VALUES (?, ?, ?, ?)'
);
const result = stmt.run(type, category, amount, description || '');
res.json({ id: result.lastInsertRowid });
});
app.get('/api/reports/summary', (req, res) => {
const totals = db.prepare(`
SELECT type, SUM(amount) as total FROM transactions GROUP BY type
`).all();
const byCategory = db.prepare(`
SELECT category, SUM(amount) as total FROM transactions
WHERE type = 'expense' GROUP BY category ORDER BY total DESC
`).all();
res.json({ totals, byCategory });
});
app.get('/api/budgets', (req, res) => {
res.json(db.prepare('SELECT * FROM budgets').all());
});
app.post('/api/budgets', (req, res) => {
const { category, limit_amount } = req.body;
db.prepare(
'INSERT OR REPLACE INTO budgets (category, limit_amount) VALUES (?, ?)'
).run(category, limit_amount);
res.json({ success: true });
});
app.listen(4000, () => console.log('Server running on port 4000'));
The API exposes endpoints for transactions, budgets, and a summary report. The reports/summary endpoint aggregates totals by type and by category — the same GROUP BY pattern used in enterprise analytics dashboards.
Step 4: Build the React Frontend
// client/src/App.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const API = 'http://localhost:4000/api';
function App() {
const [transactions, setTransactions] = useState([]);
const [summary, setSummary] = useState({ totals: [], byCategory: [] });
const [form, setForm] = useState({ type: 'expense', category: '', amount: '', description: '' });
useEffect(() => {
axios.get(`${API}/transactions`).then(r => setTransactions(r.data));
axios.get(`${API}/reports/summary`).then(r => setSummary(r.data));
}, []);
const addTransaction = async () => {
await axios.post(`${API}/transactions`, {
...form, amount: parseFloat(form.amount)
});
setForm({ type: 'expense', category: '', amount: '', description: '' });
const [txn, rep] = await Promise.all([
axios.get(`${API}/transactions`),
axios.get(`${API}/reports/summary`)
]);
setTransactions(txn.data);
setSummary(rep.data);
};
const income = summary.totals.find(t => t.type === 'income')?.total || 0;
const expenses = summary.totals.find(t => t.type === 'expense')?.total || 0;
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: 20, fontFamily: 'system-ui' }}>
<h1>Finance Tracker</h1>
<div style={{ display: 'flex', gap: 16, margin: '16px 0' }}>
<div style={{ flex: 1, padding: 16, background: '#e6f7e6', borderRadius: 8 }}>
<strong>Income</strong> ${income.toFixed(2)}
</div>
<div style={{ flex: 1, padding: 16, background: '#ffe6e6', borderRadius: 8 }}>
<strong>Expenses</strong> ${expenses.toFixed(2)}
</div>
<div style={{ flex: 1, padding: 16, background: '#e6f0ff', borderRadius: 8 }}>
<strong>Balance</strong> ${(income - expenses).toFixed(2)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<select value={form.type} onChange={e => setForm({...form, type: e.target.value})}>
<option value="expense">Expense</option>
<option value="income">Income</option>
</select>
<input placeholder="Category" value={form.category}
onChange={e => setForm({...form, category: e.target.value})} />
<input placeholder="Amount" type="number" value={form.amount}
onChange={e => setForm({...form, amount: e.target.value})} />
<input placeholder="Description" value={form.description}
onChange={e => setForm({...form, description: e.target.value})} />
<button onClick={addTransaction}>Add</button>
</div>
<h2>Recent Transactions</h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f5f5f5' }}>
<th style={thStyle}>Date</th>
<th style={thStyle}>Type</th>
<th style={thStyle}>Category</th>
<th style={thStyle}>Amount</th>
<th style={thStyle}>Description</th>
</tr>
</thead>
<tbody>
{transactions.slice(0, 10).map(t => (
<tr key={t.id}>
<td style={tdStyle}>{t.date}</td>
<td style={tdStyle}>{t.type}</td>
<td style={tdStyle}>{t.category}</td>
<td style={{...tdStyle, color: t.type === 'income' ? 'green' : 'red'}}>
${t.amount.toFixed(2)}
</td>
<td style={tdStyle}>{t.description}</td>
</tr>
))}
</tbody>
</table>
<h2>Spending by Category</h2>
<ul>
{summary.byCategory.map(c => (
<li key={c.category}>{c.category}: ${c.total.toFixed(2)}</li>
))}
</ul>
</div>
);
}
const thStyle = { padding: 8, borderBottom: '2px solid #ddd', textAlign: 'left' };
const tdStyle = { padding: 8, borderBottom: '1px solid #eee' };
export default App;
Expected output: The dashboard shows income/expense/balance cards at top, a Transaction form, a table of recent entries, and a category breakdown. Add a Transaction — the summary updates immediately.
Architecture
flowchart LR
A[React Frontend] -->|HTTP| B[Express API :4000]
B --> C[SQLite Database]
B --> D[Reports Endpoint]
D --> E[Aggregated Summary]
E --> A
Common Errors
1. CORS errors on fetch
The browser blocks cross-origin requests if the server doesn't send CORS headers. Our Express app uses cors() middleware which handles this. If you get CORS errors, ensure the middleware is registered before your routes.
2. SQLITE_BUSY errors
SQLite locks the database during writes. better-sqlite3 is synchronous so this is rare, but if you run multiple server instances, switch to sqlite3 with WAL mode: db.pragma('journal_mode = WAL').
3. NaN showing for amounts
If parseFloat(form.amount) receives an empty string, it returns NaN. Add validation before calling the API: if (!form.amount) return alert('Enter an amount').
4. Transactions not persisting after server restart
SQLite writes to a local file (finance.db). If you restart the server in a different working directory, it creates a new empty database. Always start the server from the server/ folder.
Practice Questions
1. What does the CHECK(type IN ('income', 'expense')) constraint do?
It ensures the database only accepts valid Transaction types at the database level. If your code tries to insert 'withdrawal', SQLite rejects it — a safety net against bugs.
2. How does the summary report calculate totals?
It uses GROUP BY type to sum all income and expense amounts separately, and GROUP BY category to break down spending by category. This is the same aggregation pattern used in SQL reporting dashboards.
3. What happens if you delete the finance.db file?
All data is lost. SQLite stores everything in that single file. For production, back it up regularly or migrate to PostgreSQL.
4. Challenge: Add a budget alert Compare each category's spending against its budget limit. If spending exceeds 80% of the limit, show a warning icon next to the category in the report.
FAQ
Next Steps
- Add user authentication so multiple people can use the same app
- Learn about React state management with Redux or Context API
- Try building the Habit Tracker with Vue.js for another full-stack project
- Explore data visualization with D3.js in the charting tutorial
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro