Frontend Foundations
Objectives
By the end of this chapter, you should be able to:
- Bootstrap the Next.js App Router shell and a CSS custom-property design-token system that every later component reads from
- Build a Zustand store for live aircraft state, and explain why selector-based subscriptions matter once data is updating every few seconds
- Consume the WebSocket feed from Module 2 in the browser, with reconnect-with-backoff
- Render the first live Leaflet map: one glowing triangle per aircraft, rotated to its heading
💡 Why this matters: Every later module adds a feature to this screen. None of it has anywhere to attach until a working map, a store other components can read, and a token-driven theme system all exist first.
This chapter deliberately stays narrow: one map, one flat aircraft list, a two-state theme toggle. Search, the detail panel, and the rest of the dashboard come in the next chapter; multi-region support, alerts, and airports arrive in later modules. Building the thinnest possible vertical slice first, server pushes a position, browser draws a plane, means everything after this is additive to something that already works, not a leap of faith.
Why a Zustand Store, and Why CSS Custom Properties Instead of dark: Variants
React Context re-renders every consumer on every change unless contexts are split carefully or memoized aggressively — for a value updating on every poll cycle, that’s a real cost, not a theoretical one. Zustand’s create() gives you a store that lives outside React’s tree entirely; components subscribe to just the slice they read via a selector function, and only re-render when that slice changes. useAppStore((s) => s.connectionStatus) doesn’t re-render when aircraft changes, and vice versa.
The theming choice is smaller but shows up in every component from here on: every color in this app is a CSS custom property (var(--phosphor), var(--bg-deep), etc.), redefined in one block per theme, rather than components sprinkling dark:bg-slate-900 next to bg-white everywhere. The difference shows up the moment a second dark-family theme exists — with Tailwind’s dark: variant, every component with a dark: class needs a second variant added per new theme. With custom properties, one new theme block redefining the same property names covers every component that already reads var(--phosphor), with zero component-level changes.
Design Tokens and the App Shell
cd apps/web
npm install zustand leaflet react-leaflet
npm install -D @types/leaflet
npm install @fontsource/ibm-plex-mono @fontsource/space-grotesk
/* apps/web/src/app/globals.css */
@import "leaflet/dist/leaflet.css";
@import "tailwindcss";
/*
* :root holds the light theme and doubles as the fallback. `.dark` overrides
* the same property names for the dark/CRT-phosphor look. Components never
* branch on which theme is active -- they reference these custom properties
* (bg-base, text-fg, border-line, via the @theme inline block below) and the
* cascade resolves it.
*/
:root {
--bg-deep: #eef2f0;
--bg-panel: #ffffff;
--bg-panel-raised: #f3f6f4;
--line: #d3ddd8;
--phosphor: #0e8a52;
--phosphor-dim: #4f9d78;
--phosphor-rgb: 14, 138, 82;
--text-main: #16211c;
--text-dim: #5c6b64;
}
.dark {
--bg-deep: #0a121c;
--bg-panel: #101a26;
--bg-panel-raised: #16222f;
--line: #26394a;
--phosphor: #4dffab;
--phosphor-dim: #2a8f61;
--phosphor-rgb: 77, 255, 171;
--text-main: #e4eef0;
--text-dim: #85a0aa;
}
@theme inline {
--color-base: var(--bg-deep);
--color-surface: var(--bg-panel);
--color-surface-raised: var(--bg-panel-raised);
--color-line: var(--line);
--color-phosphor: var(--phosphor);
--color-phosphor-dim: var(--phosphor-dim);
--color-fg: var(--text-main);
--color-fg-dim: var(--text-dim);
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
--font-display: "Space Grotesk", ui-sans-serif, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
background: var(--bg-deep);
color: var(--text-main);
font-family: var(--font-mono);
transition: background-color 0.15s ease, color 0.15s ease;
}
.plane-icon {
filter: drop-shadow(0 0 4px rgba(var(--phosphor-rgb), 0.7));
}
.leaflet-container {
background: var(--bg-deep) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel-raised) !important;
color: var(--phosphor) !important;
border-color: var(--line) !important;
}
@theme inline is Tailwind v4’s mechanism for turning a runtime CSS custom property into a real Tailwind utility name — bg-base, text-fg-dim, border-line all work as ordinary Tailwind classes from here on, resolving through a property the current theme controls. That combination, Tailwind utilities for layout and spacing, custom properties for every color, is what every component in this course uses; dark: never appears anywhere.
The Theme Store and the No-Flash Script
// apps/web/src/store/useThemeStore.ts
import { create } from "zustand";
const STORAGE_KEY = "skywatch-theme";
interface ThemeState {
isDark: boolean;
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeState>((set, get) => ({
isDark: true,
toggleTheme: () => {
const isDark = !get().isDark;
document.documentElement.classList.toggle("dark", isDark);
try {
localStorage.setItem(STORAGE_KEY, isDark ? "dark" : "light");
} catch {
// localStorage can throw in private-browsing/storage-restricted contexts -- theme just won't persist.
}
set({ isDark });
},
}));
The store’s isDark: true initial value only matters for the very first client-side render before anything has hydrated from localStorage — the real starting value is decided before React even runs, by a synchronous script in <head>:
// apps/web/src/app/layout.tsx
import type { Metadata } from "next";
import "@fontsource/ibm-plex-mono/400.css";
import "@fontsource/ibm-plex-mono/500.css";
import "@fontsource/ibm-plex-mono/600.css";
import "@fontsource/ibm-plex-mono/700.css";
import "@fontsource/space-grotesk/400.css";
import "@fontsource/space-grotesk/500.css";
import "@fontsource/space-grotesk/600.css";
import "@fontsource/space-grotesk/700.css";
import "./globals.css";
export const metadata: Metadata = {
title: "SKYWATCH — Live Flight Tracker",
description: "Real-time ADS-B aircraft tracking dashboard",
};
// Runs before first paint so there's no flash of the wrong theme -- a normal
// useEffect-based sync would apply the class after React's first paint,
// producing a visible flash on every load. Reads the same localStorage key
// useThemeStore writes.
const NO_FLASH_THEME_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem('skywatch-theme');
var isDark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', isDark);
} catch (e) {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: NO_FLASH_THEME_SCRIPT }} />
</head>
<body className="h-full overflow-hidden font-mono antialiased">{children}</body>
</html>
);
}
suppressHydrationWarning on <html> is required here: the script mutates document.documentElement’s class list on the client before React hydrates, so the server-rendered markup and the first client paint legitimately differ. Without this prop, React logs a hydration mismatch warning every load for a difference that’s intentional.
// apps/web/src/app/page.tsx
import { Dashboard } from "@/components/Dashboard";
export default function Home() {
return <Dashboard />;
}
The Shared WebSocket Message Type
ServerToClientMessage — the discriminated union describing what crosses the /ws socket — was already defined back in Module 2’s realtime-layer lesson, in packages/shared/src/events.ts, alongside SquawkAlertEvent and OverflightEvent. Nothing new to add here; useLiveFeed below just imports it, the same contract the server’s send() calls have been writing against since that lesson.
The Store
// apps/web/src/store/useAppStore.ts
import { create } from "zustand";
import type { AircraftState } from "@skywatch/shared";
export type ConnectionStatus = "connecting" | "open" | "closed";
interface AppState {
connectionStatus: ConnectionStatus;
/**
* Flat list of live aircraft. Only one watch region is configured at this
* point, so the WS message's regionId is ignored for now -- a later
* module introduces multiple simultaneously-watched regions and, with
* them, per-region storage.
*/
aircraft: AircraftState[];
lastUpdate: Date | null;
selectedIcao: string | null;
filterText: string;
setConnectionStatus: (status: ConnectionStatus) => void;
setPositions: (aircraft: AircraftState[], ts: number) => void;
setSelectedIcao: (icao: string | null) => void;
setFilterText: (text: string) => void;
}
export const useAppStore = create<AppState>((set) => ({
connectionStatus: "connecting",
aircraft: [],
lastUpdate: null,
selectedIcao: null,
filterText: "",
setConnectionStatus: (status) => set({ connectionStatus: status }),
setPositions: (aircraft, ts) => set({ aircraft, lastUpdate: new Date(ts) }),
setSelectedIcao: (icao) => set({ selectedIcao: icao }),
setFilterText: (text) => set({ filterText: text }),
}));
filterText and selectedIcao aren’t consumed by anything yet — they’re here because the next chapter’s search box and flight list both need to read and write them, and defining the store shape once, fully, is clearer than growing it field by field across two adjacent lessons.
Consuming the Live Feed
// apps/web/src/lib/serverUrl.ts
export const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL ?? "http://localhost:4000";
export function apiUrl(path: string): string {
return `${SERVER_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
export function wsUrl(): string {
const u = new URL(SERVER_URL);
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
u.pathname = "/ws";
return u.toString();
}
// apps/web/src/hooks/useLiveFeed.ts
"use client";
import { useEffect } from "react";
import type { ServerToClientMessage } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { wsUrl } from "@/lib/serverUrl";
const MAX_RECONNECT_DELAY_MS = 15_000;
/**
* Opens the live WebSocket connection to apps/server and feeds every
* "positions" message into the store. Reconnects with exponential backoff
* if the connection drops instead of giving up after one failed attempt.
*/
export function useLiveFeed() {
const setConnectionStatus = useAppStore((s) => s.setConnectionStatus);
const setPositions = useAppStore((s) => s.setPositions);
useEffect(() => {
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let unmounted = false;
let attempt = 0;
function connect() {
setConnectionStatus("connecting");
ws = new WebSocket(wsUrl());
ws.onopen = () => {
attempt = 0;
setConnectionStatus("open");
};
ws.onmessage = (evt) => {
let msg: ServerToClientMessage;
try {
msg = JSON.parse(evt.data);
} catch {
console.error("[live-feed] received malformed WS message");
return;
}
// squawk_alert / overflight arrive on this same socket but have
// nothing to render yet -- a later module adds their handling here.
if (msg.type === "positions") {
setPositions(msg.aircraft, msg.ts);
}
};
ws.onclose = () => {
setConnectionStatus("closed");
if (unmounted) return;
attempt += 1;
const delay = Math.min(1000 * 2 ** attempt, MAX_RECONNECT_DELAY_MS);
reconnectTimer = setTimeout(connect, delay);
};
ws.onerror = () => {
ws?.close();
};
}
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
};
}, [setConnectionStatus, setPositions]);
}
The backoff, capped at 15 seconds, matters in practice: without it, a server restart during development causes the browser to hammer /ws with a reconnect attempt every few milliseconds. onerror just closing the socket is deliberate, too — close always fires after error for a WebSocket, so routing both through the same onclose reconnect logic avoids writing the backoff dance twice.
The Aircraft Icon
// apps/web/src/lib/aircraftIcon.ts
// Client-only: touches `document` via Leaflet's divIcon. Only ever imported
// from MapPanel, which is reached through a dynamic import with ssr:false.
import L from "leaflet";
/**
* The icon HTML becomes a real DOM node under the themed <html> root, so
* referencing var(--phosphor) here means the marker automatically tracks
* the current theme -- the icon factory itself never needs to know whether
* dark mode is active.
*/
function planeIconHtml(track: number): string {
return `
<svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${track}deg)">
<path d="M12 2 L15 11 L22 14 L15 15.5 L14 21 L12 18.5 L10 21 L9 15.5 L2 14 L9 11 Z" fill="var(--phosphor)" stroke="var(--bg-deep)" stroke-width="0.8"/>
</svg>
`;
}
export function makePlaneIcon(track: number): L.DivIcon {
return L.divIcon({
className: "plane-icon",
html: planeIconHtml(track),
iconSize: [24, 24],
iconAnchor: [12, 12],
});
}
Every aircraft is the same glowing phosphor-green triangle for now, rotated to its track angle — altitude-band coloring and a selected/highlighted state are additions the next chapter makes. Keeping makePlaneIcon’s signature this small now means later changes are additive, not a rewrite.
The Map
// apps/web/src/lib/mapView.ts
/** Fallback view centered on the North Atlantic -- wide enough to be a reasonable "nothing configured yet" default. */
export const DEFAULT_VIEW: { center: [number, number]; zoom: number } = {
center: [40.0, -30.0],
zoom: 3,
};
// apps/web/src/components/MapPanel.tsx
"use client";
import { memo, useMemo } from "react";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
import type { AircraftState } from "@skywatch/shared";
import { useAppStore } from "@/store/useAppStore";
import { useThemeStore } from "@/store/useThemeStore";
import { makePlaneIcon } from "@/lib/aircraftIcon";
import { DEFAULT_VIEW } from "@/lib/mapView";
const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const LIGHT_TILES = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const TILE_ATTRIBUTION = "© OpenStreetMap © CARTO";
interface AircraftMarkerProps {
aircraft: AircraftState & { lat: number; lon: number };
}
/**
* Memoized so a store update caused by one aircraft moving doesn't recreate
* every other aircraft's icon too. Leaflet's setIcon() tears down and
* rebuilds a marker's DOM node on every icon change, so a fresh L.divIcon()
* on every render, even with identical content, makes every marker flicker
* on each poll cycle instead of just the ones that moved.
*/
const AircraftMarker = memo(function AircraftMarker({ aircraft }: AircraftMarkerProps) {
const track = aircraft.track ?? aircraft.true_heading ?? aircraft.mag_heading ?? 0;
const icon = useMemo(() => makePlaneIcon(track), [track]);
return <Marker position={[aircraft.lat, aircraft.lon]} icon={icon} />;
});
function AircraftMarkers() {
const aircraft = useAppStore((s) => s.aircraft);
return (
<>
{aircraft
.filter((a): a is typeof a & { lat: number; lon: number } => a.lat != null && a.lon != null)
.map((a) => (
<AircraftMarker key={a.hex} aircraft={a} />
))}
</>
);
}
export function MapPanel() {
const isDark = useThemeStore((s) => s.isDark);
const connectionStatus = useAppStore((s) => s.connectionStatus);
return (
<div className="relative h-full w-full">
<MapContainer
center={DEFAULT_VIEW.center}
zoom={DEFAULT_VIEW.zoom}
minZoom={2}
maxZoom={12}
worldCopyJump
zoomControl
attributionControl
className="h-full w-full"
>
<TileLayer
key={isDark ? "dark" : "light"}
url={isDark ? DARK_TILES : LIGHT_TILES}
attribution={TILE_ATTRIBUTION}
subdomains="abcd"
maxZoom={19}
/>
<AircraftMarkers />
</MapContainer>
{connectionStatus !== "open" && (
<div className="absolute left-3.5 top-3.5 z-[500] rounded-sm border border-line bg-surface/90 px-3 py-2 text-[11px] tracking-[0.08em] text-phosphor">
{connectionStatus === "connecting" ? "CONNECTING TO SKYWATCH SERVER…" : "SIGNAL LOST — reconnecting…"}
</div>
)}
</div>
);
}
key={isDark ? "dark" : "light"} on TileLayer forces React to unmount and remount the layer when the theme flips — Leaflet’s TileLayer doesn’t cleanly support swapping its tile URL template on an existing instance, so a key change is the reliable way to get the new tiles to actually load.
Wiring It Into the Dashboard
// apps/web/src/components/Dashboard.tsx
"use client";
import dynamic from "next/dynamic";
import { useLiveFeed } from "@/hooks/useLiveFeed";
import { useThemeStore } from "@/store/useThemeStore";
import { useAppStore } from "@/store/useAppStore";
// react-leaflet touches `window`/`document` at module-evaluation time, so
// MapPanel can only ever run client-side -- a plain top-level import would
// crash Next's server-side render before the page ever reaches the browser.
const MapPanel = dynamic(() => import("./MapPanel").then((m) => m.MapPanel), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center text-xs tracking-[0.1em] text-fg-dim">
LOADING MAP…
</div>
),
});
function formatLastUpdate(date: Date | null): string {
if (!date) return "—";
return date.toLocaleTimeString();
}
export function Dashboard() {
useLiveFeed();
const isDark = useThemeStore((s) => s.isDark);
const toggleTheme = useThemeStore((s) => s.toggleTheme);
const aircraftCount = useAppStore((s) => s.aircraft.length);
const lastUpdate = useAppStore((s) => s.lastUpdate);
return (
<div className="flex h-full flex-col bg-base text-fg">
<header className="flex items-center justify-between border-b border-line bg-surface px-4 py-3">
<h1 className="text-sm font-semibold tracking-[0.14em] text-phosphor">SKYWATCH</h1>
<div className="flex items-center gap-4 text-[11px] tracking-[0.08em] text-fg-dim">
<span>{aircraftCount} AIRCRAFT</span>
<span>UPDATED {formatLastUpdate(lastUpdate)}</span>
<button
type="button"
onClick={toggleTheme}
className="rounded-sm border border-line px-2 py-1 text-fg-dim transition-colors hover:border-phosphor-dim hover:text-phosphor"
>
{isDark ? "☾ DARK" : "☀ LIGHT"}
</button>
</div>
</header>
<div className="relative min-h-0 flex-1">
<MapPanel />
</div>
</div>
);
}
This header is intentionally throwaway — the next chapter replaces it wholesale with a real Header component. Writing a minimal one here rather than skipping straight ahead means this chapter’s Try It is a real, complete, working screen, not something that only renders once later code exists.
Try It
- With Postgres running and at least one watch region configured, run
npm run devfrom the repo root and openhttp://localhost:3000. - Confirm the header shows “CONNECTING TO SKYWATCH SERVER…” or the live status badge on the map, a dark CRT-green map covering the North Atlantic, and — once the poller’s next cycle lands — glowing green triangles appearing wherever your configured watch region has traffic, each rotated to its actual heading.
- Click the theme toggle. The whole page should flip to the light palette instantly, with no flash. Reload the page and confirm it remembers your choice — check
localStorage.getItem('skywatch-theme')in the browser console. - If aircraft never appear, check the browser console for
[live-feed]errors first, then confirm with a WebSocket client (wscat -c ws://localhost:4000/ws) that the server side is actually pushingpositionsmessages.
Recap
- A Zustand store outside React’s tree, subscribed to via selectors, avoids the re-render cost of Context for data that updates on every poll cycle.
- CSS custom properties, not Tailwind’s
dark:variant, are what make adding new themes later a zero-component-change operation. react-leafletmust be dynamically imported withssr: false, since it toucheswindow/documentat module-evaluation time.- Reconnect-with-backoff on the WebSocket connection keeps a server restart from turning into a hammering loop of reconnect attempts.
Next lesson: the rest of the dashboard shell around this map, search, the selected-aircraft detail panel, and the flight list.