# How to Build a Finance Tracker with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a personal finance tracker in Replit in 1-2 hours. Track income and expenses across multiple accounts, set monthly budgets per category, and visualize spending trends with charts. Account balances update automatically via a PostgreSQL trigger on every transaction. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.

## Before you start

- A Replit account (free tier is sufficient)
- Basic understanding of what income, expenses, and accounts are (no coding needed)
- No external API keys required — everything runs on Replit's built-in PostgreSQL

## Step-by-step guide

### 1. Generate the schema and project with Replit Agent

The transaction-to-balance relationship is the most important design decision. By maintaining balances in the accounts table via a trigger, you never need to SUM all transactions to show a balance — it's always pre-calculated and accurate.

```
// Prompt to type into Replit Agent:
// Build a personal finance tracker with Express and PostgreSQL using Drizzle ORM.
// Create these tables in shared/schema.ts:
// - accounts: id serial pk, user_id text not null, name text not null,
//   type text not null (checking/savings/credit_card/cash/investment),
//   balance integer not null default 0 (in cents),
//   currency text default 'USD', is_active boolean default true, created_at timestamp
// - categories: id serial pk, user_id text not null, name text not null,
//   type text not null (income/expense), icon text, color text,
//   position integer default 0
// - transactions: id serial pk, user_id text not null,
//   account_id integer references accounts not null,
//   category_id integer references categories,
//   type text not null (income/expense/transfer),
//   amount integer not null (always positive, in cents),
//   description text, date date not null,
//   is_recurring boolean default false,
//   recurrence_rule text (none/daily/weekly/biweekly/monthly),
//   notes text, created_at timestamp
// - budgets: id serial pk, user_id text not null,
//   category_id integer references categories not null,
//   amount integer not null (monthly limit in cents),
//   month date not null (first day of month),
//   UNIQUE on (user_id, category_id, month)
// Create a PostgreSQL trigger on transactions that updates accounts.balance:
//   On INSERT: if type='expense' subtract amount, if type='income' add amount
//   On DELETE: reverse the above
//   On UPDATE: reverse the old amount, apply the new amount
// Set up Replit Auth. Bind server to 0.0.0.0.
```

> Pro tip: Ask Agent to create the trigger in the initial migration. A trigger-based balance is more reliable than application-level balance updates because it fires even for direct database edits done through Drizzle Studio.

**Expected result:** Agent creates shared/schema.ts with all four tables and a migration that includes the PostgreSQL trigger function. Verify the trigger exists by opening Drizzle Studio and creating a test transaction.

### 2. Build the transaction CRUD routes

Transactions are the core of the app. The balance trigger means you only need to insert, update, or delete transaction rows — the balance updates automatically. No manual account balance management.

```
const { db } = require('../db');
const { transactions, accounts, categories } = require('../../shared/schema');
const { eq, and, gte, lte, desc, sql } = require('drizzle-orm');

router.get('/api/transactions', async (req, res) => {
  const { startDate, endDate, accountId, categoryId, type, q, page = 1, limit = 50 } = req.query;
  const offset = (Number(page) - 1) * Number(limit);

  const conditions = [eq(transactions.userId, req.user.id)];
  if (startDate) conditions.push(gte(transactions.date, startDate));
  if (endDate) conditions.push(lte(transactions.date, endDate));
  if (accountId) conditions.push(eq(transactions.accountId, Number(accountId)));
  if (categoryId) conditions.push(eq(transactions.categoryId, Number(categoryId)));
  if (type) conditions.push(eq(transactions.type, type));
  if (q) conditions.push(sql`transactions.description ILIKE ${'%' + q + '%'}`);

  const [result, countResult] = await Promise.all([
    db.select({
      id: transactions.id,
      type: transactions.type,
      amount: transactions.amount,
      description: transactions.description,
      date: transactions.date,
      accountName: accounts.name,
      categoryName: categories.name,
      categoryColor: categories.color,
      categoryIcon: categories.icon
    }).from(transactions)
      .leftJoin(accounts, eq(transactions.accountId, accounts.id))
      .leftJoin(categories, eq(transactions.categoryId, categories.id))
      .where(and(...conditions))
      .orderBy(desc(transactions.date))
      .limit(Number(limit))
      .offset(offset),

    db.select({ count: sql`COUNT(*)` }).from(transactions).where(and(...conditions))
  ]);

  res.json({ transactions: result, total: Number(countResult[0].count) });
});

router.post('/api/transactions', async (req, res) => {
  const { accountId, categoryId, type, amount, description, date, isRecurring, recurrenceRule, notes } = req.body;

  // Validate account belongs to user
  const account = await db.query.accounts.findFirst({
    where: and(eq(accounts.id, Number(accountId)), eq(accounts.userId, req.user.id))
  });
  if (!account) return res.status(404).json({ error: 'Account not found' });

  const [tx] = await db.insert(transactions).values({
    userId: req.user.id, accountId: Number(accountId), categoryId: categoryId ? Number(categoryId) : null,
    type, amount: Math.abs(Number(amount)), description, date, isRecurring, recurrenceRule, notes
  }).returning();

  // The trigger automatically updates accounts.balance — no manual update needed
  res.json(tx);
});

router.delete('/api/transactions/:id', async (req, res) => {
  const tx = await db.query.transactions.findFirst({
    where: and(eq(transactions.id, Number(req.params.id)), eq(transactions.userId, req.user.id))
  });
  if (!tx) return res.status(404).json({ error: 'Transaction not found' });

  await db.delete(transactions).where(eq(transactions.id, tx.id));
  // Trigger reverses the balance automatically
  res.json({ success: true });
});
```

