|
| 1 | +--- |
| 2 | +title: 'Routing, But Make It Nested' |
| 3 | +author: [gallayl] |
| 4 | +tags: ['shades', 'shades-common-components', 'shades-showcase-app'] |
| 5 | +date: '2026-03-12T12:00:00.000Z' |
| 6 | +draft: false |
| 7 | +image: img/015-nested-router.jpg |
| 8 | +excerpt: The old flat Router served us well, but it's time to talk about its successor — NestedRouter brings hierarchical routes, type-safe links, automatic breadcrumbs, navigation trees, and view transitions to Shades. |
| 9 | +--- |
| 10 | + |
| 11 | +At the end of the [VNode refactor post](/posts/013-shades-vnode-refactor/), we dropped a teaser: _"Stay tuned for a dedicated post on the NestedRouter."_ Well, here we are. Buckle up — this one's got type-level wizardry, recursive route matching, and enough generic parameters to make your IDE's IntelliSense break a sweat. |
| 12 | + |
| 13 | +## The old Router: flat, simple, and... flat |
| 14 | + |
| 15 | +The original `Router` component did one job: match a URL against a flat list of routes, render the first hit. It looked like this: |
| 16 | + |
| 17 | +```typescript |
| 18 | +const routes: Route[] = [ |
| 19 | + { url: '/users', component: () => <UserList /> }, |
| 20 | + { url: '/users/:id', component: ({ match }) => <UserDetail id={match.params.id} /> }, |
| 21 | + { url: '/settings', component: () => <Settings /> }, |
| 22 | +] |
| 23 | + |
| 24 | +<Router routes={routes} notFound={<NotFound />} /> |
| 25 | +``` |
| 26 | + |
| 27 | +Clean. Readable. _Completely incapable of expressing layouts._ |
| 28 | + |
| 29 | +Want a persistent sidebar that stays mounted while child pages swap? Tough luck — every route renders from scratch. Want breadcrumbs that actually know the route hierarchy? You're hand-rolling that yourself. Want your navigation sidebar to auto-generate from the route definitions? Write a separate data structure and keep it in sync manually. _Fun._ |
| 30 | + |
| 31 | +The `Router` is now officially **deprecated**. It still works (we're not monsters), but new code should reach for its successor. |
| 32 | + |
| 33 | +## Enter the NestedRouter |
| 34 | + |
| 35 | +The `NestedRouter` flips the model. Routes are no longer a flat array — they're a **tree**. Each route is a Record entry where keys are URL patterns, and routes can have `children`. The killer feature: parent routes receive an `outlet` prop containing their matched child's rendered content. |
| 36 | + |
| 37 | +```typescript |
| 38 | +const routes = { |
| 39 | + '/': { |
| 40 | + component: ({ outlet }) => ( |
| 41 | + <AppLayout sidebar={<Sidebar />}> |
| 42 | + {outlet ?? <HomePage />} |
| 43 | + </AppLayout> |
| 44 | + ), |
| 45 | + children: { |
| 46 | + '/users': { |
| 47 | + component: ({ outlet }) => outlet ?? <UserList />, |
| 48 | + children: { |
| 49 | + '/:id': { |
| 50 | + component: ({ match }) => <UserDetail id={match.params.id} />, |
| 51 | + }, |
| 52 | + }, |
| 53 | + }, |
| 54 | + '/settings': { |
| 55 | + component: () => <Settings />, |
| 56 | + }, |
| 57 | + }, |
| 58 | + }, |
| 59 | +} satisfies Record<string, NestedRoute<any>> |
| 60 | +``` |
| 61 | + |
| 62 | +When the user navigates to `/users/42`: |
| 63 | + |
| 64 | +1. The `/` route matches as a prefix — its component renders `AppLayout` with the `outlet` |
| 65 | +2. `/users` matches as a prefix — it passes through its `outlet` |
| 66 | +3. `/:id` matches as a leaf — it renders `UserDetail` with `id: '42'` |
| 67 | + |
| 68 | +The result is composed inside-out: `UserDetail` → `outlet` of `/users` → `outlet` of `/`. The `AppLayout` stays mounted, the sidebar doesn't re-render, and the only thing that swaps is the innermost content. Exactly like a good layout system should work. |
| 69 | + |
| 70 | +## The match chain: how the sausage is made |
| 71 | + |
| 72 | +Under the hood, `buildMatchChain()` walks the route tree recursively. For routes with children, it first tries a prefix match (so `/users` can match `/users/42`), then recurses into children with the remaining URL. For leaf routes, it does an exact match. The result is a `MatchChainEntry[]` — an ordered list from outermost to innermost matched route. |
| 73 | + |
| 74 | +When the URL changes, `findDivergenceIndex()` compares the old and new chains to figure out _exactly_ which levels changed. Only divergent routes fire their lifecycle hooks. Navigate from `/users/42` to `/users/99`? The `AppLayout` and `/users` wrapper don't even blink — only the `/:id` leaf gets its `onLeave`/`onVisit` treatment. |
| 75 | + |
| 76 | +This is not just an optimization — it means parent routes can hold state, run animations, and manage resources without getting torn down every time a child changes. |
| 77 | + |
| 78 | +## Type safety that earns its keep |
| 79 | + |
| 80 | +Here's where things get spicy. The `NestedRouteLink` component does SPA navigation (intercepts clicks, calls `history.pushState`), but the generic version goes further. |
| 81 | + |
| 82 | +### Route parameter inference |
| 83 | + |
| 84 | +`ExtractRouteParams` is a recursive conditional type that pulls parameter names out of URL patterns: |
| 85 | + |
| 86 | +```typescript |
| 87 | +type Params = ExtractRouteParams<'/users/:id/posts/:postId'>; |
| 88 | +// => { id: string; postId: string } |
| 89 | + |
| 90 | +type NoParams = ExtractRouteParams<'/settings'>; |
| 91 | +// => Record<string, never> |
| 92 | +``` |
| 93 | + |
| 94 | +When you use `NestedRouteLink`, it knows: |
| 95 | + |
| 96 | +```typescript |
| 97 | +// ✅ No params needed — params is optional |
| 98 | +<NestedRouteLink href="/settings">Settings</NestedRouteLink> |
| 99 | + |
| 100 | +// ✅ Params required — TypeScript enforces it |
| 101 | +<NestedRouteLink href="/users/:id" params={{ id: '42' }}>User 42</NestedRouteLink> |
| 102 | + |
| 103 | +// ❌ TypeScript error: Property 'id' is missing |
| 104 | +<NestedRouteLink href="/users/:id">Oops</NestedRouteLink> |
| 105 | +``` |
| 106 | + |
| 107 | +### Route path validation |
| 108 | + |
| 109 | +Want to go even further? `createNestedRouteLink` constrains `href` to only accept paths that actually exist in your route tree: |
| 110 | + |
| 111 | +```typescript |
| 112 | +const AppLink = createNestedRouteLink<typeof appRoutes>() |
| 113 | + |
| 114 | +// ✅ Valid path from the route tree |
| 115 | +<AppLink href="/settings">Settings</AppLink> |
| 116 | + |
| 117 | +// ❌ TypeScript error: '/nonexistent' is not assignable |
| 118 | +<AppLink href="/nonexistent">Nope</AppLink> |
| 119 | +``` |
| 120 | + |
| 121 | +`ExtractRoutePaths` recursively walks the route tree type and produces a union of all valid full paths — including child routes concatenated with their parents. Typo in a route link? TypeScript catches it at compile time, not in a bug report from production. |
| 122 | + |
| 123 | +The same pattern powers the `createAppBarLink` and `createBreadcrumb` factories in `shades-common-components`. One line of setup, and every link in your app is validated against the actual route definitions. |
| 124 | + |
| 125 | +## Route metadata: teach your routes to introduce themselves |
| 126 | + |
| 127 | +Every `NestedRoute` can carry a `meta` object with a `title` (static string or async resolver function). But `NestedRouteMeta` is an **interface**, not a type — and that's deliberate. Declaration merging lets you extend it with your own fields: |
| 128 | + |
| 129 | +```typescript |
| 130 | +declare module '@furystack/shades' { |
| 131 | + interface NestedRouteMeta { |
| 132 | + icon?: IconDefinition; |
| 133 | + hidden?: boolean; |
| 134 | + } |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +Now every route in your app can carry an icon, a visibility flag, or whatever your navigation components need — and it's all type-checked. |
| 139 | + |
| 140 | +The showcase app uses this to attach icons to category routes: |
| 141 | + |
| 142 | +```typescript |
| 143 | +'/inputs-and-forms': { |
| 144 | + meta: { title: 'Inputs & Forms', icon: icons.fileText }, |
| 145 | + component: ({ outlet }) => outlet ?? <Navigate to="/inputs-and-forms/buttons" />, |
| 146 | + children: { /* ... */ }, |
| 147 | +}, |
| 148 | +``` |
| 149 | + |
| 150 | +## Breadcrumbs: know where you are |
| 151 | + |
| 152 | +The `RouteMatchService` exposes the current match chain as an `ObservableValue`. Subscribe to it, and you always know the full path of matched routes from root to leaf. |
| 153 | + |
| 154 | +`resolveRouteTitles()` takes a match chain and an injector, resolves all titles (including async ones) in parallel, and hands you an array of display-friendly strings. Combine that with `buildDocumentTitle()`: |
| 155 | + |
| 156 | +```typescript |
| 157 | +buildDocumentTitle(['Media', 'Movies', 'Superman'], { prefix: 'My App', separator: ' / ' }); |
| 158 | +// => 'My App / Media / Movies / Superman' |
| 159 | +``` |
| 160 | + |
| 161 | +The `Breadcrumb` component in `shades-common-components` wires all of this together. And yes, it gets the `createBreadcrumb<typeof appRoutes>()` treatment too — every breadcrumb link is validated against your route tree. Here's how the showcase app uses it: |
| 162 | + |
| 163 | +```typescript |
| 164 | +const ShowcaseBreadcrumbItem = createBreadcrumb<typeof appRoutes>() |
| 165 | + |
| 166 | +// In the render function: |
| 167 | +<ShowcaseBreadcrumbItem |
| 168 | + homeItem={{ path: '/', label: <Icon icon={icons.home} size="small" /> }} |
| 169 | + items={resolvedItems} |
| 170 | + separator=" › " |
| 171 | +/> |
| 172 | +``` |
| 173 | + |
| 174 | +No manual breadcrumb configuration. Add a new nested route with a title, and the breadcrumbs just... work. It's almost suspicious how well it works. |
| 175 | + |
| 176 | +## Navigation trees: routes as data |
| 177 | + |
| 178 | +Sometimes you need the route hierarchy as a data structure — for sidebar navigation, sitemaps, or mega-menus. `extractNavTree()` walks your route definitions and returns a `NavTreeNode[]` tree: |
| 179 | + |
| 180 | +```typescript |
| 181 | +import { extractNavTree } from '@furystack/shades'; |
| 182 | + |
| 183 | +const navTree = extractNavTree(appRoutes['/'].children, '/'); |
| 184 | +``` |
| 185 | + |
| 186 | +Each node has `pattern`, `fullPath`, `meta`, and optional `children`. The showcase app uses this to auto-generate its entire sidebar navigation: |
| 187 | + |
| 188 | +```typescript |
| 189 | +const getCategoryNodes = (): NavTreeNode[] => extractNavTree(appRoutes['/'].children, '/'); |
| 190 | +``` |
| 191 | + |
| 192 | +Those nodes feed into `SidebarCategory` and `SidebarPageLink` components. New route? New sidebar entry. Delete a route? Gone from the sidebar. The navigation is always a perfect mirror of the route tree because it _is_ the route tree. |
| 193 | + |
| 194 | +## View transitions: because teleporting is jarring |
| 195 | + |
| 196 | +The `NestedRouter` supports the native View Transition API. Enable it globally or per-route: |
| 197 | + |
| 198 | +```typescript |
| 199 | +// Global — all route changes get transitions |
| 200 | +<NestedRouter routes={appRoutes} viewTransition /> |
| 201 | + |
| 202 | +// Per-route — only this route animates |
| 203 | +'/fancy-page': { |
| 204 | + viewTransition: { types: ['slide-left'] }, |
| 205 | + component: () => <FancyPage />, |
| 206 | +} |
| 207 | + |
| 208 | +// Opt out — disable for a specific route even when global is on |
| 209 | +'/instant-page': { |
| 210 | + viewTransition: false, |
| 211 | + component: () => <InstantPage />, |
| 212 | +} |
| 213 | +``` |
| 214 | + |
| 215 | +`resolveViewTransition()` merges the router-level default with the leaf route's override. A per-route `false` wins over a global `true`, and custom `types` let you drive CSS `view-transition-name` styling for directional animations. The transition wraps the DOM update — `onLeave` fires before, `onVisit` fires after, and the browser handles the crossfade in between. |
| 216 | + |
| 217 | +## The old vs. the new: a side-by-side |
| 218 | + |
| 219 | +| | Old `Router` | New `NestedRouter` | |
| 220 | +| ----------------------- | -------------------- | ----------------------------------------------- | |
| 221 | +| **Route structure** | Flat array | Nested Record with `children` | |
| 222 | +| **Layouts** | Re-render everything | Parent `outlet` pattern — persistent layouts | |
| 223 | +| **Type-safe links** | Nope | `createNestedRouteLink<typeof routes>()` | |
| 224 | +| **Route metadata** | Nope | `meta` with declaration merging | |
| 225 | +| **Breadcrumbs** | DIY | `RouteMatchService` + `resolveRouteTitles()` | |
| 226 | +| **Nav tree extraction** | DIY | `extractNavTree()` | |
| 227 | +| **View Transitions** | Nope | Built-in, per-route configurable | |
| 228 | +| **Lifecycle scoping** | Per matched route | Per chain level — parents survive child changes | |
| 229 | + |
| 230 | +## Migrating from Router to NestedRouter |
| 231 | + |
| 232 | +If you're using the old `Router`, migration is straightforward: |
| 233 | + |
| 234 | +1. Convert your flat `Route[]` array into a nested `Record<string, NestedRoute<any>>` object |
| 235 | +2. Move the `url` field to the Record key |
| 236 | +3. Wrap shared layouts in parent routes that render `outlet` |
| 237 | +4. Replace `RouteLink` / `LinkToRoute` with `NestedRouteLink` |
| 238 | +5. Optionally add `meta` for breadcrumbs and navigation |
| 239 | + |
| 240 | +The old components aren't going anywhere immediately (deprecated ≠ deleted), but the new system is strictly better in every dimension. There's no reason to start new code with the flat router. |
| 241 | + |
| 242 | +## What's next |
| 243 | + |
| 244 | +Routing was just one piece of a busy stretch. A few other topics deserve their own deep dives — stay tuned for posts on the **19-theme system** with scoped theming and Monaco integration, the new **Entity Sync** packages bringing real-time WebSocket subscriptions with optimistic updates to the data layer, the custom **changelog tooling** that validates entries in CI and can auto-generate drafts, and the **ESLint plugin** that now ships 19 FuryStack-specific rules with auto-fixers. Plenty to unpack. |
| 245 | + |
| 246 | +Want to see NestedRouter in action right now? The [Showcase App](https://shades-showcase.netlify.app/) runs entirely on it — 60+ pages, nested layouts, auto-generated navigation, the works. The source is in [`packages/shades-showcase-app`](https://github.com/furystack/furystack/tree/develop/packages/shades-showcase-app), and the router itself lives in [`packages/shades/src/components/nested-router.tsx`](https://github.com/furystack/furystack/blob/develop/packages/shades/src/components/nested-router.tsx). |
| 247 | + |
| 248 | +Go nest some routes. Your flat router will understand. Eventually. |
0 commit comments