← Work

Case study

Currency Converter

Built and shipped an installable, offline-first currency converter that converts one amount into all your currencies at once, with reference rates from Frankfurter and rate history charts.

Period
January 2026 – Present
Role
Creator & Maintainer
Stack
PWAReactTypeScriptTanStack StartTanStack QueryNitroWorkboxChart.jsTailwind CSSVitestPlaywrightAPI IntegrationCI/CDHTMLCSS
Currency converter screenshots with the keypad and a rate history chart
Currency converter screenshots with the keypad and a rate history chart. Open the image to view it at full size.

The work

Currency Converter is a Progressive Web App that converts one amount into every currency on your list at the same time. It uses reference rates from the Frankfurter API and starts with KRW, THB, USD, EUR, ISK and JPY.

I started it in January 2026 with a PWA and a React Native version side by side. I kept the PWA, and in October 2026 I moved it into its own open-source repository.

How I approached it

I built the app on TanStack Start with React 19. The amount comes from an on-screen keypad or a physical keyboard, and the swap key switches back to the previously selected currency. In the currency editor you search, add, remove with undo, and reorder by drag or with the arrow keys. The list and its order stay in localStorage.

Currency editor in three states: the saved list with drag handles and remove buttons, a search for peso with add buttons, and an undo toast after removing ISK

Figure: The currency editor. Reorder or remove saved currencies, search to add new ones, and undo a removal.

The rate history sheet charts any pair of your currencies over 1W, 1M, 3M, 1Y or 5Y with Chart.js, and shows the change over the range.

Architecture

The app has three layers: Frankfurter as the source of rates, a thin server on Vercel, and the installable app on your device. The server has no database and keeps no user data. Your currencies, their order and the last rates stay on the device.

Architecture diagram in three layers: the Frankfurter API at the top, Nitro API routes and the app shell on Vercel in the middle, and the service worker, TanStack Query cache, localStorage and React UI on the device at the bottom

Figure: Data flows down from Frankfurter, through the API routes on Vercel, to the service worker, query cache and UI on the device.

Nitro server routes proxy Frankfurter, so the app never calls it directly and visitors share the server caches. The app asks for every rate in one request and does every conversion itself, so typing an amount sends no request.

Scalability

  • Work grows with currencies, not visitors. One cache key holds every rate for every visitor, and the device does the conversions.
  • History is cached per currency, not per pair. The server keeps two spans per currency: a daily year, which the device cuts into 1W, 1M, 3M and 1Y, and five years sampled weekly. The device builds any pair from two euro-based histories, so the server needs one entry per currency instead of one per pair.
  • The server is stateless. Vercel can run as many instances as traffic needs, and none of them holds anything that must survive a restart.
  • The device preloads when idle. Chart code and the history for every saved currency load while the browser is idle, so the history sheet opens at once. Devices with Save-Data on skip this.
  • Trade-off: each server instance keeps its own in-memory cache. A warm instance calls Frankfurter about once an hour per key, or once a minute while someone refreshes. At this traffic that is cheap and simple. With much more traffic, the next step is a shared cache or CDN caching in front of the API routes.

Reliability

Each layer answers from its own cache when it can, and every failure ends with rates still on screen.

Flowchart of how the app gets exchange rates: the device checks its saved rates, the server checks its cache and requests already in flight, then fetches Frankfurter, falls back to the last good value, and finally answers 503 so the device keeps its saved rates

Figure: How the app gets exchange rates, from the device cache to Frankfurter and back.

  • One upstream call for many visitors. When a cache entry expires and many visitors arrive at once, the server makes one call to Frankfurter and shares the answer.
  • Stale beats empty. If Frankfurter fails, the server returns the last good value. If it has none, it answers 503 with no-store, so no browser caches made-up rates.
  • The device never blanks out. A failed request, after a 10-second timeout and one retry, leaves the saved rates on screen. The "Updated" time comes from the query cache, which is why the service worker does not cache rates: the time always shows how old the rates are.
  • Refreshes are bounded. Pull to refresh skips the device and HTTP caches, but the server may answer with rates up to 60 seconds old. Repeated refreshes within a minute do not reach Frankfurter.
  • Offline works. The service worker serves the precached app shell, and the query cache restores rates, history and currencies from localStorage for up to seven days.
  • Deploys do not break installed apps. The rates route still answers the older request format that earlier installed versions send.

Delivery

Vercel deploys every push to main, and a GitHub Actions workflow posts each build and deployment to Discord. Vitest covers the conversion, cache, persistence and rate history logic, and Playwright covers the converter, offline rates, the service worker and rate history in a real browser.

Results

  • Live, installable app at currency.domenurh.com
  • Works offline with the last saved rates and history
  • 107 Vitest unit tests and 48 Playwright browser tests
  • Open source under GPL-2.0

UI history

How the UI changed

5 versions, from the first working build to today.

Fig. 01 · 30 January 2026Full size ↗