> Pro tip: After inserting a transaction, verify the trigger worked by calling GET /api/accounts and checking the balance changed. If it didn't, check the trigger exists in Drizzle Studio under Database → Functions.

**Expected result:** Adding a $50 expense transaction automatically reduces the account balance by $50. Deleting the transaction restores the balance. This confirms the trigger is working.

### 3. Build the budget and reports routes

The budget comparison query is the most valuable feature — it shows how much you've spent against your budget for the current month, per category. The monthly summary report powers the trend charts.

```
// Prompt to type into Replit Agent:
// Add budget and reporting routes to server/routes/reports.js:
//
// GET /api/budgets/:month — budget comparison for a month (format: YYYY-MM-01)
//   Returns per-category: budget_amount, actual_spent, percentage_used
//   Query:
//   SELECT c.id, c.name, c.color, c.icon,
//          b.amount AS budget_amount,
//          COALESCE(SUM(t.amount), 0) AS actual_spent,
//          CASE WHEN b.amount > 0 THEN ROUND(COALESCE(SUM(t.amount), 0) * 100.0 / b.amount)
//               ELSE 0 END AS percentage_used
//   FROM budgets b
//   JOIN categories c ON c.id = b.category_id
//   LEFT JOIN transactions t ON t.category_id = c.id
//     AND t.type = 'expense'
//     AND t.date >= :month AND t.date < :month + interval '1 month'
//   WHERE b.user_id = req.user.id AND b.month = :month
//   GROUP BY c.id, c.name, c.color, c.icon, b.amount
//
// PUT /api/budgets/:month — set budgets for a month
//   Body: array of {categoryId, amount}
//   Use INSERT ... ON CONFLICT (user_id, category_id, month) DO UPDATE SET amount
//
// GET /api/reports/monthly — income vs expense for last 12 months
//   Returns array of {month, income, expenses, net}
//   Use DATE_TRUNC('month', date) to group by month
//
// GET /api/reports/categories — spending by category for a date range
//   Returns array of {category_name, category_color, total_amount, transaction_count}
//
// GET /api/dashboard — summary endpoint:
//   Returns: accounts (list with balances), net_worth (sum of all account balances),
//   current_month_income, current_month_expenses, budget_alerts (categories over 90%),
//   recent_transactions (last 10)
```

**Expected result:** GET /api/budgets/2026-05-01 returns each budget category with actual spending and percentage used. GET /api/dashboard returns all summary data needed for the main dashboard view.

### 4. Build the React dashboard and transaction form

The dashboard is where users spend most of their time. Show accounts, net worth, budget progress bars, and recent transactions on a single page. The add-transaction modal should be fast to open and submit.

```
// Prompt to type into Replit Agent:
// Build these React components:
//
// 1. Dashboard at client/src/pages/Dashboard.jsx:
//    - On mount: fetch GET /api/dashboard
//    - Net worth card: large number showing sum of all account balances (in dollars)
//    - Account cards: one per account showing name, type badge, current balance
//      Color: positive balance = green text, negative = red (for credit cards: invert)
//    - Income vs Expenses section: two numbers for current month
//      'Saved this month' = income - expenses in green or red
//    - Budget progress bars:
//      Each category: name, progress bar (width = percentage_used%),
//      actual/budget amounts in dollars
//      Colors: green if < 80%, yellow if 80-100%, red if > 100%
//    - Recent transactions list: last 10, each showing:
//      category icon, description, account name, date, amount (red for expense, green for income)
//
// 2. Add Transaction modal:
//    - Amount input with $ prefix (number, required)
//    - Type selector: Income / Expense / Transfer (button group)
//    - Account selector (dropdown from GET /api/accounts)
//    - Category selector (dropdown filtered by selected type)
//    - Description input
//    - Date picker (default today)
//    - Notes textarea (optional)
//    - Submit → POST /api/transactions, close modal, refresh dashboard
//
// 3. Transactions page at client/src/pages/Transactions.jsx:
//    - Data table with filters: date range picker, account dropdown, category dropdown, search bar
//    - Rows: date, description, category (with colored icon), account, amount (colored)
//    - Click row to edit, delete button per row
```

