--- title: React SPA type: entity created: 2026-04-30 updated: 2026-05-02 sources: [gps-tracking-architecture, traccar-maps-architecture] tags: [service, presentation-plane, frontend] --- # React SPA The end-user experience for operators and external participants. Single React application, role-based views, served as a static bundle. ## Why a separate SPA (not just the Directus admin UI) The Directus admin UI is generic CRUD over collections — right for back-office editing and operators who think in records, wrong for end users who think in domain concepts. A dedicated SPA delivers: - **Domain-shaped UX** — screens organized around the user's mental model. - **Independent deployment** — front-end ships on its own cadence. - **Targeted access control** — public/partner-facing routes without exposing the admin surface. - **Mobile/offline tuning** — bundles tuned for the actual user environment. ## Single app, role-based views One application serves multiple user types via role-based routing and conditional UI. All users authenticate through [[directus]]; the SPA receives a JWT, reads role, and renders appropriate navigation/screens. Splitting into multiple apps is only justified when user populations are genuinely disjoint (public site vs. authenticated console) or when bundle size for one audience harms another. ## Data access pattern The SPA talks to **two endpoints**, one per plane (see [[live-channel-architecture]]): - **[[directus]]** — REST/GraphQL via `@directus/sdk`, plus Directus's WebSocket for business-plane events. Auth, schema, all CRUD on entries / vehicles / devices / geofences / etc. - **[[processor]]** — its own WebSocket endpoint, exclusively for the live position firehose. Authenticated by the same Directus-issued credential the SPA already holds; authorization delegated to Directus once at subscribe time. **Never** talks to [[tcp-ingestion]], [[redis-streams]], or [[postgres-timescaledb]] directly. The two-endpoint split exists because Directus's WebSocket subscriptions only fire for writes through its own `ItemsService` — Processor's direct-to-DB position writes are invisible to it. See [[live-channel-architecture]] for why this is the architecturally honest answer rather than a workaround. ## Stack - **Vite + React + TypeScript** — SPA build, no SSR. - **TanStack Router** — file-based, type-safe routes. - **TanStack Query** — Directus REST: caching, invalidation, refetch on focus. - **`@directus/sdk`** — typed access for REST + Directus's WebSocket. - **MapLibre GL JS** — WebGL map renderer. Used **raw**, not via `react-map-gl` — the declarative wrapper fights the imperative `setData` pattern that's the whole point of the architecture (see [[maps-architecture]]). - **`maplibre-google-maps`** *(optional, runtime-config-gated)* — protocol adapter that lets MapLibre consume Google's official Map Tiles API when an operator-provided API key is present. - **`@mapbox/mapbox-gl-draw`** — geofence editor (polygon / line / trash modes) wrapped to look native to MapLibre. - **Zustand** — high-frequency live state (positions, trails, selection). Granular subscribers; chosen over Redux specifically because Redux's dispatch + selector cascade is the most likely cause of the lag observed in production [[traccar-maps-architecture]] deployments. - **shadcn/ui + Tailwind** — UI primitives. - **react-hook-form + Zod** — forms and validation. Covers form-heavy admin screens and real-time map dashboards without architectural changes between them. ## Auth pattern Same-domain session cookie via reverse proxy. One origin serves the SPA, Directus, and Processor's WebSocket endpoint — Vite's `server.proxy` in dev, Traefik (or whatever fronts the deploy stack) in stage/prod. - Login uses the Directus SDK in **session mode** (`authentication('session', { credentials: 'include' })`). Directus issues an `httpOnly`/`Secure`/`SameSite=Lax` session cookie; the cookie itself carries the session — there is no separate in-memory access token to manage and no `/auth/refresh` dance. - Reload survives cleanly: the browser still has the cookie, `/users/me` returns the user without any client-side state. - No JavaScript ever reads or writes the cookie (it's `httpOnly`), so XSS cannot exfiltrate it. - WebSocket handshake: same-origin means the browser sends the session cookie automatically with the upgrade request. Processor reads it on the upgrade, validates against Directus's `/users/me`, and uses the resulting user identity for subscription authorization. See [[live-channel-architecture]] and [[processor-ws-contract]]. This requires the proxy to serve everything under one origin (path-based or single subdomain) — separate subdomains break cookie flow. **Mode choice context.** Directus's SDK also supports `'cookie'` mode (refresh cookie + in-memory access token). It works while the SDK is alive in memory but doesn't survive a hard reload cleanly because there's no access token to retry `/users/me` against, and the refresh-then-read sequence is order-sensitive. `'session'` mode collapses that to one credential — the session cookie — and is the right default for an SPA that wants reload-survives behaviour. ## Real-time rendering The full pattern lives at [[maps-architecture]]; the headlines: - **MapLibre is a singleton** held in a module-level variable, attached to a detached `
` that React refs mount/unmount per page. WebGL context survives navigation. - **Two GeoJSON sources** for live positions: clustered non-selected, unclustered always-on-top selected. Updates flow through `setData`, not DOM marker manipulation. - **rAF coalescer at the WS boundary.** Incoming position messages buffer per-device; one `requestAnimationFrame` tick flushes the latest snapshot to the Zustand store. Without this, per-message dispatches cascade through selectors and `setData` at every position arrival — the failure mode [[traccar-maps-architecture]] exhibits. - **Per-device bounded ring buffers** for trail history. Default 200 points per device, configurable. The throttle controls visual cadence; trails are never lossy. - **High-frequency tabular updates** (live leaderboards, event feeds) — same Zustand store, separate component subtrees so the map's re-renders don't ripple into the leaderboard and vice versa. ## Failure mode UI unavailable → back-end unaffected. See [[failure-domains]].