What this is, and how it works

A financial watchlist: sign up, search for a company, add it, and get live prices, a candlestick chart, fundamentals, news and price alerts that arrive by email. Built to be read as much as clicked through — the interesting parts are the constraints and what they forced.

Stack

Next.js 15
App Router, React server componentsServer components by default; client components only where interaction requires it — charts, forms, the search input.
TypeScript
Strict mode
Tailwind CSS v4
Styling, themed with CSS custom propertiesOne token set, two themes. Light is a purpose-built palette, not an inversion of the dark one.
lightweight-charts
TradingView's open-source charting libraryNot Recharts. Candlesticks, crosshair and time-scale behaviour are what it exists for.
Supabase
Postgres, Auth, Edge FunctionsEmail/password and Google OAuth. Schema and row-level security ship as SQL migrations, not dashboard clicks.
Twelve Data
Quotes, historical candles, symbol search
Finnhub
Company news and fundamentals
Resend
Alert emails, sent from a scheduled function
Vercel + Cloudflare
Hosting and DNSCloudflare is DNS-only on purpose — proxying in front of Vercel double-CDNs the traffic and interferes with certificate management.

Two data providers, and why

Neither free tier covers the whole surface, so the app uses both and hides that fact from everything above the data layer.

Twelve Data
800 credits/day · 8 requests/minReal-time quotes, historical OHLC for the chart, and symbol search. The 8/min ceiling is the binding constraint on the whole app.
Finnhub
60 requests/min · US marketsCompany news and fundamentals — market cap, P/E, 52-week range. Its historical-candle endpoint is paywalled and returns 403 on a free key, which is precisely why Twelve Data keeps the chart.

One interface, swappable vendors

Callers import getQuote, getCandles, getNews and friends from one module and never learn which vendor answered. Vendor-specific code is isolated in a providers directory. That seam has already earned itself once: the chart moved from Finnhub to Twelve Data when the paywall was discovered, and nothing outside the data layer changed.

The browser never talks to a provider

Every external call runs server-side, so the API keys never reach the client and responses can be cached and rate-limited centrally. The two routes the browser can call are authenticated and bounded — an open proxy to a metered API is somebody else’s free tier.

Batching, because 8/min is the real limit

A dashboard with twelve companies makes one request, not twelve: the tickers go out as a single comma-separated quote call. Chart history is billed per request rather than per bar, so a page of 750 candles costs exactly what 180 would — meaning depth is a payload decision, not a budget one.

When a provider fails

Free tiers fail routinely, so failure is a value rather than an exception. The data layer never throws; it returns either data or one of five reasons, and every surface maps those to copy that says something true.

rate_limited
429, or the daily budget is spentRecovers on its own. Raises one toast per cause, not one per card.
unavailable
5xx, timeout, network failureTheirs, not ours. Offers a retry.
not_entitled
403 — the free plan does not include it
misconfigured
401, or the key is missing entirelyOurs. Split out from not_entitled after a deployment with no key told every visitor the data sat behind a paid plan — a configuration fault presented as a product decision.
not_found
No such symbol

Stale beats empty

Quotes and news are cached in Postgres. If a refresh fails and a cached value exists, the page renders the cached number with a visible as of 14:32 badge instead of an error. A slightly old price is a far better answer than a broken card, and the badge is what keeps that honest.

Vendor internals stay on the server

Raw provider messages, status bodies and URLs are logged server-side only — some providers echo the query string, which carries the key. The browser sees mapped copy and nothing else.

Data model

Five tables. The interesting parts are the constraints — most of what could go wrong here is enforced by Postgres rather than checked in application code, because an application check races and a constraint does not.

profiles

id
uuid pk → auth.users, on delete cascade
display_name
text
created_at
timestamptz not null default now()

Created by a Postgres trigger on auth.users insert, never from the client. A client-created profile row is a row that may not exist — the trigger makes it an invariant instead of a hopeful sequence of two calls.

watchlist_items

id
uuid pk
user_id
uuid → auth.users, on delete cascade
ticker
text, upper-case + length checks
company_name
text
added_at
timestamptz not null default now()
unique
(user_id, ticker)

The uniqueness of a company per user lives in the database rather than in a check-before-insert, which races under concurrent requests. Tickers are stored upper-case and constrained to be — without that, 'aapl' and 'AAPL' are different rows and the unique constraint silently stops doing its job.

price_alerts

id
uuid pk
user_id
uuid → auth.users, on delete cascade
ticker
text, upper-case + length checks
condition
enum: above | below
threshold
numeric(20,6), check > 0
triggered_at
timestamptz
active
boolean not null default true
created_at
timestamptz not null default now()

threshold is numeric, not a float. Money compared with >= under binary floating point fires at the wrong number, and an alert that triggers a cent early is a bug a user notices immediately.

quote_cache · news_cache

ticker
text pk
quote_json / article_json
jsonb not null
fetched_at
timestamptz not null default now()

Row-level security is enabled on both with zero policies, which makes them unreachable from the browser entirely — only the server, holding the secret key, can read them. A client able to read quote_cache could enumerate every ticker the whole user base follows.

Security