**Expected result:** The dashboard loads with account balances, net worth, budget progress bars (green/yellow/red), and recent transactions. Adding a transaction closes the modal and updates all numbers without a full page reload.

### 5. Add seed categories and deploy

Seed the categories table with common income and expense categories before sharing the app. Then deploy on Autoscale — personal finance tools are used daily but briefly, making Autoscale ideal.

```
// Prompt to type into Replit Agent:
// Create scripts/seed.js that inserts default categories for a new user.
// Income categories:
// [{name: 'Salary', type: 'income', icon: '💼', color: '#22c55e'},
//  {name: 'Freelance', type: 'income', icon: '💻', color: '#16a34a'},
//  {name: 'Investments', type: 'income', icon: '📈', color: '#15803d'},
//  {name: 'Other Income', type: 'income', icon: '➕', color: '#166534'}]
// Expense categories:
// [{name: 'Food & Dining', type: 'expense', icon: '🍔', color: '#ef4444'},
//  {name: 'Transportation', type: 'expense', icon: '🚗', color: '#f97316'},
//  {name: 'Shopping', type: 'expense', icon: '🛍️', color: '#a855f7'},
//  {name: 'Bills & Utilities', type: 'expense', icon: '📄', color: '#6366f1'},
//  {name: 'Entertainment', type: 'expense', icon: '🎬', color: '#ec4899'},
//  {name: 'Health', type: 'expense', icon: '🏥', color: '#14b8a6'},
//  {name: 'Housing', type: 'expense', icon: '🏠', color: '#8b5cf6'},
//  {name: 'Other', type: 'expense', icon: '📦', color: '#6b7280'}]
// Accept userId as command line arg: node scripts/seed.js USER_ID
// INSERT with ON CONFLICT DO NOTHING so re-running is safe
//
// Then add SESSION_SECRET to Replit Secrets (lock icon)
// Ensure server binds to 0.0.0.0
// Deploy: Deploy → Autoscale
```

> Pro tip: Run the seed script from the Replit Shell tab after getting your Replit user ID from the /api/me route. This gives you sensible default categories on first login without needing to set them up manually.

**Expected result:** The app deploys on Autoscale. After logging in with Replit Auth, the category dropdowns are pre-populated with income and expense categories. The first account and transaction can be created immediately.

## Complete code example

File: `server/routes/transactions.js`

```javascript
const { Router } = require('express');
const { db } = require('../db');
const { transactions, accounts, categories } = require('../../shared/schema');
const { eq, and, gte, lte, desc, sql } = require('drizzle-orm');

const router = Router();

router.get('/api/transactions', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const { startDate, endDate, accountId, categoryId, type, q, page = 1, limit = 50 } = req.query;
  const offset = (Number(page) - 1) * Number(limit);

  const conditions = [eq(transactions.userId, req.user.id)];
  if (startDate) conditions.push(gte(transactions.date, startDate));
  if (endDate) conditions.push(lte(transactions.date, endDate));
  if (accountId) conditions.push(eq(transactions.accountId, Number(accountId)));
  if (type) conditions.push(eq(transactions.type, type));
  if (q) conditions.push(sql`transactions.description ILIKE ${'%' + q + '%'}`);

  const rows = await db.select({
    id: transactions.id, type: transactions.type, amount: transactions.amount,
    description: transactions.description, date: transactions.date,
    accountName: accounts.name, categoryName: categories.name,
    categoryColor: categories.color, categoryIcon: categories.icon
  }).from(transactions)
    .leftJoin(accounts, eq(transactions.accountId, accounts.id))
    .leftJoin(categories, eq(transactions.categoryId, categories.id))
    .where(and(...conditions)).orderBy(desc(transactions.date))
    .limit(Number(limit)).offset(offset);

  res.json(rows);
});

router.post('/api/transactions', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const { accountId, categoryId, type, amount, description, date, notes } = req.body;

  const account = await db.query.accounts.findFirst({
    where: and(eq(accounts.id, Number(accountId)), eq(accounts.userId, req.user.id))
  });
  if (!account) return res.status(404).json({ error: 'Account not found' });

  const [tx] = await db.insert(transactions).values({
    userId: req.user.id, accountId: Number(accountId),
    categoryId: categoryId ? Number(categoryId) : null,
    type, amount: Math.abs(Number(amount)), description, date, notes
  }).returning();

  // PostgreSQL trigger updates accounts.balance automatically
  const [updated] = await db.select({ balance: accounts.balance })
    .from(accounts).where(eq(accounts.id, Number(accountId)));

  res.json({ transaction: tx, newBalance: updated.balance });
});

router.delete('/api/transactions/:id', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const tx = await db.query.transactions.findFirst({
    where: and(eq(transactions.id, Number(req.params.id)), eq(transactions.userId, req.user.id))
  });
```

