Engineering

How it’s built

What is in this application, why each piece is there, and how it works. Written to be checked rather than admired — everything below is in the code today.

Balanced Money is a personal finance app holding a month-by-month record of what you earn, owe and own. That makes two things non-negotiable: the numbers have to be right, and the data has to stay yours. Most of the decisions on this page follow from those two sentences.

It is also a deliberately small stack, maintained by one person. Where a choice looks conservative — no bank connections, as few dependencies as the job allows, no analytics — that is usually the point rather than a compromise.

Architecture and stack

One Next.js application, no separate backend service. The browser talks to server components and server actions; those talk to Postgres through Prisma. Fewer moving parts means fewer places for data to leak out of.

WhatWhy it mattersHow it’s built
Rendering happens on the serverYour financial data is assembled into a page on the server and sent as finished markup. It is never handed to the browser as a queryable API that someone else could learn to call.React server components under src/app/. Pages read from the database directly in the component that renders them.
Writes go through server actions, not a public APIThere is no REST surface sitting on the internet waiting to be probed, so there is no separately-versioned API to keep secure as the app changes.Server actions colocated with each feature as actions.ts. Every action that touches your data re-establishes who you are on the server before it reads or writes a row; the only actions without a session are the ones that cannot have it yet — signing in, signing up and unsubscribing — and none of them reaches account data.
Connection poolingServerless functions start and stop constantly. Without pooling, a busy minute would open more database connections than Postgres allows and the app would start refusing requests.Two connections by design: a pooled one for everything the app does at runtime, and a direct one for migrations, which cannot run through a pooler. A single Prisma client is cached per process, so hot reloads and warm invocations reuse it instead of stacking up new pools.
As few runtime dependencies as possibleEvery package installed is code someone else wrote running next to your data, and a future security advisory somebody has to answer for. A short list is a smaller attack surface and a shorter upgrade treadmill.The production list covers the framework, the database layer, the auth client, the rate-limit store, charts, styling and validation — and stops there. A new dependency has to justify itself against the cost of maintaining it; everything else is a build-time tool that never ships to you.
Swappable infrastructure, one edit deepVendors get acquired, priced out or outgrown. Choices made now should not need a rewrite to reverse later.Anything touching a vendor hides behind one module: logging is a neutral interface with a single place that decides where lines go, and the rate limiter keeps its policy separate from its Redis transport, so the store can change without touching a call site.
Deep modules with narrow interfacesComplexity that leaks out of a module has to be understood by everyone who touches it. Kept inside, one hard thing stays one hard thing instead of spreading through the codebase.Each module exposes the smallest useful surface and hides the rest. The rate limiter is the clearest example: callers ask one question — is this request within the limit? — while the store, the hashing, the window and the failure policy all stay behind that single function.
Coding standards written down, not impliedStandards that live in someone’s head cannot be reviewed against, and are the first thing to slip when the work gets busy.The conventions are committed alongside the code: early returns over nested conditionals, self-documenting names over comments, types derived from the validation schemas and the database schema rather than hand-written beside them, and a review checklist for security-sensitive changes. Architectural decisions are recorded as dated decision records, so the reasoning survives longer than the memory of it.

Validation and type safety

Nothing reaches the database on trust. Every value crossing a boundary — a form submission, a CSV row, an environment variable — is parsed against a schema first, and the types the code is written against are derived from those same schemas rather than declared separately and hoped to match.

WhatWhy it mattersHow it’s built
Schema validation at every input boundaryA browser can send anything, whatever the form on screen allowed. Checking on the server is the only check that counts.Each feature keeps its zod schemas in its own schemas.ts, and its server actions parse against them before touching the database. A parse failure surfaces as an error instead of reaching Prisma as a half-understood write.
Configuration is validated before the server takes trafficA missing key should be an obvious failure at startup, not a confusing error that reaches one unlucky user mid-request.The environment is parsed with zod as the process boots. A malformed or absent required variable stops the boot with a message naming it.
Secrets are structurally unable to reach the browserThe most common way credentials leak is being bundled into client-side JavaScript by accident.Public and server-only configuration are separate schemas, and the server-only parse is guarded so it never runs in the browser bundle or in the edge proxy. A secret read in the wrong place fails rather than shipping.
Money is stored as exact decimalsFloating point cannot represent 0.10 exactly. Used for money it produces balances that drift by pennies and totals that do not reconcile — the one class of bug a finance app cannot have.Every monetary column is a Postgres DECIMAL fixed at two decimal places — DECIMAL(12,2) for budgeted and actual amounts, DECIMAL(14,2) for balances. Values stay decimal rather than becoming floats on the way through.

