# Type-Safe Client-Side Routing Without a Framework If you want a lightweight, hand-rolled router, do it this way. As much as possible, treat routing as a pure parsing problem. Especially when nowadays AI can write the boilerplate and relieve you of the pressure of over-wrapping APIs for human write-time ergonomics. ## Core Insight: Route Object vs URL Most frameworks treat the URL as the source of truth. This is wrong. **Think of it like JSON:** - You don't pass JSON strings around your app - You parse once at the boundary, then work with typed objects - The route object is your source of truth - The URL is a lossy serialization for sharing/bookmarking ## When Is URL vs History State the Source of Truth? This is subtle but important: | Scenario | Source of Truth | Why | |----------|-----------------|-----| | Fresh page load (refresh, shared link, typed URL) | URL | `history.state` is null | | Normal navigation (clicking links) | Route object | Stored in `history.state` | | Browser back/forward | History state | `e.state` from popstate event | The discriminator is whether `history.state` is null. ## Same URL, Different Meaning Example: A photo modal at `/photo/123` opened from: - The feed page - The profile page - Direct link The URL is the same, but "close modal" should go to different places. Instagram gets this wrong — refresh a video and you lose your feed position. **Solution:** Store the full route object (including parent context) in `history.state`. The URL is just for sharing. ```ts // URL: /photo/123 // But history.state knows the context: { type: 'photoModal', id: '123', owner: { type: 'feed', scrollPosition: 1234 } } { type: 'photoModal', id: '123', owner: { type: 'profile', userId: 'abc' } } { type: 'photoModal', id: '123', owner: null } // direct navigation ``` ## The Types ```ts // All possible routes in your app — a tagged union type Route = | { type: 'home' } | { type: 'feed'; filter: FeedFilter } | { type: 'profile'; userId: string; tab: 'posts' | 'likes' | null } | { type: 'photoModal'; id: string; owner: PhotoModalOwner | null } | { type: 'settings' } | { type: 'auth'; callbackUrl: Route | null } | { type: '404'; url: string } // Which routes can "own" (render behind) a photo modal type PhotoModalOwner = | { type: 'feed'; filter: FeedFilter } | { type: 'profile'; userId: string; tab: 'posts' | 'likes' | null } ``` Using a tagged union gives you: - Exhaustive switch statements (TypeScript errors if you miss a case) - No stringly-typed route matching - IDE autocomplete for route payloads ## The Flow ``` ┌─────────────────────────────────────────────────────────────────┐ │ FRESH PAGE LOAD │ │ │ │ URL ──parseUrl──▶ Route ──sanitize──▶ Route ──▶ render │ │ ▲ │ │ │ │ │ history.state is null, │ │ so we parse from URL │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ NAVIGATE (push) │ │ │ │ Route ──sanitize──▶ Route ──┬──▶ setState(route) │ │ ├──▶ pushState(route, encode(route)) │ │ │ │ │ │ ▼ │ │ │ URL is derived │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ BROWSER BACK/FORWARD │ │ │ │ popstate event │ │ │ │ │ ▼ │ │ e.state ──▶ Route (skip URL parsing!) ──sanitize──▶ render │ └─────────────────────────────────────────────────────────────────┘ ``` ## Implementation ### 1. Parse URL → Route ```ts function parseUrl(location: URL, historyState: unknown): Route { // History state takes precedence — it has full context if (historyState != null) { return historyState as Route } // Otherwise parse from URL (fresh navigation) const path = location.pathname.split('/').filter(Boolean) const params = new URLSearchParams(location.search) switch (path[0]) { case undefined: return { type: 'home' } case 'feed': return { type: 'feed', filter: parseFilter(params) } case 'profile': return { type: 'profile', userId: path[1] ?? '', tab: parseTab(params.get('tab')) } case 'photo': return { type: 'photoModal', id: path[1] ?? '', owner: null // direct navigation, no owner } case 'auth': return { type: 'auth', callbackUrl: parseCallbackUrl(params.get('callbackUrl')) } default: return { type: '404', url: location.href } } } ``` ### 2. Encode Route → URL ```ts function encode(route: Route): string { switch (route.type) { case 'home': return '/' case 'feed': return `/feed?filter=${route.filter}` case 'profile': const tab = route.tab ? `?tab=${route.tab}` : '' return `/profile/${route.userId}${tab}` case 'photoModal': return `/photo/${route.id}` // owner not encoded — it's in history state case 'settings': return '/settings' case 'auth': const callback = route.callbackUrl ? `?callbackUrl=${encodeURIComponent(encode(route.callbackUrl))}` : '' return `/auth${callback}` case '404': return route.url } } ``` ### 3. Sanitize/Redirect (Sync!) ```ts function sanitize(route: Route, user: User | null): Route { // Auth guards switch (route.type) { case 'settings': if (user == null) return { type: 'auth', callbackUrl: route } return route case 'auth': // Already logged in? Redirect to callback or home if (user != null) { return route.callbackUrl ?? { type: 'home' } } // Clean up circular callbacks if (route.callbackUrl?.type === 'auth') { return { type: 'auth', callbackUrl: null } } return route case 'photoModal': // If modal has owner, validate the owner too if (route.owner != null) { const sanitizedOwner = sanitize(route.owner, user) if (sanitizedOwner.type !== route.owner.type) { return sanitizedOwner // owner redirected, follow it } } return route default: return route } } ``` **Important:** Keep this synchronous. Async redirects cause flashes of wrong content and are hard to reason about. ### 4. Navigation Functions ```ts function pushRoute(route: Route) { const sanitized = sanitize(route, currentUser) setCurrentRoute(sanitized) window.history.pushState(sanitized, '', encode(sanitized)) } function replaceRoute(route: Route) { const sanitized = sanitize(route, currentUser) setCurrentRoute(sanitized) window.history.replaceState(sanitized, '', encode(sanitized)) } // Listen for back/forward window.addEventListener('popstate', (e) => { const route = parseUrl(window.location, e.state) const sanitized = sanitize(route, currentUser) setCurrentRoute(sanitized) }) ``` ### 5. Type-Safe Link Component ```tsx function Link({ to, children, ...props }: { to: Route } & AnchorProps) { return ( { // Let browser handle modified clicks (new tab, etc) if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return } e.preventDefault() pushRoute(to) }} {...props} > {children} ) } // Usage — fully typed! View Profile ``` ### 6. Route → Component Dispatch ```tsx function App() { const route = useCurrentRoute() switch (route.type) { case 'home': return case 'feed': return case 'profile': return case 'photoModal': // Owner pattern: render owner page with modal overlay if (route.owner == null) { return } switch (route.owner.type) { case 'feed': return case 'profile': return } case 'settings': return case 'auth': return case '404': return } } ``` ## Patterns ### Owner Pattern (for Modals) When opening a modal from a page, set the owner: ```ts // Opening photo modal from feed pushRoute({ type: 'photoModal', id: '123', owner: { type: 'feed', filter: currentFilter } }) // Closing modal — navigate to owner function closeModal(route: PhotoModalRoute) { if (route.owner) { pushRoute(route.owner) } else { pushRoute({ type: 'home' }) // fallback for direct links } } ``` The owner page component renders both itself and the modal overlay. The URL shows `/photo/123`, but history state knows the feed is behind it. ### CallbackUrl Pattern (for Auth) ```ts // Redirect to auth with return destination pushRoute({ type: 'auth', callbackUrl: { type: 'settings' } }) // After successful auth function onAuthSuccess(route: AuthRoute) { pushRoute(route.callbackUrl ?? { type: 'home' }) } ``` CallbackUrl is recursively parsed as a full Route, not a string. This lets you validate it. ### Privacy-Aware Logging ```ts function encodeForLogging(route: Route): string { switch (route.type) { case 'profile': return '/profile/[userId]' // don't log actual user IDs case 'photoModal': return '/photo/[id]' default: return encode(route) } } ``` ## Don'ts - **Don't parse URLs in components** — centralize in one `parseUrl` function - **Don't use async redirects** — causes flashes, hard to debug - **Don't encode everything in URL** — URL is lossy; use history state for full context - **Don't use string hrefs** — use typed route objects - **Don't guess modal owners** — always pass owner explicitly when opening ## Gotchas - Safari private mode hides URL params that look like tracking params ## Why This Works 1. **Type safety** — TypeScript catches missing routes at compile time 2. **No magic** — it's just a switch statement and `pushState` 3. **Full control** — you own the code, not fighting a framework 4. **History works** — back/forward preserve full context via `history.state` 5. **Shareable URLs** — `encode()` produces clean URLs for sharing 6. **LLM-friendly** — the boilerplate is mechanical; let AI write it