## Common mistakes

- **Manually updating account balances in the route handler instead of using a trigger** — If the balance update and the transaction insert run in separate statements and the server crashes between them, the balance and transaction history get out of sync permanently. Fix: Use a PostgreSQL trigger that fires AFTER INSERT, UPDATE, DELETE on transactions. The trigger and the originating transaction execute atomically — either both succeed or both fail.
- **Storing amounts as floats (1.50) instead of integers in cents (150)** — Floating-point arithmetic in databases and JavaScript produces rounding errors: 0.1 + 0.2 = 0.30000000000000004. Financial calculations must be exact. Fix: Store all amounts as integers in cents. Convert to dollars only in the frontend for display: (cents / 100).toFixed(2). Never store 1.50 — store 150.
- **Querying all transactions to calculate account balance on every page load** — A user with 3 years of transactions (1,000+ rows) forces a full table scan on every dashboard load. Fix: The trigger-maintained balance column in the accounts table is the answer. Always read the pre-calculated balance, never SUM transactions. The trigger keeps it current.

## Best practices

- Store all monetary amounts as integers in cents (100 = $1.00) to avoid floating-point rounding errors in financial calculations.
- Use a PostgreSQL trigger to maintain account balances — it runs atomically with the transaction insert and handles rollbacks correctly if anything fails.
- Scope every query to the authenticated user's user_id. Never return transactions or account balances for other users even if called with a valid account ID.
- Use Drizzle Studio (database icon in sidebar) to verify the trigger works after setup: insert a transaction manually in Drizzle Studio and confirm the account balance updates.
- Default the transaction date to today's date in the frontend form but allow editing — users often log transactions from receipts at the end of the day.
- Deploy on Autoscale — personal finance apps have brief daily usage sessions. The scale-to-zero behavior keeps costs at zero on the free tier.

## Frequently asked questions

### How do I handle credit card transactions?

Credit card accounts work like any other account. Expenses increase the amount owed (balance goes more negative) and payments decrease it (balance goes less negative). In the UI, show credit card balances in red and display the absolute value as 'owed' to make it intuitive.

### What is a transfer transaction?

A transfer moves money between two of your accounts — for example, paying your credit card bill from your checking account. Create two transactions: an expense on the checking account and an income on the credit card account, both for the same amount. The trigger handles both balance updates.

### Can multiple family members use the same tracker?

In the current design, each Replit Auth user gets their own isolated data. For shared tracking, add a household_id column to accounts and transactions, and a household_members table. Queries would filter by household_id instead of user_id for shared accounts.

### Do I need a paid Replit plan for this?

No. The free plan includes PostgreSQL, Autoscale deployment, and Replit Auth. The only limitation is the database sleeping after 5 minutes of inactivity, which adds a brief cold-start delay on the first daily login.

### How do I import my bank transactions?

Most banks let you export transactions as CSV. Build a POST /api/transactions/import endpoint using multer for file upload and csv-parse for parsing. Map the CSV columns (date, description, amount) to your schema, detect income vs expense from the amount sign, and bulk-insert the rows. The trigger handles all balance updates.

### Why store amounts in cents instead of dollars?

Floating-point numbers cannot represent all decimal values precisely. 0.1 + 0.2 evaluates to 0.30000000000000004 in JavaScript. Storing amounts as integers (cents) eliminates all rounding errors in financial arithmetic. Only convert to dollars when displaying to the user.

### Can RapidDev help build a custom finance tool for my business?

Yes. RapidDev has built 600+ apps and can add features like multi-currency support, bank API integration (Plaid), tax category mapping, and accountant export formats. Book a free consultation at rapidevelopers.com.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/finance-tracker
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/finance-tracker