Authentication and sessions

Passwords, email confirmation and Google sign-in are handled by Supabase Auth, a dedicated identity service — not by hand-rolled hashing in this codebase. What the app adds is session lifetime, route protection, and closing the gaps a hosted service leaves open.

WhatWhy it mattersHow it’s built
Passwords are never handled by this applicationCredential storage is a solved problem that is still routinely got wrong. The safest amount of custom password code is none.Supabase Auth owns password hashing, email verification, OAuth and its own brute-force throttling. The app holds a session, never a credential.
Sessions expire: 6 hours idle, 24 hours absoluteA session that never ends is a session still valid on the laptop you left on a train. The absolute cap means even continuous use eventually asks you to sign in again.Supabase enforces these limits only on paid plans, so the app implements them: an activity cookie records session start and last request, and the proxy re-evaluates both windows on every request. You are warned 60 seconds before an idle expiry rather than losing what you were typing.
The activity cookie is deliberately unsignedWorth stating plainly rather than leaving it to look like an oversight: forging it can only extend the forger’s own session, and they already hold the token that grants it. There is nothing to gain and nothing to protect.Two timestamps, httpOnly so page scripts cannot read it, with a lifetime that outlives both windows on purpose — a cookie expiring at the idle limit would arrive looking like a fresh session and silently resurrect the one that limit exists to end.
Route protection at the edge, without an open redirectGuarding pages is the easy half. The usual bug is the convenience feature beside it: a “return here after login” parameter that will happily forward you to an attacker’s domain.The proxy redirects unauthenticated requests to sign-in with the intended path preserved, and that path is validated before use — relative destinations only, so it cannot be pointed off-site.
Sign-up cannot be used to test whether an address has an accountAn error reading “already registered” turns a public form into a tool for confirming who uses the service. Worth protecting even while the service is small.The underlying error is logged server-side for diagnostics, and every attempt lands on the same neutral “check your email” page regardless of outcome.

Abuse resistance and rate limiting

Public forms attract automated traffic: password guessing on sign-in, confirmation-email flooding on sign-up, unbounded uploads on import. Each is capped, and the caps are built so that the protection failing never becomes an outage.

WhatWhy it mattersHow it’s built
The real client IP reaches the identity serviceSupabase throttles authentication per IP — but behind a hosting platform every request appears to come from a handful of shared platform addresses, which collapses everyone’s attempts into one bucket and makes the throttle useless.The client IP is taken from the platform edge’s x-forwarded-for header — set by the platform, so not spoofable by the client — and forwarded explicitly on Supabase calls, so its per-IP limits bind to actual clients.
A second rate limiter in the application itselfDefence that depends on one vendor’s behaviour is one configuration change away from being gone. The app enforces its own ceiling regardless.Ten attempts per minute per IP on sign-in and on sign-up, counted in Upstash Redis over HTTPS — a managed store reached per request, which is the only shape of Redis that suits serverless, where holding a TCP connection open is an anti-pattern.
Rate limiting that stores no IP addressesAn IP address is personal data. Blocking abuse should not require building a record of where users connect from.The counter key holds a SHA-256 digest of the address, never the address itself, and the key self-expires after its 60-second window. Nothing about the request outlives the minute it happened in.
The limiter fails open, on purposeIf the counter store is unreachable the choice is to degrade protection or to lock every legitimate user out of their own account. For a personal finance app the first is clearly right.A missing or erroring store allows the request and records a warning, so an outage is visible in the logs rather than silent. The limiter also no-ops where it is not configured, which is why local development and CI need no Redis at all.
Import and storage are boundedA signed-in user is still an untrusted source of volume. Uncapped uploads are a cost and availability problem long before they are a security one.5,000 rows and 2MB per CSV, and a ceiling of 250,000 stored transactions per account — the per-file cap alone would have left the number of files unbounded.

Your data, and who can reach it

