@funstack/router 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,601 @@
1
+ import { CodeBlock } from "../components/CodeBlock.js";
2
+
3
+ export function LearnNestedRoutesPage() {
4
+ return (
5
+ <div className="learn-content">
6
+ <h2>Nested Routes</h2>
7
+
8
+ <p className="page-intro">
9
+ <b>Nested routes</b> let you build complex page layouts where parts of
10
+ the UI persist across navigation while other parts change. Think of a
11
+ dashboard with a sidebar that stays in place while the main content area
12
+ updates&mdash;that's nested routing in action.
13
+ </p>
14
+
15
+ <section>
16
+ <h3>Why Nested Routes?</h3>
17
+ <p>
18
+ Consider a typical dashboard application. You have a header, a
19
+ sidebar, and a main content area. When users navigate between
20
+ different dashboard pages, the header and sidebar should remain
21
+ visible while only the main content changes.
22
+ </p>
23
+ <p>Without nested routes, you'd have two options:</p>
24
+ <ul>
25
+ <li>
26
+ <strong>Duplicate the layout</strong> in every page component,
27
+ leading to repetition and maintenance headaches
28
+ </li>
29
+ <li>
30
+ <strong>Use conditional rendering</strong> based on the current
31
+ route, which quickly becomes complex and hard to manage
32
+ </li>
33
+ </ul>
34
+ <p>
35
+ Nested routes solve this elegantly by letting you compose layouts
36
+ hierarchically. Parent routes define the persistent UI, and child
37
+ routes fill in the changing parts.
38
+ </p>
39
+ </section>
40
+
41
+ <section>
42
+ <h3>The Outlet Component</h3>
43
+ <p>
44
+ The key to nested routing is the <code>{"<Outlet>"}</code> component.
45
+ It acts as a placeholder in a parent route's component where child
46
+ routes will be rendered.
47
+ </p>
48
+ <CodeBlock language="tsx">{`import { Outlet } from "@funstack/router";
49
+
50
+ function DashboardLayout() {
51
+ return (
52
+ <div className="dashboard">
53
+ <aside className="sidebar">
54
+ <nav>
55
+ <a href="/dashboard">Overview</a>
56
+ <a href="/dashboard/analytics">Analytics</a>
57
+ <a href="/dashboard/settings">Settings</a>
58
+ </nav>
59
+ </aside>
60
+ <main className="content">
61
+ {/* Child routes render here */}
62
+ <Outlet />
63
+ </main>
64
+ </div>
65
+ );
66
+ }`}</CodeBlock>
67
+ <p>
68
+ When you navigate to <code>/dashboard/analytics</code>, the{" "}
69
+ <code>DashboardLayout</code> component renders the sidebar, and the{" "}
70
+ <code>{"<Outlet>"}</code> renders the Analytics page component.
71
+ </p>
72
+ </section>
73
+
74
+ <section>
75
+ <h3>Defining Nested Routes</h3>
76
+ <p>
77
+ Nested routes are defined using the <code>children</code> property in
78
+ your route definitions. Each child route's path is{" "}
79
+ <strong>relative to its parent</strong>.
80
+ </p>
81
+ <CodeBlock language="tsx">{`import { route } from "@funstack/router";
82
+
83
+ const routes = [
84
+ route({
85
+ path: "/dashboard",
86
+ component: DashboardLayout,
87
+ children: [
88
+ // Matches "/dashboard" exactly
89
+ route({
90
+ path: "/",
91
+ component: DashboardOverview,
92
+ }),
93
+ // Matches "/dashboard/analytics"
94
+ route({
95
+ path: "/analytics",
96
+ component: AnalyticsPage,
97
+ }),
98
+ // Matches "/dashboard/settings"
99
+ route({
100
+ path: "/settings",
101
+ component: SettingsPage,
102
+ }),
103
+ ],
104
+ }),
105
+ ];`}</CodeBlock>
106
+ <p>
107
+ Notice how child paths start with <code>/</code> but are relative to
108
+ the parent. The path <code>/analytics</code> under a parent with path{" "}
109
+ <code>/dashboard</code> matches the full URL{" "}
110
+ <code>/dashboard/analytics</code>.
111
+ </p>
112
+ </section>
113
+
114
+ <section>
115
+ <h3>Route Matching Behavior</h3>
116
+ <p>
117
+ Understanding how routes match URLs is crucial for nested routing. The
118
+ router uses different matching strategies depending on whether a route
119
+ has children.
120
+ </p>
121
+
122
+ <h4>Parent Routes: Prefix Matching</h4>
123
+ <p>
124
+ Routes with <code>children</code> use <strong>prefix matching</strong>
125
+ . They match any URL that starts with their path, allowing child
126
+ routes to match the remaining portion.
127
+ </p>
128
+ <CodeBlock language="tsx">{`route({
129
+ path: "/dashboard", // Matches "/dashboard", "/dashboard/settings", etc.
130
+ component: DashboardLayout,
131
+ children: [
132
+ // Children handle the rest of the URL
133
+ ],
134
+ })`}</CodeBlock>
135
+ <p>
136
+ When you navigate to <code>/dashboard/settings/profile</code>, the
137
+ parent route <code>/dashboard</code> matches and consumes that portion
138
+ of the URL. The remaining <code>/settings/profile</code> is then
139
+ matched against its children.
140
+ </p>
141
+
142
+ <h4>Leaf Routes: Exact Matching</h4>
143
+ <p>
144
+ Routes without <code>children</code> (leaf routes) use{" "}
145
+ <strong>exact matching</strong> by default. They only match when the
146
+ URL exactly matches their full path.
147
+ </p>
148
+ <CodeBlock language="tsx">{`route({
149
+ path: "/dashboard",
150
+ component: DashboardLayout,
151
+ children: [
152
+ route({
153
+ path: "/settings", // Only matches "/dashboard/settings" exactly
154
+ component: SettingsPage,
155
+ }),
156
+ ],
157
+ })`}</CodeBlock>
158
+ <p>
159
+ With this configuration, <code>/dashboard/settings</code> matches, but{" "}
160
+ <code>/dashboard/settings/advanced</code> does not&mdash;there's no
161
+ child route to handle <code>/advanced</code>.
162
+ </p>
163
+
164
+ <h4>The Index Route Pattern</h4>
165
+ <p>
166
+ A common pattern is using <code>path: "/"</code> as an index route.
167
+ This matches when the parent's path is matched exactly with no
168
+ additional segments.
169
+ </p>
170
+ <CodeBlock language="tsx">{`route({
171
+ path: "/dashboard",
172
+ component: DashboardLayout,
173
+ children: [
174
+ route({
175
+ path: "/", // Matches "/dashboard" exactly
176
+ component: DashboardHome,
177
+ }),
178
+ route({
179
+ path: "/settings", // Matches "/dashboard/settings"
180
+ component: SettingsPage,
181
+ }),
182
+ ],
183
+ })`}</CodeBlock>
184
+ <p>
185
+ Here, navigating to <code>/dashboard</code> renders{" "}
186
+ <code>DashboardLayout</code> with <code>DashboardHome</code> in its
187
+ outlet. Navigating to <code>/dashboard/settings</code> renders{" "}
188
+ <code>DashboardLayout</code> with <code>SettingsPage</code> instead.
189
+ </p>
190
+
191
+ <h4>Forcing Exact Matching</h4>
192
+ <p>
193
+ Sometimes you want a parent route to only match its exact path, not
194
+ act as a prefix. Use the <code>exact</code> option:
195
+ </p>
196
+ <CodeBlock language="tsx">{`route({
197
+ path: "/blog",
198
+ exact: true, // Only matches "/blog", not "/blog/post-1"
199
+ component: BlogIndex,
200
+ children: [
201
+ route({
202
+ path: "/:slug", // This won't match because parent requires exact
203
+ component: BlogPost,
204
+ }),
205
+ ],
206
+ })`}</CodeBlock>
207
+ <p>
208
+ With <code>exact: true</code>, the route only matches when the URL is
209
+ exactly <code>/blog</code>. URLs like <code>/blog/post-1</code> won't
210
+ match this route at all. This is rarely needed but useful when you
211
+ want a route to behave as a leaf even though it has children defined.
212
+ </p>
213
+
214
+ <h4>Requiring Children to Match</h4>
215
+ <p>
216
+ By default, parent routes <strong>require</strong> at least one child
217
+ route to match. If no children match, the parent doesn't match
218
+ either&mdash;allowing other routes (like a catch-all) to handle the
219
+ URL instead.
220
+ </p>
221
+ <CodeBlock language="tsx">{`const routes = [
222
+ route({
223
+ path: "/dashboard",
224
+ component: DashboardLayout,
225
+ children: [
226
+ route({ path: "/", component: DashboardHome }),
227
+ route({ path: "/settings", component: SettingsPage }),
228
+ ],
229
+ }),
230
+ route({
231
+ path: "/*", // Catch-all for unmatched routes
232
+ component: NotFoundPage,
233
+ }),
234
+ ];
235
+
236
+ // /dashboard → matches DashboardLayout + DashboardHome
237
+ // /dashboard/settings → matches DashboardLayout + SettingsPage
238
+ // /dashboard/unknown → matches NotFoundPage (not DashboardLayout)`}</CodeBlock>
239
+ <p>
240
+ This behavior ensures that catch-all routes work intuitively. Without
241
+ it, <code>/dashboard/unknown</code> would match the dashboard layout
242
+ with an empty outlet, which is usually not desired.
243
+ </p>
244
+ <p>
245
+ If you want a parent route to match even when no children match, set{" "}
246
+ <code>requireChildren: false</code>. The <code>{"<Outlet>"}</code>{" "}
247
+ will render <code>null</code> in this case.
248
+ </p>
249
+ <CodeBlock language="tsx">{`route({
250
+ path: "/files",
251
+ component: FileExplorer,
252
+ requireChildren: false, // Match even without child matches
253
+ children: [
254
+ route({ path: "/:fileId", component: FileDetails }),
255
+ ],
256
+ });
257
+
258
+ // /files → matches FileExplorer (outlet is null)
259
+ // /files/123 → matches FileExplorer + FileDetails`}</CodeBlock>
260
+ </section>
261
+
262
+ <section>
263
+ <h3>Multiple Levels of Nesting</h3>
264
+ <p>
265
+ You can nest routes as deeply as your application requires. Each level
266
+ can have its own layout component with an <code>{"<Outlet>"}</code>.
267
+ </p>
268
+ <CodeBlock language="tsx">{`const routes = [
269
+ route({
270
+ path: "/",
271
+ component: RootLayout, // Header, footer
272
+ children: [
273
+ route({
274
+ path: "/",
275
+ component: HomePage,
276
+ }),
277
+ route({
278
+ path: "/dashboard",
279
+ component: DashboardLayout, // Adds sidebar
280
+ children: [
281
+ route({
282
+ path: "/",
283
+ component: DashboardHome,
284
+ }),
285
+ route({
286
+ path: "/settings",
287
+ component: SettingsLayout, // Adds settings tabs
288
+ children: [
289
+ route({
290
+ path: "/",
291
+ component: GeneralSettings,
292
+ }),
293
+ route({
294
+ path: "/security",
295
+ component: SecuritySettings,
296
+ }),
297
+ route({
298
+ path: "/notifications",
299
+ component: NotificationSettings,
300
+ }),
301
+ ],
302
+ }),
303
+ ],
304
+ }),
305
+ ],
306
+ }),
307
+ ];`}</CodeBlock>
308
+ <p>
309
+ When you navigate to <code>/dashboard/settings/security</code>, the
310
+ rendering stack looks like:
311
+ </p>
312
+ <ol>
313
+ <li>
314
+ <code>RootLayout</code> renders the header and footer with an{" "}
315
+ <code>{"<Outlet>"}</code>
316
+ </li>
317
+ <li>
318
+ <code>DashboardLayout</code> renders the sidebar with an{" "}
319
+ <code>{"<Outlet>"}</code>
320
+ </li>
321
+ <li>
322
+ <code>SettingsLayout</code> renders the settings tabs with an{" "}
323
+ <code>{"<Outlet>"}</code>
324
+ </li>
325
+ <li>
326
+ <code>SecuritySettings</code> renders the actual page content
327
+ </li>
328
+ </ol>
329
+ </section>
330
+
331
+ <section>
332
+ <h3>Sharing Data with Loaders</h3>
333
+ <p>
334
+ Parent routes can load data that child routes need. This is
335
+ particularly useful for loading user information, permissions, or
336
+ other shared data once at the parent level.
337
+ </p>
338
+ <CodeBlock language="tsx">{`import { use, Suspense } from "react";
339
+ import { route, Outlet, useRouteData } from "@funstack/router";
340
+
341
+ // Define the parent route with a loader
342
+ const teamRoute = route({
343
+ id: "team",
344
+ path: "/teams/:teamId",
345
+ component: TeamLayout,
346
+ loader: async ({ params }) => {
347
+ const response = await fetch(\`/api/teams/\${params.teamId}\`);
348
+ return response.json();
349
+ },
350
+ });
351
+
352
+ // Parent layout loads team data once
353
+ function TeamLayoutContent({
354
+ data,
355
+ }: {
356
+ data: Promise<{ name: string; members: string[] }>;
357
+ }) {
358
+ const team = use(data);
359
+ return (
360
+ <div>
361
+ <h1>{team.name}</h1>
362
+ <nav>
363
+ <a href="members">Members ({team.members.length})</a>
364
+ <a href="settings">Settings</a>
365
+ </nav>
366
+ <Outlet />
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function TeamLayout(props: {
372
+ data: Promise<{ name: string; members: string[] }>;
373
+ }) {
374
+ return (
375
+ <Suspense fallback={<div>Loading team...</div>}>
376
+ <TeamLayoutContent {...props} />
377
+ </Suspense>
378
+ );
379
+ }`}</CodeBlock>
380
+ <p>
381
+ Child routes can access the parent's loaded data using the{" "}
382
+ <code>useRouteData</code> hook with the parent's route ID:
383
+ </p>
384
+ <CodeBlock language="tsx">{`function TeamMembers() {
385
+ // Access parent route's data by route ID
386
+ const teamData = useRouteData(teamRoute);
387
+ const team = use(teamData);
388
+
389
+ return (
390
+ <ul>
391
+ {team.members.map((member) => (
392
+ <li key={member}>{member}</li>
393
+ ))}
394
+ </ul>
395
+ );
396
+ }`}</CodeBlock>
397
+ </section>
398
+
399
+ <section>
400
+ <h3>Layout Routes Without Paths</h3>
401
+ <p>
402
+ Sometimes you want to wrap a group of routes in a layout without
403
+ adding a path segment. These are called <b>pathless routes</b>
404
+ &mdash;routes that provide UI structure without affecting the URL.
405
+ </p>
406
+ <CodeBlock language="tsx">{`const routes = [
407
+ route({
408
+ path: "/",
409
+ component: RootLayout,
410
+ children: [
411
+ route({ path: "/", component: HomePage }),
412
+ route({ path: "/about", component: AboutPage }),
413
+
414
+ // Pathless route - doesn't add to the URL
415
+ route({
416
+ component: AuthenticatedLayout, // No path property
417
+ children: [
418
+ route({ path: "/dashboard", component: Dashboard }),
419
+ route({ path: "/profile", component: Profile }),
420
+ route({ path: "/settings", component: Settings }),
421
+ ],
422
+ }),
423
+ ],
424
+ }),
425
+ ];`}</CodeBlock>
426
+ <p>
427
+ The <code>AuthenticatedLayout</code> component wraps{" "}
428
+ <code>/dashboard</code>, <code>/profile</code>, and{" "}
429
+ <code>/settings</code> without adding anything to their URLs. This is
430
+ perfect for:
431
+ </p>
432
+ <ul>
433
+ <li>Authentication wrappers that check if users are logged in</li>
434
+ <li>Feature flag wrappers that conditionally show features</li>
435
+ <li>Context providers that supply data to a group of routes</li>
436
+ <li>Error boundaries for a subset of your application</li>
437
+ </ul>
438
+ <p>
439
+ Pathless routes also play a key role in server-side rendering. During
440
+ SSR, only pathless routes render (since no URL is available on the
441
+ server), making them ideal for defining the app shell. See the{" "}
442
+ <a href="/funstack-router/learn/server-side-rendering">
443
+ Server-Side Rendering
444
+ </a>{" "}
445
+ page for details.
446
+ </p>
447
+ </section>
448
+
449
+ <section>
450
+ <h3>Building a Complete Example</h3>
451
+ <p>
452
+ Let's put it all together with a realistic example&mdash;a project
453
+ management application with nested layouts.
454
+ </p>
455
+ <CodeBlock language="tsx">{`import { Router, route, Outlet } from "@funstack/router";
456
+ import { Suspense } from "react";
457
+
458
+ // Root layout with app-wide header
459
+ function AppLayout() {
460
+ return (
461
+ <div className="app">
462
+ <header>
463
+ <h1>Project Manager</h1>
464
+ <nav>
465
+ <a href="/">Home</a>
466
+ <a href="/projects">Projects</a>
467
+ </nav>
468
+ </header>
469
+ <Outlet />
470
+ <footer>© 2024 Project Manager</footer>
471
+ </div>
472
+ );
473
+ }
474
+
475
+ // Projects section layout with project list sidebar
476
+ function ProjectsLayout() {
477
+ return (
478
+ <div className="projects-layout">
479
+ <aside className="project-list">
480
+ <h2>Your Projects</h2>
481
+ {/* Project list would be loaded here */}
482
+ </aside>
483
+ <main>
484
+ <Outlet />
485
+ </main>
486
+ </div>
487
+ );
488
+ }
489
+
490
+ // Individual project layout with project-specific navigation
491
+ function ProjectLayout({ params }: { params: { projectId: string } }) {
492
+ return (
493
+ <div className="project">
494
+ <nav className="project-nav">
495
+ <a href={\`/projects/\${params.projectId}\`}>Overview</a>
496
+ <a href={\`/projects/\${params.projectId}/tasks\`}>Tasks</a>
497
+ <a href={\`/projects/\${params.projectId}/team\`}>Team</a>
498
+ </nav>
499
+ <Outlet />
500
+ </div>
501
+ );
502
+ }
503
+
504
+ // Route definitions
505
+ const routes = [
506
+ route({
507
+ path: "/",
508
+ component: AppLayout,
509
+ children: [
510
+ route({
511
+ path: "/",
512
+ component: HomePage,
513
+ }),
514
+ route({
515
+ path: "/projects",
516
+ component: ProjectsLayout,
517
+ children: [
518
+ route({
519
+ path: "/",
520
+ component: ProjectListPage,
521
+ }),
522
+ route({
523
+ path: "/:projectId",
524
+ component: ProjectLayout,
525
+ children: [
526
+ route({
527
+ path: "/",
528
+ component: ProjectOverview,
529
+ }),
530
+ route({
531
+ path: "/tasks",
532
+ component: ProjectTasks,
533
+ }),
534
+ route({
535
+ path: "/tasks/:taskId",
536
+ component: TaskDetail,
537
+ }),
538
+ route({
539
+ path: "/team",
540
+ component: ProjectTeam,
541
+ }),
542
+ ],
543
+ }),
544
+ ],
545
+ }),
546
+ ],
547
+ }),
548
+ ];
549
+
550
+ function App() {
551
+ return <Router routes={routes} />;
552
+ }`}</CodeBlock>
553
+ <p>With this structure:</p>
554
+ <ul>
555
+ <li>
556
+ <code>/</code> shows <code>AppLayout</code> &rarr;{" "}
557
+ <code>HomePage</code>
558
+ </li>
559
+ <li>
560
+ <code>/projects</code> shows <code>AppLayout</code> &rarr;{" "}
561
+ <code>ProjectsLayout</code> &rarr; <code>ProjectListPage</code>
562
+ </li>
563
+ <li>
564
+ <code>/projects/123</code> shows <code>AppLayout</code> &rarr;{" "}
565
+ <code>ProjectsLayout</code> &rarr; <code>ProjectLayout</code> &rarr;{" "}
566
+ <code>ProjectOverview</code>
567
+ </li>
568
+ <li>
569
+ <code>/projects/123/tasks/456</code> shows <code>AppLayout</code>{" "}
570
+ &rarr; <code>ProjectsLayout</code> &rarr; <code>ProjectLayout</code>{" "}
571
+ &rarr; <code>TaskDetail</code> (with both{" "}
572
+ <code>projectId: "123"</code> and <code>taskId: "456"</code>)
573
+ </li>
574
+ </ul>
575
+ </section>
576
+
577
+ <section>
578
+ <h3>Key Takeaways</h3>
579
+ <ul>
580
+ <li>
581
+ Use <code>{"<Outlet>"}</code> to mark where child routes should
582
+ render
583
+ </li>
584
+ <li>Child route paths are relative to their parent route's path</li>
585
+ <li>
586
+ Parent routes use prefix matching; leaf routes use exact matching
587
+ </li>
588
+ <li>Use pathless routes for layouts that don't affect the URL</li>
589
+ <li>
590
+ Parent route loaders run before children, making them ideal for
591
+ shared data
592
+ </li>
593
+ <li>
594
+ Deep nesting is supported&mdash;compose as many layout levels as you
595
+ need
596
+ </li>
597
+ </ul>
598
+ </section>
599
+ </div>
600
+ );
601
+ }