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.
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.
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
locationDeniedflag - 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:
- Login / Signup —
POST /api/auth/login(or/signup) sends{ username, password }. On success the API returns{ token, id }. The token is saved tolocalStorageunder the key"auth_token", and a customAUTH_TOKEN_CHANGED_EVENTis dispatched onwindowso other hooks (likeuseWebSocket) react in the same tab instantly. - Session restore (mount) — When
useAuthmounts, it readslocalStorage. If a token exists it callsGET /api/auth/mewith anAuthorization: Token <token>header. A 200 populates theuserstate; any other response clears the stale token silently (no error shown to the user). - Module-level session cache — Once
/mesucceeds, the validateduserandtokenare stored in two module-scope variables:sessionUserandsessionToken. On subsequent remounts (e.g. navigating/passion → /), the hook'suseStateinitializer checks the cache before rendering. If the stored token matchessessionToken, the user is restored synchronously — no loading spinner, no network call, no risk of a transient error clearing the token. - Logout —
logout()is optimistic: it removes the token fromlocalStorage, clears the session cache (sessionUser = null,sessionToken = null), and resets React state before firingPOST /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).
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:
- Token detected —
useWebSocketpicks up the token fromlocalStorage. The effect fires and opensnew WebSocket(wss://api.nomadatlas.dev/ws/<hex-token>/). - Server accepts — the backend (Django Channels) validates the token in the URL path. If valid, the connection is upgraded and messages can flow.
- Heartbeat loop — the client sends a
{ type: "ping" }every 30 seconds. Any message from the server (includingpongorheartbeat) resets a 45-second staleness timer. If nothing arrives within 45s, the connection is marked dead. - Server push — the backend pushes JSON messages like
{ type: "expenses_synced", created: 3, skipped: 1 }. ThemessageToToasthelper maps each message type to a user-facing toast (title, body, variant). Unknown types get a generic info toast. - 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. - Logout teardown — when the token is cleared (by
useAuthdispatchingAUTH_TOKEN_CHANGED_EVENT), the effect cleanup runs:ws.close(), all timers cleared,isAliveset tofalse.
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
messagefield 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).
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:
/api/rate?country=XX— single USD→local rate for a country/api/rates— bulk rates table (powers CBfx dashboard)/api/convert?from=X&to=Y&amount=1— live cross-rate for any pair (powers QuickConvert)/api/supported— list of currencies the convert API supports/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,/passionare 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 bootstrapsrc/App.tsx— dashboard composition (hooks → components → layout)src/worker.ts— Cloudflare Worker proxy + SEO origin rewritessrc/components/— 60+ presentational + container componentssrc/hooks/— domain hooks (data, theme, pagination, user prefs)src/utils/— pure functions: budget, expenses, dates, currenciessrc/constants/— ISO currency tables, API base, categories, weather codessrc/types/— shared TypeScript interfacessrc/themes/— Aurora theme CSS variablessrc/blog/— journal posts + renderer + prerender scriptsrc/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-sm8px,--radius-md12px,--radius-lg16px - Shadows scale from
--shadow-smto--shadow-xlfor elevation