The app holds a month-by-month picture of your finances, which is about as sensitive as personal data gets. Two independent fences keep one account’s rows away from another’s, and everything the law calls a data right is a working button in Settings rather than an email you have to send.

WhatWhy it mattersHow it’s built
Every query is scoped to one accountThis is the boundary that does the work day to day. It is applied in the query itself, so a missing filter shows up as a visible bug in review rather than a silent cross-account read.Server-side reads and writes filter on the authenticated user id, taken from the verified session on the server — never from anything the browser supplied.
Row-level security in the database as a second fenceApplication filters protect you from bugs in the application. They do not help if something ever reaches the tables by another route. The database should refuse on its own.Postgres row-level security is enabled on every table, so the rules live with the data and not only in the code that usually reads it.
Table permissions narrowed to what a user may legitimately changeColumns like account status or last-active timestamps are the app’s business, not the account holder’s. A broad update permission would let someone rewrite their own record.A migration restricts the user table’s update grant to the self-editable columns, so the rest cannot be written through that path whatever is sent.
Export everything, as JSON, whenever you likeData you cannot get out is data you do not control. Leaving should not cost you your history.One button in Settings serialises every row belonging to you into a single JSON file.
Delete your financial data but keep the accountStarting over after a year of experimenting should not mean losing your login and preferences too.A single database transaction clears the financial rows in foreign-key-safe order, leaving your account and categories intact.
Real deletion, in the order that protects you if it fails“Delete my account” often means a flag in a column. Here the rows are actually gone — and the sequence is chosen so a failure part-way through cannot leave your financial data sitting behind a login you can no longer use.Financial rows, categories and settings go first, in one transaction; the identity at the auth provider is erased second. If that second step ever failed, the sensitive data is already gone and the leftover empty identity is logged for follow-up.
No analytics, no trackers, no third-party pixelsNobody needs a record of which parts of your budget you looked at. It also means there is no consent banner to click, because there is nothing to consent to.No measurement scripts, no advertising or retargeting tags, no session recording, no third-party embeds. The only cookies set are the ones that keep you signed in — each named and explained in the cookie policy.
No bank connections, by choiceLinking accounts is the industry norm and the single largest risk in it: somewhere has to hold credentials or long-lived tokens that can read your accounts. This app cannot be breached for access it never had.You export a CSV from your bank and import it. Duplicate detection and remembered categories are what make that a minute’s work rather than a chore.

Correctness and data integrity

A finance app is judged on whether the numbers are right and stay right. The recurring theme below is that an action either completes or does nothing at all, and that anything automatic can be inspected and undone.

WhatWhy it mattersHow it’s built
One action either fully happens or does not happen at allA multi-row edit interrupted half-way is the classic way a ledger ends up subtly wrong — and nobody notices for months.Each user gesture is one server action wrapped in a single database transaction. A failure rolls the whole thing back, so there is no half-applied state to reconcile.
Schema changes are forward-only and reviewedAd-hoc changes to a live database are how production schemas drift out of step with the code that reads them.Every schema change is a migration file committed alongside the code that needs it and applied in order. Already-applied migrations are never edited.
Deletes respect the relationships between recordsRemoving an account that transfers still point at should not be possible; removing a plan should not leave its rows orphaned.Every relation in the schema declares what a delete does — cascade where the children are meaningless on their own, restrict where a reference has to block the removal outright.
Duplicate imports are surfaced, never silently mergedImporting overlapping date ranges is normal and should not double your spending. Guessing on your behalf would be worse than asking: two identical payments on one day are sometimes genuinely two payments.A fingerprint per row — account, calendar day, signed amount and a normalised description — flags likely duplicates for you to confirm or drop. Nothing is combined automatically.
Any import can be reversedThe confidence to import comes from knowing you can undo it. A bad CSV should be a thirty-second mistake.Rows are grouped into an import batch, and the batch can be removed as a unit, restoring the ledger to exactly its previous state.
Categories are remembered without storing a modelBank descriptions repeat every month with only the reference digits changing, so your own past decisions are the best classifier available — and a better one than a generic merchant list.The memory is rebuilt from your recent categorised transactions at import time rather than saved anywhere, so it always matches what the ledger actually says, and a correction is picked up on the next import with nothing to retrain or clear.

Testing and delivery

