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.
What you're building
Expense tracking is essential for any team or business. Employees need to submit expenses with receipts, managers need to approve or reject them, and finance teams need spending visibility across departments and categories.
V0 generates the submission forms, approval interfaces, and analytics dashboards from prompts. Supabase handles both the database and file storage for receipt photos via the Connect panel. Recharts provides interactive spending charts wrapped in client components.
The architecture uses Next.js App Router with Server Components for dashboards and reports, client components for the interactive submission form and receipt upload, Server Actions for expense CRUD and approval with Zod validation, Supabase Storage for receipt files, and a PostgreSQL trigger for automatic policy enforcement.
Final result
A complete expense tracking system with receipt uploads, multi-level approval, department budgets, spending analytics, and automatic policy enforcement.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build an expense tracking app. Create a Supabase schema with:3// 1. departments: id (uuid PK), name (text), budget_cents (int)4// 2. expense_categories: id (uuid PK), name (text), icon (text)5// 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)6// 4. expense_policies: id (uuid PK), department_id (uuid FK), category_id (uuid FK), max_amount_cents (int), requires_approval (boolean default true)7// 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'.8// Create a Supabase Storage bucket 'receipts' with 5MB file limit.9// 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.
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.
1// Paste this prompt into V0's AI chat:2// Build an expense submission page at app/expenses/new/page.tsx as a 'use client' component.3// Requirements:4// - Input for expense title (required)5// - Input for amount with dollar sign prefix, type number, step 0.016// - Select dropdown for expense category (fetched from expense_categories)7// - Select dropdown for department8// - Textarea for description/notes9// - A drag-and-drop file upload zone for receipt image (accept image/*, max 5MB)10// - Upload receipt to Supabase Storage bucket 'receipts' with path: {user_id}/{timestamp}-{filename}11// - Show upload Progress bar during file upload12// - Submit Button that calls a Server Action with Zod validation:13// - title required, 3-100 chars14// - amount_cents must be > 015// - category_id required16// - receipt_url required for amounts over $2517// - After submission, show success toast and redirect to /expenses18// - Use Card to wrap the form with clear section labelsExpected result: The expense form accepts all fields including a receipt photo upload. Validation enforces receipt requirement for expenses over $25.
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.
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'3import { Badge } from '@/components/ui/badge'4import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'5import Link from 'next/link'6import { SpendingChart } from '@/components/spending-chart'78export default async function ExpenseDashboard() {9 const supabase = await createClient()10 const { data: { user } } = await supabase.auth.getUser()1112 const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString()1314 const { data: expenses } = await supabase15 .from('expenses')16 .select('*, expense_categories(name, icon)')17 .eq('user_id', user?.id)18 .order('submitted_at', { ascending: false })19 .limit(10)2021 const { data: monthlyTotal } = await supabase22 .from('expenses')23 .select('amount_cents')24 .eq('user_id', user?.id)25 .gte('submitted_at', startOfMonth)26 .in('status', ['approved', 'reimbursed'])2728 const total = monthlyTotal?.reduce((s, e) => s + e.amount_cents, 0) ?? 029 const pending = expenses?.filter(e => e.status === 'pending').length ?? 03031 const statusColors: Record<string, string> = {32 pending: 'bg-yellow-100 text-yellow-800',33 approved: 'bg-green-100 text-green-800',34 rejected: 'bg-red-100 text-red-800',35 reimbursed: 'bg-blue-100 text-blue-800',36 }3738 return (39 <div className="container mx-auto py-8">40 <div className="flex justify-between items-center mb-6">41 <h1 className="text-3xl font-bold">My Expenses</h1>42 <Link href="/expenses/new">43 <Badge className="cursor-pointer">+ New Expense</Badge>44 </Link>45 </div>46 <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">47 <Card><CardHeader><CardTitle className="text-sm">This Month</CardTitle></CardHeader><CardContent><p className="text-2xl font-bold">${(total / 100).toFixed(2)}</p></CardContent></Card>48 <Card><CardHeader><CardTitle className="text-sm">Pending</CardTitle></CardHeader><CardContent><p className="text-2xl font-bold">{pending}</p></CardContent></Card>49 <Card><CardHeader><CardTitle className="text-sm">Total Submitted</CardTitle></CardHeader><CardContent><p className="text-2xl font-bold">{expenses?.length ?? 0}</p></CardContent></Card>50 </div>51 <Table>52 <TableHeader>53 <TableRow>54 <TableHead>Title</TableHead>55 <TableHead>Category</TableHead>56 <TableHead>Amount</TableHead>57 <TableHead>Status</TableHead>58 <TableHead>Date</TableHead>59 </TableRow>60 </TableHeader>61 <TableBody>62 {expenses?.map((exp) => (63 <TableRow key={exp.id}>64 <TableCell><Link href={`/expenses/${exp.id}`} className="hover:underline">{exp.title}</Link></TableCell>65 <TableCell>{exp.expense_categories?.icon} {exp.expense_categories?.name}</TableCell>66 <TableCell>${(exp.amount_cents / 100).toFixed(2)}</TableCell>67 <TableCell><Badge className={statusColors[exp.status]}>{exp.status}</Badge></TableCell>68 <TableCell>{new Date(exp.submitted_at).toLocaleDateString()}</TableCell>69 </TableRow>70 ))}71 </TableBody>72 </Table>73 </div>74 )75}Expected result: The dashboard shows monthly spending total, pending count, and a table of recent expenses with color-coded status badges.
Build the manager approval queue
Create the admin page where managers review pending expenses, view receipt images, and approve or reject with optional notes.
1// Paste this prompt into V0's AI chat:2// Build a manager approval page at app/admin/page.tsx.3// Requirements:4// - Server Component that fetches all pending expenses for the manager's department5// - Display in a shadcn/ui Table with columns: employee name, title, category Badge, amount, receipt thumbnail (clickable to expand), submitted date6// - Each row has two action Buttons: "Approve" (green) and "Reject" (red)7// - Approve calls a Server Action that sets status to 'approved' and reviewed_by/reviewed_at8// - Reject opens a Dialog with a Textarea for rejection reason, then sets status to 'rejected'9// - Show spending summary Cards at the top: total pending amount, approved this month, department budget remaining10// - Add filter Tabs: All Pending, Over $100, Flagged by Policy11// - Clicking the receipt thumbnail opens a Dialog with the full-size receipt image from Supabase Storage12// - Use Select to filter by expense categoryPro 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.
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.
1// Paste this prompt into V0's AI chat:2// Build a reports page at app/reports/page.tsx.3// Requirements:4// - Date range filter at the top using shadcn/ui DatePickerWithRange component5// - Department spending breakdown as a Recharts BarChart (one bar per department, colored by budget utilization)6// - Category spending breakdown as a Recharts PieChart showing percentage per category7// - Monthly trend as a Recharts LineChart showing total approved expenses over the last 12 months8// - Summary Cards: total approved, average expense amount, highest single expense, total reimbursed9// - Wrap charts in 'use client' components, keep the page as Server Component for initial data fetch10// - Select dropdown to filter by department11// - Table showing top 10 highest expenses in the selected period12// - Export Button to download the report data as CSV13// - Use Card to wrap each chart section with clear headingsExpected result: The reports page shows spending analytics with interactive date filtering, department bar charts, category pie charts, and monthly trends.
Complete code
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath } from 'next/cache'5import { redirect } from 'next/navigation'6import { z } from 'zod'78const expenseSchema = z.object({9 title: z.string().min(3).max(100),10 amount_cents: z.number().int().positive(),11 category_id: z.string().uuid(),12 department_id: z.string().uuid(),13 description: z.string().max(500).optional(),14 receipt_url: z.string().url().optional(),15})1617export async function submitExpense(formData: FormData) {18 const supabase = await createClient()19 const { data: { user } } = await supabase.auth.getUser()20 if (!user) throw new Error('Unauthorized')2122 const parsed = expenseSchema.parse({23 title: formData.get('title'),24 amount_cents: Math.round(Number(formData.get('amount')) * 100),25 category_id: formData.get('category_id'),26 department_id: formData.get('department_id'),27 description: formData.get('description'),28 receipt_url: formData.get('receipt_url'),29 })3031 const { error } = await supabase.from('expenses').insert({32 ...parsed,33 user_id: user.id,34 })3536 if (error) throw new Error(error.message)3738 revalidatePath('/expenses')39 redirect('/expenses')40}4142export async function approveExpense(expenseId: string) {43 const supabase = await createClient()44 const { data: { user } } = await supabase.auth.getUser()4546 await supabase47 .from('expenses')48 .update({49 status: 'approved',50 reviewed_by: user?.id,51 reviewed_at: new Date().toISOString(),52 })53 .eq('id', expenseId)5455 revalidatePath('/admin')56}5758export async function rejectExpense(expenseId: string, reason: string) {59 const supabase = await createClient()60 const { data: { user } } = await supabase.auth.getUser()6162 await supabase63 .from('expenses')64 .update({65 status: 'rejected',66 description: reason,67 reviewed_by: user?.id,68 reviewed_at: new Date().toISOString(),69 })70 .eq('id', expenseId)7172 revalidatePath('/admin')73}Customization ideas
Add receipt OCR with AI
Send receipt images to OpenAI Vision API via an API route to automatically extract vendor name, amount, and date, pre-filling the expense form.
Add recurring expenses
Allow users to mark expenses as recurring (monthly, weekly) and auto-generate future expense entries using a Vercel Cron job.
Add multi-currency support
Integrate an exchange rate API to convert expenses in foreign currencies to the base currency using the rate on the expense date.
Add Slack notifications for approvals
Send Slack notifications to managers when new expenses are submitted and to employees when their expenses are approved or rejected.
Common pitfalls
Pitfall: Storing receipt files in the database instead of Supabase Storage
How to avoid: Upload receipts to Supabase Storage and store only the file URL in the expenses table. Use signed URLs for private receipt access.
Pitfall: Not enforcing receipt requirements for high-value expenses server-side
How to avoid: 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.
Pitfall: Using floating-point numbers for monetary amounts
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an expense tracking app with Next.js and Supabase. I need to create a PostgreSQL trigger function that automatically approves expenses under a certain amount threshold based on department and category policies. Show me the trigger function, the expense_policies table schema, and how to attach the trigger to the expenses table.
Build the receipt upload component for an expense tracking app. Create a 'use client' component with a drag-and-drop zone that accepts image files up to 5MB. Upload to Supabase Storage bucket 'receipts' with the path {userId}/{timestamp}-{filename}. Show a Progress bar during upload. Return the public URL to the parent form. Display a thumbnail preview of the uploaded receipt with a remove Button.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation