# How to Build Expense tracking with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build an expense tracking app with V0 using Next.js, Supabase for expense records and receipt storage, and Recharts for spending analytics. You'll create expense submission with receipt uploads, manager approval workflows, and department budget dashboards — all in about 1-2 hours without touching a terminal.

## Before you start

- A V0 account (Premium plan for chart and upload iterations)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A list of expense categories for your organization (travel, meals, software, etc.)
- Department names and budget amounts if using the budget tracking feature

## Step-by-step guide

### 1. Set up the project and expense database schema

Open V0 and create a new project. Connect Supabase via the Connect panel. Create the schema for departments, categories, expenses, and policies with a trigger for automatic policy enforcement.

```
// Paste this prompt into V0's AI chat:
// Build an expense tracking app. Create a Supabase schema with:
// 1. departments: id (uuid PK), name (text), budget_cents (int)
// 2. expense_categories: id (uuid PK), name (text), icon (text)
// 3. expenses: id (uuid PK), user_id (uuid FK to auth.users), department_id (uuid FK), category_id (uuid FK), title (text), amount_cents (int), currency (text default 'usd'), receipt_url (text), description (text), status (text default 'pending' check in 'pending','approved','rejected','reimbursed'), submitted_at (timestamptz default now()), reviewed_by (uuid FK to auth.users), reviewed_at (timestamptz)
// 4. expense_policies: id (uuid PK), department_id (uuid FK), category_id (uuid FK), max_amount_cents (int), requires_approval (boolean default true)
// Create a PostgreSQL function check_expense_policy that fires before INSERT on expenses: if amount_cents <= max_amount_cents for matching department+category, auto-set status to 'approved'.
// Create a Supabase Storage bucket 'receipts' with 5MB file limit.
// Add RLS: users see their own expenses, managers see their department's expenses.
```

> Pro tip: Create the 'receipts' Storage bucket in Supabase Dashboard with RLS policies that allow authenticated uploads and restrict reads to the expense owner and their department manager.

**Expected result:** Database schema created with automatic policy trigger. Supabase Storage bucket 'receipts' configured for file uploads.

### 2. Build the expense submission form with receipt upload

Create the expense submission page with a drag-and-drop receipt upload zone, category selection, and amount input. Receipts are uploaded directly to Supabase Storage.

```
// Paste this prompt into V0's AI chat:
// Build an expense submission page at app/expenses/new/page.tsx as a 'use client' component.
// Requirements:
// - Input for expense title (required)
// - Input for amount with dollar sign prefix, type number, step 0.01
// - Select dropdown for expense category (fetched from expense_categories)
// - Select dropdown for department
// - Textarea for description/notes
// - A drag-and-drop file upload zone for receipt image (accept image/*, max 5MB)
// - Upload receipt to Supabase Storage bucket 'receipts' with path: {user_id}/{timestamp}-{filename}
// - Show upload Progress bar during file upload
// - Submit Button that calls a Server Action with Zod validation:
//   - title required, 3-100 chars
//   - amount_cents must be > 0
//   - category_id required
//   - receipt_url required for amounts over $25
// - After submission, show success toast and redirect to /expenses
// - Use Card to wrap the form with clear section labels
```

**Expected result:** The expense form accepts all fields including a receipt photo upload. Validation enforces receipt requirement for expenses over $25.

### 3. Create the personal expense dashboard

Build the main dashboard showing the user's expense summary with monthly totals, recent expenses, and spending breakdown by category. Use Server Components for data fetching and Recharts for charts.

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import Link from 'next/link'
import { SpendingChart } from '@/components/spending-chart'