Three layers of tests, each answering a different question, and a pipeline arranged so the database schema can never be behind the code that depends on it.

WhatWhy it mattersHow it’s built
Unit tests for the logic that decides your numbersBudget maths, date handling, amount parsing and session-expiry rules are pure logic. They should be provable in milliseconds, not by clicking around.Jest and React Testing Library across the calculation modules and the components that present them.
Integration tests against a real PostgresMost bugs in a data-heavy app live in the gap between the code and the database — constraints, transactions, delete ordering. A mocked database finds none of them, because a mock agrees with whatever the code expects.The real server actions run against a real throwaway database, with only the authentication boundary substituted. Constraint violations and rollback behaviour are exercised rather than imagined.
End-to-end tests in three real browser enginesLayout, focus behaviour and colour scheme genuinely differ between engines. Testing one of them means shipping regressions to users of the other two.Playwright drives Chromium, Firefox and WebKit through the actual journeys — sign in, import, categorise, plan — including phone-sized viewports.
No retries: a test that fails once fails the buildAutomatic retries turn a real intermittent bug into a green tick. A flaky test is information, and re-running until it passes throws that information away.Retries are set to zero. When a test proves unreliable the cause gets fixed rather than papered over.
Checks that cannot be skipped, including by the authorA rule the person who wrote it can wave through is a habit, not a guarantee.Lint, types, unit, integration and end-to-end tests run on every pull request, and a branch rule requires them with nobody on a bypass list. The same checks run locally on every push through a git hook, so CI is rarely the first place a problem is noticed.
Migrations run before the new code is liveThe classic deployment outage is code that expects a column the database does not have yet.Migrations are applied as a gated pipeline step and the deploy waits for it to succeed. Rolling back is one click.

Interface, accessibility and operations

The last group is the everyday experience: that the app is usable without a mouse, readable in either colour scheme, honest when something breaks, and observable when it does.

WhatWhy it mattersHow it’s built
Accessibility is a written standard, not a good intention“Accessible” claimed with nothing behind it is worth nothing. Written down, it can be checked — and argued with.A standards document in the repository sets the rules the interface is held to: semantic landmarks, a proper heading hierarchy, keyboard operability, and a minimum 4.5:1 contrast ratio for body text.
Keyboard and screen-reader structure is tested, not assumedAccessibility regressions are invisible to everyone who does not rely on them, which is exactly why they need a test rather than a review.End-to-end tests assert that every route exposes exactly one main landmark, that skip-to-content is the first thing a keyboard user reaches and that it actually moves focus, and that the budget and balance sheets expose real tables with named columns. They run in all three engines on purpose, because this is where engines differ most.
Light and dark, following your systemDark mode at midnight is less a preference than a comfort, and the choice should not have to be made twice.Themes are design tokens applied through styled-components, honouring prefers-color-scheme by default with an explicit override in Settings if you want one.
Motion respects the setting that says it should notAnimation can cause real physical discomfort, and the operating system already knows who it affects.The loading skeletons stop shimmering and the plan drawer stops sliding under prefers-reduced-motion — the two places in the app that animate at all.
Designed for a phone, not shrunk onto oneSpreadsheet-style grids are where responsive design usually gives up and hands you a page that scrolls sideways.The sheets pan horizontally with their labels pinned, dialogs fit small screens, and the plan editor becomes a bottom sheet — each with its own phone-viewport tests.
Honest failure statesA blank screen tells you nothing. Knowing that something broke, and that it was not your data’s fault, is the minimum.Loading skeletons while a page’s data arrives, an error boundary that explains and offers a retry, and a real not-found page rather than a crash.
Structured logging, with somewhere to go nextThe point of a log line is to shorten the gap between something going wrong and someone knowing why.Call sites use a neutral logger rather than console, emitting structured lines with errors expanded into name, message and stack. Sending them on to a monitoring service later is one function body, not a sweep through the codebase.
The reminder email carries no financial informationEmail is not a private channel. A monthly nudge should not become a monthly leak of your net worth into an inbox.Off unless you turn it on, no figures in the message — just a link to sign in — and a one-click unsubscribe that works without logging in.

Found something wrong?

This page is only useful if it is accurate. If a row overstates what the code does, that is a bug worth reporting like any other — hello@balanced.money. For what the app is for and how to use it, read the guide.