Personal finance aggregator: Building the frontend: React, routing, and a design system - Part 5
~10 min read
For a while, the frontend was one component: log in, see a table of transactions, link a bank from a text input. That was fine for checking the backend actually worked, but it wasn't a tool I wanted to open every day. This post covers turning that single page into a proper multi-page app - Home, Accounts, Transactions, Settings - with real navigation and a design system underneath it, instead of default browser styling on a scattering of <div>s. It's the work of one commit (Add routed pages (Home, Accounts, Transactions, Settings) with a new design system), with a couple of details that only landed in the commits right after it flagged as such below - most importantly, the Home page's numbers at this point were hardcoded demo values.
Why routing first
The single-page version had grown a login gate, a bank-linking form, a 2FA settings block, and a transactions table all stacked vertically on one screen. Every feature added made that page longer and the mental model fuzzier - "where do I go to link a bank" was a scroll position, not a place. Before touching any visual design, the first change was structural: react-router-dom, four real routes, and a persistent nav bar.
<BrowserRouter>
<NavBar user={user} onLogout={handleLogout} />
<main className="app-main">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/settings" element={<Settings user={user} onLogout={handleLogout} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</BrowserRouter>
Each page fetches only what it needs - Transactions doesn't fetch provider metadata, Accounts doesn't fetch transaction summaries - which sounds obvious in hindsight but wasn't true of the single-page version, where everything loaded on mount whether the page ended up using it or not. Splitting by route made "what data does this screen actually need" a question with an obvious answer for the first time.
A design system means picking defaults once
"Design system" is a big phrase for what this actually was: a handful of CSS custom properties, defined once, referenced everywhere, so that "what colour is a card border" or "how much padding does a button get" was a decision made a single time instead of re-litigated in every component.
:root {
--surface: #fcfcfb;
--surface-page: #f9f9f7;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--border: rgba(11, 11, 11, 0.1);
--accent: #2a78d6;
--success: #006300;
--danger: #d03b3b;
--radius: 10px;
--shadow: 0 1px 2px rgba(11, 11, 11, 0.06), 0 1px 3px rgba(11, 11, 11, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #1a1a19;
--surface-page: #0d0d0d;
--text-primary: #ffffff;
/* ...matching dark values for every token above */
}
}
Every card, button, and input references these tokens rather than a literal colour, so dark mode is a second @media block defining the same token names against a dark surface - not a parallel set of dark-mode class names sprinkled through every component. prefers-color-scheme picks it up automatically from the OS; I've never had to add a manual toggle, and haven't needed one for a single-user tool I mostly look at on my own devices.
A .card class, a .btn/.btn-primary/.btn-danger set, and a handful of layout primitives (.stat-grid, .account-list, .modal-backdrop) cover essentially the entire visual surface of the app. New features almost never need new CSS from scratch - the unlink confirmation modal added later reused the existing .card shadow and border-radius tokens and just needed a backdrop and a couple of layout rules on top.
Choosing chart colours properly, not by eye
The one place this app does anything chart-like is the "spending by category" breakdown on the Home page - a horizontal bar per category. It'd be easy to grab five colours that look nice together and move on, but colour choice for data visualisation has a right-and-wrong dimension that's easy to get wrong without checking: some colour pairs are genuinely hard to tell apart for colourblind readers, and it's not something you can reliably eyeball.
I used a validated categorical palette - eight hues, ordered specifically so that adjacent colours in that order clear a minimum perceptual distance under colour-vision-deficiency simulation, not just to normal vision:
--series-1: #2a78d6; /* blue */
--series-2: #eb6834; /* orange */
--series-3: #1baf7a; /* aqua */
--series-4: #eda100; /* yellow */
--series-5: #e87ba4; /* magenta */
For five categories that's comfortably inside the "safe to distinguish" range for this ordering. (At this commit those five categories were a SPEND_BY_CATEGORY constant in Home.tsx, under a "Demo data shown below" banner - the chart was real, the numbers weren't. It stayed that way until categorisation gave it something to chart, at which point the palette had to grow past five hues.) Each bar also gets a direct text label (Bills, Groceries, ...) rather than relying on a separate colour-key legend - with only five categories and each one labelled right next to its bar, a legend would just be duplicating information already on screen.
Testing components without a browser
Every page and most components got a Vitest + Testing Library test alongside it, and the pattern that made this worth doing rather than skipping was keeping the fetch mocking cheap. A typical test stubs global.fetch to return canned JSON for whatever endpoint the component under test calls, renders it, and asserts on what a user would actually see - text on screen, a button's disabled state, an option in a dropdown - rather than reaching into component internals:
function stubFetch(summary: unknown, accounts: unknown = []) {
vi.stubGlobal('fetch', vi.fn(async (input) => {
const url = input.toString()
const body = url.includes('/accounts') ? accounts : summary
return { ok: true, json: async () => body } as Response
}))
}
That one helper, varied slightly per test file, covered loading states (never resolve the promise, assert on a placeholder), success states (resolve with realistic data, assert on the rendered numbers), and error states (resolve with ok: false, assert an error message appears) without needing a real backend running or a heavier end-to-end test tool. The delta-polarity logic mentioned below got exactly this treatment once the tiles showed real numbers (Home.test.tsx arrived shortly after): a test asserting spending-up renders red and income-up renders green, which is the kind of thing that's easy to silently invert while refactoring and would otherwise only get noticed by happening to have both numbers move in the same direction on the same day.
Stat tiles, and getting "good" and "bad" the right way round
The Home page's headline numbers - total balance, income this month, spending this month, accounts linked - are stat tiles: a label, a value, and optionally a delta against last month with an up or down arrow. The subtlety here isn't the visual design, it's that "up" doesn't always mean "good." Income going up is good, shown in green. Spending going up is bad - it should be red, even though the number is also going up.
const isGood = stat.invertDelta ? stat.delta <= 0 : stat.delta >= 0
I'll be honest that the first version of this got it wrong: in the first version the demo tiles coloured any positive delta green and any negative one red (stat.delta >= 0 ? 'is-up' : 'is-down'), which made the demo's falling spending look bad. The invertDelta flag arrived in a follow-up commit, when the tiles started showing real numbers and the mistake would have started to matter.
invertDelta flips which direction of change reads as good for a given stat, decoupling the arrow's direction (which always matches the actual sign of the change) from the colour (which reflects whether that change is welcome). Missing this is an easy mistake to make and an easy one to not notice you've made - a dashboard that shows increased spending in the same reassuring green as increased income will look fine and just be quietly wrong every time you glance at it.
The login screen is the one page that had to feel trustworthy
Every other page in the app assumes you're already in. The login screen is the one place a first impression is actually doing work - it's the first thing anyone sees, including me, every single day - and it's the one page where "looks like a real product, not a half-finished side project" felt like it mattered rather than being pure vanity. That mostly came down to restraint rather than extra effort: a centred card on a plain background, the same --surface/--border/--shadow tokens every other card in the app uses (so it doesn't look like a different app bolted onto the front of this one), one clear call-to-action button, and inline validation errors that use the same --danger red as everywhere else rather than a generic browser alert. None of that is a special "login page" design system - it's the same tokens from the rest of the app, applied to a page with dramatically less on it, which turned out to be the entire trick. A login screen doesn't need more visual polish than the rest of the app; it needs the same polish, with nothing else competing for attention on the page.
(That screen lasted about a day. A later commit deleted LoginForm when authentication moved to a separate service - see post 9. The design lesson held; the page didn't survive.)
What stayed simple on purpose
A few things I deliberately didn't reach for:
- No component library. No Material UI, no Chakra, no Tailwind. Plain CSS with custom properties covers everything this app needs, and there's no dependency to keep updated for styling alone.
- No client-side state management library. Every page fetches its own data with
useEffectandfetch, holds it inuseState, and that's the entire state story. Nothing here needs Redux or a query cache - the data volumes are small (one user, a handful of accounts) and most pages only need their own data once per visit. - No chart library. The one chart is bars built from
<div>s withwidthpercentages. Pulling in a charting library for one component would have been more dependency than the feature warranted. (That held until the Home page needed balances plotted over time: I addedrechartsfor the line charts, and left the category bars as<div>s. See post 13.)
None of that is a permanent stance - if this grows more charts or more cross-page shared state, those defaults would be worth revisiting. But "plain React, plain CSS, fetch-on-mount" turned out to cover a genuinely useful multi-page personal finance app without needing any of it, which was worth confirming before reaching for heavier tools by default.
Routing and styling got the app somewhere pleasant to look at. What it didn't have yet was a login screen worth trusting with real bank data, or any way to turn on two-factor authentication - that's next.
Previous: ← From script to service · Next: Auth, two-factor, and account settings →
