← The Journal
Product

How NomadAtlas Is Built: Architecture of a Zero-Backend Travel App

A deep dive into the architecture of NomadAtlas — a React 19 SPA served from a Cloudflare Worker with no backend server, 14+ custom hooks, and 99% test coverage.

The NomadAtlas Team · · 14 min read

NomadAtlas is a single-page React 19 app that travels with you — pulling live location, weather, FX rates, and news through a Cloudflare Worker edge proxy, with a local-first expense ledger that stays usable offline. No traditional backend. No database server. No infra you have to keep alive at 3 AM. This post maps how the moving parts fit together.

74
Tests
passing
99%
Coverage
statements
5
API endpoints
proxied
14+
Custom hooks
domain-scoped
60+
Components
in production
0
Backend servers
to maintain

The tech stack

The app is a Vite 8-bundled React 19 SPA written in strict TypeScript. Static assets are served by a Cloudflare Worker that doubles as an API proxy — no CORS, no server. Client routing via React Router 7, layout via Bootstrap 5, testing via Vitest 4 + Testing Library, and a hand-rolled design system called Aurora.

  • React 19 — concurrent rendering, strict lint rules enforced
  • TypeScript ~5.9 — strict mode, no implicit any
  • Vite 8 — dev server + production bundler with manual code-splitting
  • Cloudflare Workers — edge proxy for /api/*, static asset serving, SEO rewrites
  • Vitest 4 — unit + hook tests with jsdom, fake timers, 99% coverage
  • ESLint 9 — react-hooks/purity, set-state-in-effect, refs rules enforced

Runtime architecture

Every request the browser makes goes through a same-origin /api/* path. In dev, Vite proxies it; in prod, the Cloudflare Worker forwards it to api.nomadatlas.dev. Same-origin keeps CORS out of the picture and gives a single throttling/caching layer at the edge.

The Worker handles two jobs: (1) route /api/* to the upstream, attaching Cache-Control: public, max-age=60; (2) serve everything else as static assets. For SEO paths (sitemap.xml, RSS, prerendered blog HTML) it rewrites hardcoded build-time origins to match the actual request host — fixing the classic www-vs-apex mismatch that Google penalizes.

Data flow — hooks compose, no global store

There is no Redux, no Zustand, no global state manager. Each domain — location, weather, FX, news, expenses — has a dedicated hook that owns its fetch, cache, and state. App.tsx wires hooks together and passes plain props down. Hooks compose naturally: weather depends on coordinates, FX depends on country code, news depends on country code.

  • useGetCoordinates — wraps the browser Geolocation API, surfaces a locationDenied flag
  • useGetLocation — reverse-geocodes lat/lng → city, country, countryCode
  • useGetWeather + useGetAQI — fetches conditions from coords
  • useExpenses — CRUD operations on the local expense ledger (localStorage)
  • useConvertRate — fetches live cross-rate from /api/convert, debounced 200ms, falls back to a static USD-pivot table
  • useSupportedCurrencies — fetches and caches the list of API-supported currencies once per session
  • useBudget — remaining = set budget − total spent this month in USD
  • useDarkMode + useTheme — manual sun/moon toggle, persisted in localStorage, class applied before first paint so reloads don't flash the wrong palette

The question isn't 'which state manager?' — it's 'do you even need one?' At this scale, composing hooks + lifting state is simpler, faster, and produces code that reads top-to-bottom.

Authentication — useAuth & the session cache

Auth is handled by a single custom hook — useAuth in src/hooks/useAuth.ts — that owns signup, login, logout, and session restore. There is no React Context and no global store. Instead the hook uses module-level variables to share a validated session across component mounts.

Here is the full token lifecycle, step by step:

  1. Login / SignupPOST /api/auth/login (or /signup) sends { username, password }. On success the API returns { token, id }. The token is saved to localStorage under the key "auth_token", and a custom AUTH_TOKEN_CHANGED_EVENT is dispatched on window so other hooks (like useWebSocket) react in the same tab instantly.
  2. Session restore (mount) — When useAuth mounts, it reads localStorage. If a token exists it calls GET /api/auth/me with an Authorization: Token <token> header. A 200 populates the user state; any other response clears the stale token silently (no error shown to the user).
  3. Module-level session cache — Once /me succeeds, the validated user and token are stored in two module-scope variables: sessionUser and sessionToken. On subsequent remounts (e.g. navigating /passion → /), the hook's useState initializer checks the cache before rendering. If the stored token matches sessionToken, the user is restored synchronously — no loading spinner, no network call, no risk of a transient error clearing the token.
  4. Logoutlogout() is optimistic: it removes the token from localStorage, clears the session cache (sessionUser = null, sessionToken = null), and resets React state before firing POST /api/auth/logout. The server call is fire-and-forget — if it fails, the user is already signed out locally.

The auth UI lives in AuthModal.tsx — a tabbed login/signup modal that closes on success. When authenticated, the header icon switches from outline to solid and the modal shows a logout button instead. Expense sync (src/utils/Sync.ts) reads the token from localStorage directly; if no token is present the sync is skipped with reason: "no_auth".

Key files: src/hooks/useAuth.ts (hook + cache + token helpers), src/components/AuthModal.tsx (login/signup/logout UI), src/components/header/HeaderActions.tsx (calls useAuth, renders AuthModal), src/constants/Events.ts (AUTH_TOKEN_CHANGED_EVENT constant), src/utils/Sync.ts (auth-gated expense sync).

TOKEN LIFECYCLE — useAuth 1 · LOGIN / SIGNUP POST /api/auth/login → { token, id } 2 · PERSIST localStorage.setItem "auth_token" → JWT 3 · BROADCAST AUTH_TOKEN_CHANGED → useWebSocket reacts 4 · CACHE sessionUser sessionToken ON REMOUNT (e.g. /passion → /) CACHE HIT? sessionToken === storedToken → restore user instantly ✓ CACHE MISS GET /api/auth/me validate + populate cache or LOGOUT (optimistic) clear localStorage sessionUser = sessionToken = null POST /api/auth/logout
The full token lifecycle — from login through the module-level session cache to optimistic logout.

Real-time — useWebSocket

NomadAtlas maintains a persistent WebSocket connection to the backend whenever the user is authenticated. The hook useWebSocket (src/hooks/useWebSocket.ts) manages the entire lifecycle: connecting, heartbeating, reconnecting, and tearing down — all driven by the auth token.

Here is how a connection is established and maintained:

  1. Token detecteduseWebSocket picks up the token from localStorage. The effect fires and opens new WebSocket(wss://api.nomadatlas.dev/ws/<hex-token>/).
  2. Server accepts — the backend (Django Channels) validates the token in the URL path. If valid, the connection is upgraded and messages can flow.
  3. Heartbeat loop — the client sends a { type: "ping" } every 30 seconds. Any message from the server (including pong or heartbeat) resets a 45-second staleness timer. If nothing arrives within 45s, the connection is marked dead.
  4. Server push — the backend pushes JSON messages like { type: "expenses_synced", created: 3, skipped: 1 }. The messageToToast helper maps each message type to a user-facing toast (title, body, variant). Unknown types get a generic info toast.
  5. Auto-reconnect — if the socket closes unexpectedly (code ≠ 1000), the hook retries with exponential backoff: 2s → 4s → 8s → … capped at 30s. The retry counter resets on every successful onopen.
  6. Logout teardown — when the token is cleared (by useAuth dispatching AUTH_TOKEN_CHANGED_EVENT), the effect cleanup runs: ws.close(), all timers cleared, isAlive set to false.

Connection status in the UI

The hook exposes a wsStatus field with three possible values: `"live"` (socket open, heartbeat healthy — green dot in the ContextStrip), `"disconnected"` (authenticated but socket is down — amber dot), and `"local"` (not logged in — grey dot). The ContextStrip component renders a small status pill from this value so you always know the connection state at a glance.

Message types

  • `expenses_synced` — after the backend processes a batch sync it pushes the count of created, skipped, and errored records. Rendered as a success or warning toast.
  • `pong` / `heartbeat` — keep-alive frames. Silently consumed (no toast), only used to reset the staleness timer.
  • Everything else — falls through to a generic info toast showing the raw message field or the stringified payload.

Key files: src/hooks/useWebSocket.ts (connection lifecycle, heartbeat, reconnect, toast mapping), src/components/WsToasts.tsx (renders toasts from the hook), src/components/ContextStrip.tsx (status pill), src/constants/Api.ts (WS_BASE URL), src/constants/Events.ts (AUTH_TOKEN_CHANGED_EVENT).

WEBSOCKET — useWebSocket LIFECYCLE BROWSER BACKEND (Django Channels) 1 · AUTH_TOKEN_CHANGED fires useWebSocket detects token → opens connection 2 · new WebSocket(wss://…/ws/<token>/) upgrade validate token → accept 3 · HEARTBEAT LOOP ping every 30s — reset 45s staleness timer pong / heartbeat server heartbeat every 30s ping → ← pong 4 · SERVER PUSH { type: "expenses_synced", created, skipped } messageToToast → UI toast WsToasts component renders it 5 · RECONNECT on drop exponential backoff: 2s → 4s → 8s → … → 30s max 6 · LOGOUT → token removed WS effect cleanup: close socket live — WS active disconnected — auth'd, no WS local — unauthenticated
WebSocket connection lifecycle — from token detection through heartbeat to auto-reconnect and logout teardown.

The API surface

Five endpoints, all proxied same-origin. Both the Vite dev server and the Cloudflare Worker rewrite these paths to https://api.nomadatlas.dev:

  1. /api/rate?country=XX — single USD→local rate for a country
  2. /api/rates — bulk rates table (powers CBfx dashboard)
  3. /api/convert?from=X&to=Y&amount=1 — live cross-rate for any pair (powers QuickConvert)
  4. /api/supported — list of currencies the convert API supports
  5. /api/news/:countryCode — localized news headlines

Multi-layer caching

Performance is built from three independent cache layers, each chosen for a different reason:

Bundle strategy

Vite's manualChunks configuration isolates heavy dependencies. Routes are code-split with React.lazy():

  • vendor-flags — country-flag-icons (huge, only used by /locations)
  • vendor-icons-pi — Phosphor icons pack
  • vendor-react — react + react-dom
  • vendor-bootstrap — react-bootstrap + bootstrap
  • Lazy routes/cbfx, /locations, /blog, /blog/:slug, /architecture, /passion are all code-split

Testing philosophy

Every pure utility function and every custom hook has test coverage. The suite runs in under 2 seconds with 74 tests across 5 test files. Coverage sits at 99% statements, 100% functions, 100% lines.

  • Hook tests use renderHook + vi.useFakeTimers() to deterministically test debounce, cache TTL, and error handling
  • Utils are tested with fixtures covering edge cases (empty arrays, invalid data, boundary months)
  • ESLint enforces React's purity rules — no impure calls in render, no synchronous setState in effects, no ref reads during render. The entire codebase passes.

Build & deploy — one command

npm run deploy runs four steps in sequence: tsc -b (type-check) → vite build (bundle + split) → tsx scripts/prerender-blog.ts (generates static HTML for each blog post) → wrangler deploy (uploads Worker + assets to Cloudflare's edge). Zero-downtime, globally distributed in ~15 seconds.

Repository layout

  • src/main.tsx — router + theme bootstrap
  • src/App.tsx — dashboard composition (hooks → components → layout)
  • src/worker.ts — Cloudflare Worker proxy + SEO origin rewrites
  • src/components/ — 60+ presentational + container components
  • src/hooks/ — domain hooks (data, theme, pagination, user prefs)
  • src/utils/ — pure functions: budget, expenses, dates, currencies
  • src/constants/ — ISO currency tables, API base, categories, weather codes
  • src/types/ — shared TypeScript interfaces
  • src/themes/ — Aurora theme CSS variables
  • src/blog/ — journal posts + renderer + prerender script
  • src/test/ — fixtures + test setup

Design system — Aurora

The visual language is built from a small set of CSS custom properties and two typefaces: Space Grotesk for numbers, labels, and monospace contexts; Manrope for body text and UI copy. Dark mode is a class-toggle on <html> — no React context required, no flash of unstyled content.

  • --accent #3b82f6 — primary blue
  • Purple #a78bfa — gradient pair with the accent
  • --text-primary #1e293b (slate-800) / white in dark mode
  • --border-light #e2e8f0 — subtle separators
  • Radius tokens: --radius-sm 8px, --radius-md 12px, --radius-lg 16px
  • Shadows scale from --shadow-sm to --shadow-xl for elevation
See it live
The architecture page doubles as a standalone visual reference with inline SVG diagrams of the runtime, data flow, routing, and build pipeline.
Open the Architecture page →