Atlas

Personal finance dashboard

Atlas is a personal finance dashboard I built to replace the 4+ years of Google Sheet I was tracking my finances in. It tracks four kinds of asset
Access is invite-only: a whitelist gates both sign-up and every subsequent login.

2400+ Transactions tracked
3 years Of history
4 Asset types
5 weeks To first release
89 Server actions
10 Dashboard sections
7 Chart types

Highlights

One net worth, four asset types

No total is ever stored — each slice is queried from the rows behind it, and opens into what it's made of.

Cash
balance per account
ETFs
held × latest quote
Crypto
priced the same way
Inventory
unsold items only
A slice opens into its own rows — accounts by name, holdings by ticker, inventory by tag — reusing the query that produced it, so the parts always add back up to the whole.

A year of money as one diagram

A full year as a Sankey diagram, with the same numbers readable as a table underneath.

Atlas cashflow Sankey diagram for a full year

Income on the left, then expense, business and investments, each opening into its categories. Whatever is left lands in savings; a shortfall shows as a funding gap.

Transfers are left out — moving money between your own accounts isn't cashflow.

Portfolio priced from live quotes

Buys and sells are the only stored truth; everything else is derived from them.

By holding
  • - Quantity held across every broker
  • - Average price paid
  • - Profit, realized and not
By account
  • - What sits in one broker
  • - Cash left uninvested
  • - Allocation across brokers
The value chart is replayed from the trades against each holding's own price history, so it stays right no matter when a trade was entered.

A demo anyone can open

The product can be shown in full without exposing my own numbers.

Server decides
  • - Every write checks the account first
  • - The demo one is refused
  • - Feedback stays open on purpose
Interface explains
  • - Clicks are caught before they fire
  • - A toast says why
  • - The attempt is recorded
The credentials are public on purpose — the account is disposable and re-seeded by a script that never touches real users.

Architecture

A single Next.js deployment on Vercel, where server actions replace the REST layer entirely and everything lives in one Postgres database.

Next.js Frontend App Router · React Query · Tailwind CSS
Better Auth Google OAuth · Invite whitelist
Server Actions Typed mutations · Zod validation · No REST layer
PostgreSQL Supabase · Drizzle ORM · Pooled connections
Supabase Storage Brand icons · Inventory images
External APIs Yahoo Finance · Google APIs

Key Challenges

Balances that stay right when history arrives late

Backend

I add old transactions months after the fact, and the balance chart still has to be correct.

Approach

Each account gets a day-by-day running total, and the whole curve is then shifted so it passes through the balance I know the account started at. Shifting instead of cutting off at that date is what makes late entries safe: a transaction dated before it was already inside the starting balance, so it gets subtracted back out rather than counted twice.

Edge cases
  • ·An account with no known starting point is left unshifted, which is just a plain running total.
  • ·Quiet days still produce a point, so the chart has no gaps to guess across.
  • ·The current balance is worked out a different way from the same rows, and the two have to agree on today.

One transfer, two accounts

Backend

Moving money between my own accounts changes two balances, but every query assumes a row belongs to one.

Approach

A transfer is two rows sharing a group id, with the outgoing leg stored as a negative amount. Balance queries can then treat it exactly like income and the negative leg subtracts itself, so there is no special case anywhere. The two legs carry their own amounts rather than mirrored ones, so a wire fee or a conversion still leaves both accounts at their real balance.

Edge cases
  • ·Filtering by one account returns one leg, so the list fetches its companion and still shows the transfer as one thing.
  • ·Counting transactions collapses the pair, otherwise every transfer would count twice.
  • ·Editing one runs as a single database transaction, so a failure can't change half a transfer.

A photo link the server has to open

Backend

Pasting an image URL asks my server to go fetch it, and my server can reach things the internet can't.

Approach

The address is checked after it resolves, not just the hostname, and anything internal — private ranges, loopback, cloud metadata — is refused before the request leaves. Redirects are followed one hop at a time and re-checked each time, since an allowed host can still point somewhere private.

Edge cases
  • ·The download is cut off mid-stream at 5 MB rather than fetched and then rejected.
  • ·Everything accepted is re-encoded, which also drops whatever metadata the original carried.
  • ·If the database row fails to save afterwards, the file is deleted so nothing is left orphaned.

Evolution

Replaced a hand-maintained Google Sheet with a standalone product.

The original Google Sheet, cashflow tab with months as columns The same year rendered in Atlas Google Sheets Atlas

Google Sheets

Months as columns, a Cashflow tab of Type→Category rows, dates split across Month/Day columns with the year implicit, and no year-over-year view.

Atlas

One single place to track all personal (and business) finances, where balances, net worth, cashflow and portfolio value are all derived at read time.

Complete Stack

Frontend
Next.jsReactTypeScriptTailwind CSSShadcn UITanStack QueryRecharts
Next.jsReactTailwind CSS
Backend
Server ActionsPostgreSQLDrizzle ORMZodBetter AuthYahoo Finance APIGoogle APIs
Node.jsTypeScriptPostgreSQL
Infrastructure
VercelSupabaseSupabase StoragePostHogVercel Analytics
VercelSupabase