Skip to content

Commit b017274

Browse files
authored
post: Nested router (#33)
* post: Nested router * zod url fix
1 parent 407e93e commit b017274

File tree

5 files changed

+783
-582
lines changed

5 files changed

+783
-582
lines changed

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,24 @@
2525
},
2626
"packageManager": "yarn@4.13.0",
2727
"dependencies": {
28-
"@astrojs/check": "^0.9.6",
29-
"@astrojs/rss": "^4.0.15",
30-
"@astrojs/sitemap": "^3.7.0",
31-
"astro": "^5.18.0",
28+
"@astrojs/check": "^0.9.7",
29+
"@astrojs/rss": "^4.0.17",
30+
"@astrojs/sitemap": "^3.7.1",
31+
"astro": "^6.0.4",
3232
"date-fns": "^4.1.0",
3333
"remark-smartypants": "^3.0.2",
3434
"sharp": "^0.34.5"
3535
},
3636
"devDependencies": {
3737
"@eslint/js": "^10.0.1",
38-
"@types/node": "^25.3.3",
39-
"eslint": "^10.0.2",
38+
"@types/node": "^25.5.0",
39+
"eslint": "^10.0.3",
4040
"eslint-plugin-astro": "^1.6.0",
4141
"husky": "^9.1.7",
42-
"lint-staged": "^16.3.2",
42+
"lint-staged": "^16.3.3",
4343
"prettier": "^3.8.1",
4444
"prettier-plugin-astro": "^0.14.1",
4545
"typescript": "^5.9.3",
46-
"typescript-eslint": "^8.56.1"
46+
"typescript-eslint": "^8.57.0"
4747
}
4848
}

src/content.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { defineCollection, z } from 'astro:content';
1+
import { defineCollection } from 'astro:content';
2+
import { z } from 'astro/zod';
23
import { glob, file } from 'astro/loaders';
34

45
const posts = defineCollection({
@@ -23,7 +24,7 @@ const authors = defineCollection({
2324
bio: z.string(),
2425
twitter: z.string().optional(),
2526
facebook: z.string().optional(),
26-
website: z.string().url().optional(),
27+
website: z.url().optional(),
2728
location: z.string().optional(),
2829
profile_image: z.string().optional(),
2930
}),
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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.
2.41 MB
Loading

0 commit comments

Comments
 (0)