Overview
FX Checker is intentionally frontend-first: no database, no backend beyond Next.js server functions. Every rate comes from the public Frankfurter API (ECB reference data), and user data lives in the browser. The engineering lives in three cooperating layers.
Simple rule
No component talks to the network directly. Every rate flows through one typed server-action boundary into React Query; the store orchestrates math and persistence.
The Layers
| Layer | What it does |
|---|---|
| Static shell | Header, layout, fonts prerender at build time — first paint costs nothing. |
| Feature components | Each feature (features/CurrencyConverter/, RateHistory/, Compare/, Favorites/, Log/) owns its UI. |
| State | One Zustand store holds the pair, both amounts, the editing cursor, favorites, log, tab — partially persisted to localStorage. |
| Query engine | TanStack Query fetches every rate via five coordinate-keyed query families (staleTime 5 min, retry 2). |
| Data boundary | services/api.ts marked "use server" — typed functions compiled into server actions, cached server-side for 1 hour. |
Request flow
The Two-Way Conversion Protocol
The converter's challenge is avoiding feedback: both fields derive from the same rate, so naive recalculation would ping-pong forever. Two mechanisms cooperate:
- Typing derives the counterpart field directly on each keystroke — synchronous multiplication or division by the current rate. The field you're editing is never touched.
- Pair changes (picking a currency) go through an
editingField+derivePendingprotocol in the store: setting a base/quote marks a derivation as pending; one guarded effect converts once — in the direction ofeditingField— when the fresh rate arrives, then clears the flag. - Focusing the other field converts the current value exactly once from the other direction before flipping
editingField, so the next keystroke divides instead of multiplies.
Reusable pattern
Separating "who owns the input" (editingField) from "when re-derivation is allowed" (derivePending) applies anywhere two views derive from one value.
Cache Discipline on Two Levels
The data updates at most once per business day, yet the UI feels live:
- Server side — every upstream call carries
next: { revalidate: 3600 }. A page with ticker, pair rate, chart, and compare list reuses one cached response per URL. - Client side — 5-minute
staleTime, no refetch-on-focus churn. - Ticker deduplication — today and the previous business day arrive in one range request, split by distinct business dates present in the response (the 7-day window survives weekends and holidays).
Data Model (No Database)
The only "model" is client-side — a persisted slice of the Zustand store under localStorage key fx-currency-converter:
type FavoritePair = { base: string; quote: string };
type ConversionLogEntry = {
id: string; // crypto.randomUUID()
base: string;
quote: string;
sendAmount: number;
receiveAmount: number;
timestamp: number;
relativeTime: string; // "5m ago"
};
Plus a static 57-currency catalog (assets/data/flags.ts) with curated names and popularity groups.
Accessibility as Architecture
Roving-tabindex tablists with arrow/Home/End keys (converter tabs and chart ranges); a combobox/listbox currency picker with search autofocus, wrap-around arrow traversal, and Escape that returns focus to the trigger; four aria-live regions announcing conversions, favorite toggles, log actions, and the live pair rate; lime focus-visible rings everywhere; reduced-motion fallbacks via a dedicated hook.
Hard Problems Solved
| Problem | Root cause | Fix |
|---|---|---|
| Two-way converter fields overwrote each other in an infinite loop | Both fields derived from one rate with no ownership rule | Typing derives directly per keystroke; pair changes go through the editingField + derivePending guarded effect — exactly one derivation per change |
| Change badges showed fake 0.00% after weekends | Comparing calendar-yesterday against per-currency publication dates | One 7-day range request split by actual business dates — guaranteed real previous day |
| Copy promised 60 currencies but only 57 were convertible | Three flags mapped to legacy/non-served ISO codes | Header count rendered from the real catalog length — copy can never drift again |
| Keyboard-only use of a "simple" converter | Dropdowns and tabs needed WAI-ARIA patterns, not just focusable buttons | Combobox/listbox picker + roving-tabindex tablists + aria-live regions |
Honest Gaps
Found by auditing the code — documented, not hidden:
- Swap preserves amounts instead of recomputing — swapping EUR→USD → USD→EUR exchanges both amounts verbatim rather than re-deriving from the new rate.
- Favorites fetch is N+1 — each pinned pair triggers its own history request for day-over-day change. Fine at favorite scale; a batching candidate.
- A fresh rate arriving mid-typing doesn't re-derive the counterpart field until a pair change.
- The "1D" chart range actually fetches 7 days of daily closes — Frankfurter publishes no intraday data.
Result
An always-fresh-feeling converter that hammers neither the upstream API nor the user's attention — loop-free, keyboard-complete, and honest about its data.