The database is the boundary, not the interface. Every table holding per-user data has row-level security enabled with a policy of user_id = auth.uid(), so queries carry no ownership clause at all — Postgres decides what a session may see. A cross-user isolation suite proves it by trying.

Cache tables
RLS on, zero policiesUnreachable from the browser entirely. A client able to read the quote cache could enumerate every ticker the whole user base follows.
Auth gate
Middleware, not per-componentUnauthenticated requests never reach a dashboard route in the first place.
Post-login redirect
SanitisedAn open redirect here is a phishing primitive — it bounces a user off-site at the moment they have just typed a password.
Response headers
HSTS, X-Frame-Options, Referrer-Policy, and moreReferrer-Policy is the load-bearing one: a full-URL referrer would leak routes like /company/AAPL to linked-out news publishers, disclosing a private watchlist.
CI
Four checks on every pull requestFull-history secret scan, environment-variable guard, semgrep static analysis, dependency audit. main is protected; nothing lands without them.

Testing

Guards get tested by being given something they should catch. The JSON-LD escape is handed a real </script> breakout; the auth gate is probed with path traversal; the chart’s scroll-paging test was rewritten twice after it was caught passing against a deliberately broken build.

Unit
Error classification, caching, routing policy, structured data
End to end
Playwright, against the production bundleNot the dev server — streaming, caching and middleware behave differently enough that a passing dev-mode test says less than it appears to.
Database
Cross-user RLS isolation

Performance and loading

Perceived speed is treated as a design surface. The shell streams immediately and each unit of data suspends on its own, so one rate-limited ticker cannot hold up eleven others.

A skeleton means we have nothing, and usually we have something

Cache-first means a returning visitor’s prices are already known and paint instantly. Skeletons are for genuine cold loads, they wait before appearing so a fast response beats them entirely, and they hold a minimum once shown — a placeholder that flashes for 80ms reads as a glitch rather than as speed.

Placeholder timing, not just placeholders

A skeleton waits before appearing, so a response that beats it is never interrupted by one, and holds a minimum once shown. A placeholder that flashes for 80ms reads as a glitch rather than as speed.

The hero chart is its own loading state

The landing page draws a gold candlestick chart in over four seconds from a committed fixture rather than a live call — the highest-traffic route is the least defensible place to spend a daily budget, and it means the first thing anyone sees can never be an error. Reduced-motion gets the finished chart immediately, not a degraded one.

Emails

Three messages leave the app: a price alert when a threshold is crossed, and the signup and password-reset mails. Each one is below, rendered from the same source that sends it — so this page cannot drift from the inbox, and nothing has to be triggered to be reviewed.

They are built to email’s constraints rather than the web’s: tables for layout because Outlook renders with Word, styles inlined because Gmail drops a stylesheet, and each button doubled as VML so it survives both. Every alert also carries a plain-text alternative.

Price alert — crossed upwardTeal, ▲, and the word “risen”: direction never rests on colour alone.
Price alert — crossed downwardThe same message inverted. Both are kept because neither is an example of the other.
Confirm your emailSent on signup. The pasteable link is full-strength gold, not a dimmed variant — it is the path that has to work once a filter strips the button.
Reset your passwordSays plainly that nothing has changed yet, because the most common reader of a reset mail did not ask for one.

How it ships

Nothing reaches production by being pushed. The repository is public, so the cost of a mistake is asymmetric — a leaked key on a public repo is burned the moment it is committed, whether or not anyone noticed. The process is built around that.

1 · pre-commit
Local secret scan, before the commit existsThis is the layer that actually matters. CI tells you a key leaked; the hook stops it leaking. Catching it in CI and force-pushing still means the key is burned and has to be rotated.
2 · branch
main is protected and rejects a direct pushEnforced for administrators too, so there is no path that quietly skips the rest of this list.
3 · pull request
Four required checks, all must pass
4 · rebase merge
Linear history, no merge commitsThe history is part of what a reader is evaluating, so it is kept readable on purpose.

The four checks

Secret scan
gitleaks, full historyEvery commit ever made, not just the diff — a key introduced and later deleted is still in the history and still compromised.
Env var guard
Custom scriptRefuses tracked .env files, values committed into .env.example, and credential-shaped strings anywhere in tracked files. It was itself tested by being handed things it should catch — that method found three fail-open defects in these scripts that reading them had missed.
OWASP / static analysis
semgrep, OWASP Top 10 plus JS/TS and secrets rulesets
Dependency audit
npm audit, high severity and above

Third-party actions are pinned to commit SHAs

Not to a tag. A tag is mutable, so pinning to one means the security pipeline runs whatever that tag points at today — which is the supply-chain attack the pipeline exists to catch.

Schema changes ship as SQL migrations

Row-level security policies are committed as migrations rather than clicked into a dashboard. A policy that exists only in a hosted console cannot be reviewed, cannot be diffed, and cannot be rebuilt from the repository — and the migration history is part of the project’s account of itself.

Documentation moves in the same commit as the code

The roadmap, the README and the project’s own instructions are treated as deliverables rather than notes about them. An unticked box beside working code reads as abandoned; a ticked box beside nothing reads as dishonest. Neither is recoverable by a later cleanup commit, because the history is visible.