export default async function ExpenseDashboard() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString()

  const { data: expenses } = await supabase
    .from('expenses')
    .select('*, expense_categories(name, icon)')
    .eq('user_id', user?.id)
    .order('submitted_at', { ascending: false })
    .limit(10)

  const { data: monthlyTotal } = await supabase
    .from('expenses')
    .select('amount_cents')
    .eq('user_id', user?.id)
    .gte('submitted_at', startOfMonth)
    .in('status', ['approved', 'reimbursed'])

  const total = monthlyTotal?.reduce((s, e) => s + e.amount_cents, 0) ?? 0
  const pending = expenses?.filter(e => e.status === 'pending').length ?? 0

  const statusColors: Record<string, string> = {
    pending: 'bg-yellow-100 text-yellow-800',
    approved: 'bg-green-100 text-green-800',
    rejected: 'bg-red-100 text-red-800',
    reimbursed: 'bg-blue-100 text-blue-800',
  }

  return (
    <div className="container mx-auto py-8">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">My Expenses</h1>
        <Link href="/expenses/new">
          <Badge className="cursor-pointer">+ New Expense</Badge>
        </Link>
      </div>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
        <Card><CardHeader><CardTitle className="text-sm">This Month</CardTitle></CardHeader><CardContent><p className="text-2xl font-bold">${(total / 100).toFixed(2)}</p></CardContent></Card>
        <Card><CardHeader><CardTitle className="text-sm">Pending</CardTitle></CardHeader><CardContent><p className="text-2xl font-bold">{pending}</p></CardContent></Card>
        <Card><CardHeader><CardTitle className="text-sm">Total Submitted</CardTitle></CardHeader><CardContent><p className="text-2xl font-bold">{expenses?.length ?? 0}</p></CardContent></Card>
      </div>
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Title</TableHead>
            <TableHead>Category</TableHead>
            <TableHead>Amount</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Date</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {expenses?.map((exp) => (
            <TableRow key={exp.id}>
              <TableCell><Link href={`/expenses/${exp.id}`} className="hover:underline">{exp.title}</Link></TableCell>
              <TableCell>{exp.expense_categories?.icon} {exp.expense_categories?.name}</TableCell>
              <TableCell>${(exp.amount_cents / 100).toFixed(2)}</TableCell>
              <TableCell><Badge className={statusColors[exp.status]}>{exp.status}</Badge></TableCell>
              <TableCell>{new Date(exp.submitted_at).toLocaleDateString()}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}
```

**Expected result:** The dashboard shows monthly spending total, pending count, and a table of recent expenses with color-coded status badges.

### 4. Build the manager approval queue

Create the admin page where managers review pending expenses, view receipt images, and approve or reject with optional notes.

```
// Paste this prompt into V0's AI chat:
// Build a manager approval page at app/admin/page.tsx.
// Requirements:
// - Server Component that fetches all pending expenses for the manager's department
// - Display in a shadcn/ui Table with columns: employee name, title, category Badge, amount, receipt thumbnail (clickable to expand), submitted date
// - Each row has two action Buttons: "Approve" (green) and "Reject" (red)
// - Approve calls a Server Action that sets status to 'approved' and reviewed_by/reviewed_at
// - Reject opens a Dialog with a Textarea for rejection reason, then sets status to 'rejected'
// - Show spending summary Cards at the top: total pending amount, approved this month, department budget remaining
// - Add filter Tabs: All Pending, Over $100, Flagged by Policy
// - Clicking the receipt thumbnail opens a Dialog with the full-size receipt image from Supabase Storage
// - Use Select to filter by expense category
```

> Pro tip: Use Design Mode (Option+D) to adjust the approval table layout, button sizing, and card positioning for the dashboard without spending credits.

**Expected result:** Managers see all pending expenses with receipt previews. They can approve with one click or reject with a required reason.

### 5. Add spending reports with charts and date filtering

Build the reports page with department and category breakdowns using Recharts. Include date range filtering with shadcn/ui DatePickerWithRange.

```
// Paste this prompt into V0's AI chat:
// Build a reports page at app/reports/page.tsx.
// Requirements:
// - Date range filter at the top using shadcn/ui DatePickerWithRange component
// - Department spending breakdown as a Recharts BarChart (one bar per department, colored by budget utilization)
// - Category spending breakdown as a Recharts PieChart showing percentage per category
// - Monthly trend as a Recharts LineChart showing total approved expenses over the last 12 months
// - Summary Cards: total approved, average expense amount, highest single expense, total reimbursed
// - Wrap charts in 'use client' components, keep the page as Server Component for initial data fetch
// - Select dropdown to filter by department
// - Table showing top 10 highest expenses in the selected period
// - Export Button to download the report data as CSV
// - Use Card to wrap each chart section with clear headings
```

**Expected result:** The reports page shows spending analytics with interactive date filtering, department bar charts, category pie charts, and monthly trends.

## Complete code example

File: `app/actions/expenses.ts`

```typescript
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'

const expenseSchema = z.object({
  title: z.string().min(3).max(100),
  amount_cents: z.number().int().positive(),
  category_id: z.string().uuid(),
  department_id: z.string().uuid(),
  description: z.string().max(500).optional(),
  receipt_url: z.string().url().optional(),
})

export async function submitExpense(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Unauthorized')

  const parsed = expenseSchema.parse({
    title: formData.get('title'),
    amount_cents: Math.round(Number(formData.get('amount')) * 100),
    category_id: formData.get('category_id'),
    department_id: formData.get('department_id'),
    description: formData.get('description'),
    receipt_url: formData.get('receipt_url'),
  })

  const { error } = await supabase.from('expenses').insert({
    ...parsed,
    user_id: user.id,
  })

  if (error) throw new Error(error.message)

  revalidatePath('/expenses')
  redirect('/expenses')
}

export async function approveExpense(expenseId: string) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  await supabase
    .from('expenses')
    .update({
      status: 'approved',
      reviewed_by: user?.id,
      reviewed_at: new Date().toISOString(),
    })
    .eq('id', expenseId)

  revalidatePath('/admin')
}

export async function rejectExpense(expenseId: string, reason: string) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  await supabase
    .from('expenses')
    .update({
      status: 'rejected',
      description: reason,
      reviewed_by: user?.id,
      reviewed_at: new Date().toISOString(),
    })
    .eq('id', expenseId)

  revalidatePath('/admin')
}
```

## Common mistakes

- **Storing receipt files in the database instead of Supabase Storage** — Binary file data in the database bloats table size, slows queries, and hits Supabase's row size limits. Fix: Upload receipts to Supabase Storage and store only the file URL in the expenses table. Use signed URLs for private receipt access.
- **Not enforcing receipt requirements for high-value expenses server-side** — Client-side validation can be bypassed. Expenses over policy thresholds could be submitted without receipts. Fix: Add a check in the Server Action: if amount_cents > policy threshold and receipt_url is null, reject the submission with a clear error message.
- **Using floating-point numbers for monetary amounts** — Floating-point arithmetic causes rounding errors ($10.10 + $10.20 might not equal $20.30). Fix: Store all amounts in cents as integers (amount_cents). Convert to dollars only for display using (amount_cents / 100).toFixed(2).

## Best practices

- Store monetary amounts in cents as integers to avoid floating-point rounding errors.
- Upload receipts to Supabase Storage and store only the URL reference in the expenses table.
- Use a PostgreSQL trigger for automatic policy enforcement — expenses under the threshold are auto-approved without manager review.
- Validate expense submissions server-side with Zod in Server Actions, enforcing receipt requirements based on amount thresholds.
- Use Design Mode (Option+D) to adjust dashboard chart layouts and approval table styling without spending V0 credits.
- Set SUPABASE_SERVICE_ROLE_KEY in the Vars tab without NEXT_PUBLIC_ prefix. Add OPENAI_API_KEY (no prefix) if using receipt OCR.

## Frequently asked questions

### How do receipt uploads work?

Receipt images are uploaded directly to a Supabase Storage bucket called 'receipts'. The client component handles the file upload with a progress indicator, and the returned URL is stored in the expense record. RLS policies restrict access to the expense owner and their manager.

### Can expenses be auto-approved based on amount?

Yes. A PostgreSQL trigger function checks the expense amount against the expense_policies table. If the amount is under the department/category threshold, the expense is automatically approved without manager review.

### How do I deploy this app?

Click Share in V0, then Publish to Production. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the Vars tab. Configure the Supabase Storage bucket 'receipts' with appropriate RLS policies.

### Can I add receipt scanning with AI?

Yes. Add an API route that sends the receipt image to OpenAI Vision API to extract the vendor, amount, and date. Set OPENAI_API_KEY in the Vars tab (no NEXT_PUBLIC_ prefix) and pre-fill the expense form with extracted data.

### What V0 plan do I need?

Premium ($20/month) is recommended for the chart components and file upload iterations. Free tier works for the basic expense form but may need manual adjustments for the analytics dashboard.

### Can RapidDev help build a custom expense tracking system?

Yes. RapidDev has built 600+ apps including enterprise expense management platforms with multi-currency support, receipt OCR, and ERP integrations. Book a free consultation to discuss your needs.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/expense-tracking
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/expense-tracking
