# typesafe router

Source: docs/source/pages/docs/typesafe-router.mdx
URL: /docs/typesafe-router

# typesafe router

This pattern keeps routing inside the same data model as the rest of the React app: the browser pathname is source data, derived route state is a selector, and React renders the selected route with ordinary components. It is useful when the app already uses Atom.io heavily and does not need a full route framework for loaders, actions, nested outlets, or route-owned data fetching.

## shape

Define route structure once as shared, typed data with
[TreeTrunks](https://www.npmjs.com/package/treetrunks), a small route-tree helper used
by this guide. It is not part of atom.io; install it separately if you want this exact
route-tree pattern. TreeTrunks route trees use object keys for path segments, `null` for
terminal leaves, `optional(...)` for optional branches, and `$paramName` segment names
for dynamic segments. From that tree, derive these types:

- `Route`: a tuple-like path representation, such as `["docs", string, "comments"]`.
- `Pathname`: a slash-prefixed string representation, such as `/docs/abc/comments`.
- `PathnameWithSearch`: the route pathname plus a query string, such as `/docs/abc/comments?tab=activity`.
- `isRoute(value)`: validates an unknown path tuple against the route tree.
- `PUBLIC_ROUTES` or other route subsets: typed lists for auth, layout, or navigation policy.

The important part is that route strings are not open-ended throughout the app. Links should accept the derived `Pathname` type. Imperative navigation should accept `Pathname | PathnameWithSearch`, since a command-style navigation often needs to preserve or set query state. Route rendering should consume the validated `Route` type.

### route shape
Source: docs/source/exhibits/guides/typesafe-router/route-shape.ts

```ts
import type { Join, Tree, TreePath } from "treetrunks"
import { isTreePath, optional } from "treetrunks"

export const ROUTES = optional({
	login: null,
	docs: optional({
		$docId: optional({
			comments: null,
		}),
	}),
})

export type Route = TreePath<typeof ROUTES>
export type Pathname = `/${Join<Route, `/`>}`
export type PathnameWithSearch = `${Pathname}?${string}`

export function isRoute(path: unknown[]): path is Route {
	return isTreePath(ROUTES, path)
}
```

Dynamic segment markers, such as `$docId`, belong in the TreeTrunks route tree; the derived `Route` type exposes the actual segment value. Keep TreeTrunks as the default mechanism for this pattern so runtime validation and TypeScript route types come from the same source.

## pathname state

Represent the current browser location as an atom. Its default reads `window.location.pathname`.

The atom owns browser integration:

- intercept ordinary left-clicks on same-origin anchors while leaving modified clicks,
  downloads, new tabs, and external links to the browser;
- call `history.pushState` for client-side navigation;
- listen to `popstate` so back and forward buttons update state;
- clean up browser listeners when the atom effect is disposed;
- expose a `navigate(pathname)` helper that pushes history and updates the atom.

### pathname state
Source: docs/source/exhibits/guides/typesafe-router/pathname-state.ts

```ts
import { atom, setState } from "atom.io"

import type { Pathname, PathnameWithSearch } from "./route-shape.ts"

type BrowserPathname = Pathname | (string & {})

export const pathnameAtom = atom<BrowserPathname>({
	key: `pathname`,
	default: () => window.location.pathname,
	effects: [
		({ setSelf }) => {
			const syncFromBrowser = () => {
				setSelf(window.location.pathname)
			}
			const navigateFromClick = (event: MouseEvent) => {
				if (
					event.defaultPrevented ||
					event.button !== 0 ||
					event.metaKey ||
					event.altKey ||
					event.ctrlKey ||
					event.shiftKey
				) {
					return
				}
				if (!(event.target instanceof Element)) return

				const anchor = event.target.closest(`a`)
				if (!(anchor instanceof HTMLAnchorElement)) return
				if (anchor.target && anchor.target !== `_self`) return
				if (anchor.hasAttribute(`download`)) return

				const url = new URL(anchor.href)
				if (url.origin !== window.location.origin) return

				event.preventDefault()
				history.pushState(null, ``, `${url.pathname}${url.search}${url.hash}`)
				setSelf(window.location.pathname)
			}

			document.addEventListener(`click`, navigateFromClick)
			window.addEventListener(`popstate`, syncFromBrowser)

			return () => {
				document.removeEventListener(`click`, navigateFromClick)
				window.removeEventListener(`popstate`, syncFromBrowser)
			}
		},
	],
})

export function navigate(pathname: Pathname | PathnameWithSearch): void {
	history.pushState(null, ``, pathname)
	setState(pathnameAtom, pathname.split(`?`)[0] as Pathname)
}
```

The atom stores `BrowserPathname`, not only `Pathname`, because browser state is
untrusted input: users can paste or refresh on any path. Keep programmatic navigation
narrow with `Pathname | PathnameWithSearch`, then let the route selector validate the
current browser string and choose a not-found route when it is not part of the tree.

Keep query-string handling explicit. The `PathnameWithSearch` alias is a small but useful cue that navigation has two related shapes: the route pathname the router validates, and the full URL path a user action might push. The route selector usually cares about path segments, while page view state, filters, or search text can live in separate atoms synchronized to search params.

## route selection

Convert `pathnameAtom` into the renderable route with a selector. This is where route validation, default routes, authentication gates, and layout policy belong.

### route selection
Source: docs/source/exhibits/guides/typesafe-router/route-selection.ts

```ts
import type { AtomToken } from "atom.io"
import { selector } from "atom.io"

import { pathnameAtom } from "./pathname-state.ts"
import { isRoute, type Route } from "./route-shape.ts"

declare const authAtom: AtomToken<boolean | null>
declare function isPublicRoute(path: Route): boolean

export const routeSelector = selector<Route | 404>({
	key: `route`,
	get: ({ get }) => {
		const pathname = get(pathnameAtom)
		const path = pathname.split(`/`).slice(1).filter(Boolean)

		if (!isRoute(path)) {
			return 404
		}

		if (isPublicRoute(path)) {
			return path
		}

		const auth = get(authAtom)
		if (!auth) {
			return [`login`]
		}

		if (path.length === 0) {
			return [`docs`]
		}

		return path
	},
})
```

This keeps redirects declarative from React's point of view. Components subscribe to route state; they do not need to parse `window.location`, inspect auth state, and decide routing policy independently.

## rendering

Render the selected route with a small component that switches on the first tuple segment and then narrows by route length or child segments. Shared chrome can be applied per branch so full-screen auth pages, application shells, and error pages do not need to share one route framework abstraction.

### current route
Source: docs/source/exhibits/guides/typesafe-router/current-route.tsx

```tsx
import { useO } from "atom.io/react"
import type * as React from "react"

import { routeSelector } from "./route-selection.ts"

declare function NotFoundPage(): React.JSX.Element
declare function LoginPage(): React.JSX.Element
declare function DocDetail(props: { docId: string }): React.JSX.Element
declare function DocsIndex(): React.JSX.Element

function CurrentRoute(): React.JSX.Element {
	const route = useO(routeSelector)

	if (route === 404) {
		return <NotFoundPage />
	}

	if (route.length === 0) {
		return <DocsIndex />
	}

	switch (route[0]) {
		case `login`:
			return <LoginPage />

		case `docs`:
			if (route.length === 2) {
				return <DocDetail docId={route[1]} />
			}
			return <DocsIndex />

		default:
			return <NotFoundPage />
	}
}
```

For links, wrap the native anchor only to narrow `href` to `Pathname`. Let global click interception do the SPA navigation work.

### app anchor
Source: docs/source/exhibits/guides/typesafe-router/app-anchor.tsx

```tsx
import type * as React from "react"

import type { Pathname } from "./route-shape.ts"

type AppAnchorProps = Omit<
	React.AnchorHTMLAttributes<HTMLAnchorElement>,
	`href`
> & {
	href: Pathname
}

export function AppAnchor(props: AppAnchorProps): React.JSX.Element {
	return <a {...props} />
}
```

## Bun static routes

When the app is served by Bun's `serve({ routes })` static routes API, the same route tree can generate the route table entries that return the frontend entrypoint. This is a Bun integration convenience: it keeps refreshes and copied deep links aligned with the client route map without hand-writing every static route.

Bun's dynamic segment syntax may differ from the route-tree syntax. For example, a route-tree segment like `$docId` can be translated to Bun's `:docId` segment before adding it to the static routes object.

### bun static routes
Source: docs/source/exhibits/guides/typesafe-router/bun-static-routes.ts

```ts
import { flattenTree, type Tree } from "treetrunks"

type FrontendEntrypoint = Response
type RouteTree = Tree

export function createSpaFallbacks(
	index: FrontendEntrypoint,
	routes: RouteTree,
): Record<`/${string}`, FrontendEntrypoint> {
	return Object.fromEntries(
		flattenRouteTree(routes).map((path) => [
			`/${path.replace(/\$(\w+)/g, `:$1`)}`,
			index,
		]),
	)
}

function flattenRouteTree(routes: RouteTree): string[] {
	return Object.keys(flattenTree(routes)).filter((path) => path.length > 0)
}
```

Other servers should use their own SPA fallback mechanism. The reusable part of this pattern is the shared route tree; the Bun-specific part is turning that tree into `serve({ routes })` entries.

## nginx static hosting

When the app is served as built static files through nginx, use nginx's file lookup fallback instead of generating a server route table. The goal is the same as the Bun static routes case: a browser refresh on `/docs/abc/comments` should return the frontend entrypoint, after which the atom-backed route selector validates and renders the route.

This app's nginx config does that with `try_files`:

### nginx static hosting
Source: docs/source/exhibits/guides/typesafe-router/nginx-static-hosting.conf.txt

```text
location / {
  root   /usr/share/nginx/html;
  index  index.html index.htm;
  add_header "Cache-Control" "max-age=0, no-cache, no-store, must-revalidate";
  expires 0;
  try_files $uri /index.html;
}
```

`try_files $uri /index.html` asks nginx to serve an actual file first. That keeps bundled assets, favicons, and other static files working normally. If the requested path is not a file, nginx serves `index.html`, preserving the original URL in the address bar for the client router to parse.

With this approach, nginx does not know the application's route tree. Unknown app paths still receive `index.html`, and the client-side `routeSelector` is responsible for returning the not-found state when `isRoute(path)` fails.

## guidelines

- Keep route definitions in shared code so client routing and any host-specific deep-link handling can share the same route map.
- Keep route validation runtime-backed; TypeScript alone cannot protect pasted URLs or browser history.
- Keep programmatic navigation typed, including the query-string case when needed.
- Keep route data as path segments, not parsed domain objects. Load domain records through normal remote-state atoms after routing selects the relevant id.
- Keep auth gating in the route selector for immediate client feedback, but do not treat it as security.
- Keep invalid paths distinct from unauthorized paths. Invalid routes should render a not-found state; valid private routes with no auth can select the login route.
- Prefer selectors for route-derived UI state such as active nav items, document titles, breadcrumbs, or layout mode.

This gives the app a small router with strong type pressure, browser-native links, refresh-safe deep links, and routing state that composes naturally with the rest of Atom.io.
