
Finance OS
A no BS, full-stack personal finance workspace with real-time dashboards, budget tracking, savings goals, and a unified transaction ledger — purpose-built to replace Notion for serious personal finance management.
Ongoing
Full Stack
Solo
Technology Stack
Key Challenges
- Atomic Multi-Table Mutations
- Parallel Server-Side Data Fetching
- Complex Relational Data Modeling
- Real-Time Budget Computation
Key Learnings
- Next.js App Router & Server Components
- Drizzle ORM & Database-Level Constraints
- Better Auth Integration
- Advanced Recharts Patterns
## Overview
Finance OS is a personal financial operating system built from scratch — not assembled from templates, not shoehorned into a general-purpose tool. It emerged from a deliberate decision to stop managing personal finances inside Notion and instead build something that fits the problem correctly from the start.
The core insight: personal finance is not a document problem, it is a data problem. Notion's rollups, calculated properties, and filtered views hit hard limits the moment you want real spending trend charts, live budget trackers, or savings goals linked directly to transaction records. Finance OS replaces all of that with a purpose-built data model, a deliberate UI, and behavior that is exactly what's needed — nothing more, nothing less.
## Key Features
- Net Worth Dashboard: A real-time overview of total net worth across all accounts, cash flow balance, and account distribution — all fetched in parallel on the server using React
Suspensewith promise passing, so the page skeleton renders immediately and each chart streams in independently. - Unified Transaction Ledger: Every money movement — expenses, incomes, transfers — is captured in one place with full metadata: payment method, category, tags, linked bank account, and optional savings goal.
- Live Budget Tracking: Per-category spending limits computed in real time against actual transaction data, with support for monthly, weekly, and yearly periods. One budget per user per category per period is enforced at the database level.
- Savings Goals with Atomic Funding: Goals track progress against a target amount and optional target date. Funding a goal atomically deducts from the source account, records a transfer transaction, and updates the goal balance — all inside a single database transaction. Goals auto-complete when the target is met.
- Recurring Transactions: Schedule templates for repeating expenses and incomes (daily, weekly, monthly, yearly). A server action processes them into real transaction records and advances the next due date.
- Bank Account Registry: Tracks all financial accounts — checking, savings, credit cards, cash wallets, investments — each with a running balance, currency (defaulting to INR), and optional UI metadata like a hex color and icon identifier.
## Technical Implementation
### Architecture
Finance OS is built on Next.js 16 with the App Router and treats Server Components as the default rendering mode. Data fetching happens on the server, close to the database. Server Actions handle all mutations, eliminating the need for dedicated API routes for standard CRUD operations. The route structure uses route groups to separate the authenticated dashboard shell from the public landing page without polluting the URL.
### Data Layer
The schema is defined in TypeScript using Drizzle ORM, with every table living in its own file under db/schema/. Constraints — type enumerations, amount positivity checks, unique indexes — are declared at the schema level and enforced at the PostgreSQL level (hosted on Supabase), not just in application code. This means the database itself is the final guardian of data integrity, not just the application layer.
Schema tables:
user, session, account, verification → Managed by Better Auth
bank_account → Accounts with balance and currency
category → System-default and user-created categories
transaction → Core ledger: expenses, incomes, transfers
recurring_transaction → Scheduled repeating transaction templates
budget → Spending limits per category per period
goal → Savings goals with live progress tracking
tag, transaction_tag → Cross-cutting transaction labels
### Authentication
Better Auth handles the entire auth surface — email/password with enforced email verification, and Google OAuth. The auth adapter integrates directly with Drizzle, meaning auth tables live in the same PostgreSQL database with no separate auth service. On user creation, the system hooks in to generate a DiceBear avatar seeded from the user's name, giving every account a distinct identity without requiring a profile photo.
### Parallel Data Fetching
The dashboard page demonstrates a key architectural pattern: all chart queries are initiated at the top of the Server Component and their promises are passed down to child components wrapped in Suspense. Queries run in parallel on the server rather than sequentially, and each chart resolves and hydrates independently — no loading spinners, no sequential waterfalls.
// All promises kicked off at the top of the Server Component
const spendingTrendPromise = getSpendingTrend()
const budgetStatusPromise = getBudgetStatus()
const categoryBreakdownPromise = getCategoryBreakdown()
const goalsProgressPromise = getGoalsProgress()
// Passed into Suspense boundaries — each chart resolves independently
<Suspense fallback={<ChartSkeleton />}>
<SpendingTrend dataPromise={spendingTrendPromise} />
</Suspense>### Atomic Goal Funding
Funding a savings goal touches three tables simultaneously: the source bank account balance, a new transaction record, and the goal's current amount. This is wrapped in a db.transaction() call inside the Server Action so the operation succeeds or fails as a unit, regardless of what happens mid-request.
await db.transaction(async (tx) => {
await tx.update(bankAccounts).set({ balance: newBalance }).where(...)
await tx.insert(transactions).values({ ...transferRecord, goalId })
const updatedGoal = await tx.update(goals).set({ currentAmount: newAmount }).where(...).returning()
if (updatedGoal[0].currentAmount >= updatedGoal[0].targetAmount) {
await tx.update(goals).set({ status: 'completed' }).where(...)
}
})### UI and Styling
Styling is handled entirely with Tailwind CSS v4, with a dark-first default theme and neutral base tones. Typography uses Inter for the primary sans-serif and Geist Mono for monospaced contexts. The component library is built on Base UI and Radix UI primitives — accessible, unstyled behaviors wrapped into a cohesive design system that covers buttons, inputs, comboboxes, dialogs, drawers, menus, toasts, calendars, number fields, and more.
Animation runs at two levels: Framer Motion for component-level transitions and layout animations, and GSAP for complex timeline and scroll-driven sequences. @number-flow/react handles animated numeric transitions in the financial summary cards, so values update smoothly as data resolves rather than snapping abruptly.
Data visualization is powered entirely by Recharts: a spending trend bar chart across the last twelve months, budget status indicators, a category spending pie chart, and goals progress visualization.
### Forms and Validation
All form state is managed with React Hook Form, paired with Zod for schema-based validation via @hookform/resolvers. The same Zod schemas used for client-side form validation serve as the single source of truth for form shape on the server — validation logic is not duplicated.
## Project Structure
finance-os/
app/
(landing)/ # Public landing page
(dashboard)/ # Authenticated application shell
layout.tsx # Sidebar, header, progressive blur overlay
dashboard/ # Overview with summary cards and charts
transactions/ # Transaction ledger and management
budgets/ # Budget configuration and status
goals/ # Savings goals with fund allocation
api/ # Auth endpoint handlers
auth/ # Sign in, sign up, verify pages
components/
ui/ # Full design system of primitive and composed components
common/ # Sidebar, search, shared app-level components
forms/ # Transaction, goal, and budget form components
evilcharts/ # Recharts-based chart wrapper components
landing/ # Landing page components
providers/ # Theme and context providers
db/
schema/ # Drizzle ORM table definitions, one file per table
index.ts # Database client initialization
seed.ts # Development seed data script
lib/
auth.ts # Better Auth configuration
queries/ # Read-only database query functions
actions/ # Server Actions for all mutations
## Design Decisions
Server Components as the default. The entire dashboard fetches data on the server, keeping the client bundle lean and Time to First Byte low. Client components are used only where interactivity demands it — forms, animated values, chart rendering.
Database-level constraints over application-level guards. Enumerations, positivity checks, and uniqueness requirements live as CHECK constraints and UNIQUE indexes in PostgreSQL. Application validation is a convenience layer on top of what the database already guarantees.
INR as the default currency. Finance OS is built for personal use with the Indian financial context in mind. INR is the default currency on bank accounts, though the field accepts any currency string.
No API routes for mutations. Server Actions replace the entire CRUD API surface, meaning there is no parallel request/response layer to maintain for standard operations. The mutation lives as close to the database as possible.
## Challenges
Atomic multi-table mutations were the most architecturally critical challenge — particularly goal funding, where a failure halfway through could leave the bank balance deducted without a transaction record, or the transaction recorded without the goal updated. Wrapping this in a database transaction with automatic rollback was the only correct solution.
Parallel server-side data fetching required understanding how Next.js handles promise passing through Suspense boundaries. The naive approach (awaiting each query sequentially in the Server Component) would waterfall every chart load. Promise passing decouples initiation from resolution and lets the client start rendering immediately.
Real-time budget computation meant there is no precomputed "current spent" value stored in the database — budget status is always derived from aggregating actual transaction records filtered by category and period. This keeps the data model clean but required careful query optimization to avoid N+1 patterns on the dashboard.
