@funstack/router 0.0.8 → 0.0.10

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.
@@ -72,19 +72,20 @@ export function ApiComponentsPage() {
72
72
  </tr>
73
73
  <tr>
74
74
  <td>
75
- <code>ssrPathname</code>
75
+ <code>ssr</code>
76
76
  </td>
77
77
  <td>
78
- <code>string</code>
78
+ <code>SSRConfig</code>
79
79
  </td>
80
80
  <td>
81
- Pathname to use for route matching during SSR. When provided,
82
- path-based routes match against this pathname on the server.
83
- Routes with loaders are always skipped during SSR. Once the
84
- client hydrates, the real URL from the Navigation API takes
85
- over. See the{" "}
86
- <a href="/learn/server-side-rendering">SSR guide</a> for
87
- details.
81
+ SSR configuration for route matching during server-side
82
+ rendering. Accepts an object with <code>path</code> (the
83
+ pathname to match against) and an optional{" "}
84
+ <code>runLoaders</code> boolean (defaults to <code>false</code>
85
+ ). When <code>runLoaders</code> is <code>false</code>, routes
86
+ with loaders are skipped during SSR. Once the client hydrates,
87
+ the real URL from the Navigation API takes over. See the{" "}
88
+ <a href="/learn/ssr">SSR guide</a> for details.
88
89
  </td>
89
90
  </tr>
90
91
  </tbody>
@@ -0,0 +1,445 @@
1
+ import { CodeBlock } from "../components/CodeBlock.js";
2
+
3
+ export function ExamplesPage() {
4
+ return (
5
+ <div className="page docs-page">
6
+ <h1>Examples</h1>
7
+
8
+ <section>
9
+ <h2>Basic Routing</h2>
10
+ <p>A simple example with home and about pages:</p>
11
+ <CodeBlock language="tsx">{`import { Router, route, Outlet, useNavigate } from "@funstack/router";
12
+
13
+ function Home() {
14
+ return <h1>Home Page</h1>;
15
+ }
16
+
17
+ function About() {
18
+ return <h1>About Page</h1>;
19
+ }
20
+
21
+ function Layout() {
22
+ const navigate = useNavigate();
23
+
24
+ return (
25
+ <div>
26
+ <nav>
27
+ <button onClick={() => navigate("/")}>Home</button>
28
+ <button onClick={() => navigate("/about")}>About</button>
29
+ </nav>
30
+ <Outlet />
31
+ </div>
32
+ );
33
+ }
34
+
35
+ const routes = [
36
+ route({
37
+ path: "/",
38
+ component: Layout,
39
+ children: [
40
+ route({ path: "/", component: Home }),
41
+ route({ path: "/about", component: About }),
42
+ ],
43
+ }),
44
+ ];
45
+
46
+ function App() {
47
+ return <Router routes={routes} />;
48
+ }`}</CodeBlock>
49
+ </section>
50
+
51
+ <section>
52
+ <h2>Dynamic Routes with Params</h2>
53
+ <p>
54
+ Handle dynamic URL segments with parameters. Components receive params
55
+ via props:
56
+ </p>
57
+ <CodeBlock language="tsx">{`import { route } from "@funstack/router";
58
+
59
+ function UserProfile({ params }: { params: { userId: string } }) {
60
+ return <h1>Viewing user: {params.userId}</h1>;
61
+ }
62
+
63
+ function PostDetail({
64
+ params,
65
+ }: {
66
+ params: { userId: string; postId: string };
67
+ }) {
68
+ return (
69
+ <div>
70
+ <h1>Post {params.postId}</h1>
71
+ <p>By user {params.userId}</p>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ const routes = [
77
+ route({
78
+ path: "/users/:userId",
79
+ component: UserProfile,
80
+ }),
81
+ route({
82
+ path: "/users/:userId/posts/:postId",
83
+ component: PostDetail,
84
+ }),
85
+ ];`}</CodeBlock>
86
+ </section>
87
+
88
+ <section>
89
+ <h2>Nested Routes</h2>
90
+ <p>Create complex layouts with nested routing:</p>
91
+ <CodeBlock language="tsx">{`import { route, Outlet } from "@funstack/router";
92
+
93
+ function Dashboard() {
94
+ return (
95
+ <div className="dashboard">
96
+ <aside>
97
+ <nav>Dashboard Menu</nav>
98
+ </aside>
99
+ <main>
100
+ <Outlet />
101
+ </main>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function DashboardHome() {
107
+ return <h2>Dashboard Overview</h2>;
108
+ }
109
+
110
+ function DashboardSettings() {
111
+ return <h2>Settings</h2>;
112
+ }
113
+
114
+ function DashboardProfile() {
115
+ return <h2>Your Profile</h2>;
116
+ }
117
+
118
+ const dashboardRoutes = route({
119
+ path: "/dashboard",
120
+ component: Dashboard,
121
+ children: [
122
+ route({ path: "/", component: DashboardHome }),
123
+ route({ path: "/settings", component: DashboardSettings }),
124
+ route({ path: "/profile", component: DashboardProfile }),
125
+ ],
126
+ });`}</CodeBlock>
127
+ </section>
128
+
129
+ <section>
130
+ <h2>Data Loading</h2>
131
+ <p>
132
+ Load data with loaders. When a loader returns a Promise, the component
133
+ receives that Promise and uses React's <code>use</code> hook to unwrap
134
+ it. Use <code>&lt;Suspense&gt;</code> within your pages or layouts to
135
+ handle loading states.
136
+ </p>
137
+ <CodeBlock language="tsx">{`import { use, Suspense } from "react";
138
+ import { route } from "@funstack/router";
139
+
140
+ interface Post {
141
+ id: number;
142
+ title: string;
143
+ body: string;
144
+ userId: number;
145
+ }
146
+
147
+ // When the loader returns a Promise, the component receives that Promise.
148
+ // Use React's \`use\` hook to unwrap the Promise.
149
+ function UserPostsContent({
150
+ data,
151
+ params,
152
+ }: {
153
+ data: Promise<Post[]>;
154
+ params: { userId: string };
155
+ }) {
156
+ const posts = use(data);
157
+ return (
158
+ <div>
159
+ <h2>Posts by user {params.userId}</h2>
160
+ <ul>
161
+ {posts.map((post) => (
162
+ <li key={post.id}>{post.title}</li>
163
+ ))}
164
+ </ul>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ // Wrap the content with Suspense at the page level
170
+ function UserPosts(props: {
171
+ data: Promise<Post[]>;
172
+ params: { userId: string };
173
+ }) {
174
+ return (
175
+ <Suspense fallback={<div>Loading posts...</div>}>
176
+ <UserPostsContent {...props} />
177
+ </Suspense>
178
+ );
179
+ }
180
+
181
+ const userPostsRoute = route({
182
+ path: "/users/:userId/posts",
183
+ component: UserPosts,
184
+ loader: async ({ params }): Promise<Post[]> => {
185
+ const response = await fetch(
186
+ \`https://jsonplaceholder.typicode.com/posts?userId=\${params.userId}\`
187
+ );
188
+ return response.json();
189
+ },
190
+ });`}</CodeBlock>
191
+ </section>
192
+
193
+ <section>
194
+ <h2>Form Submissions</h2>
195
+ <p>
196
+ Handle form POST submissions with route actions. The action receives
197
+ the form data, and its return value flows to the loader via{" "}
198
+ <code>actionResult</code>. Native <code>&lt;form&gt;</code> elements
199
+ work out of the box — no wrapper component needed.
200
+ </p>
201
+ <CodeBlock language="tsx">{`import { route, type RouteComponentPropsOf } from "@funstack/router";
202
+
203
+ // Define a route with both action and loader
204
+ const editUserRoute = route({
205
+ id: "editUser",
206
+ path: "/users/:userId/edit",
207
+ action: async ({ request, params, signal }) => {
208
+ const formData = await request.formData();
209
+ const response = await fetch(\`/api/users/\${params.userId}\`, {
210
+ method: "PUT",
211
+ body: formData,
212
+ signal,
213
+ });
214
+ return response.json() as Promise<{ success: boolean; error?: string }>;
215
+ },
216
+ loader: async ({ params, signal, actionResult }) => {
217
+ const user = await fetchUser(params.userId, signal);
218
+ return {
219
+ user,
220
+ // actionResult is undefined on normal navigation,
221
+ // contains the action's return value after form submission
222
+ updateResult: actionResult ?? null,
223
+ };
224
+ },
225
+ component: EditUserPage,
226
+ });
227
+
228
+ // Component receives data from the loader (which includes the action result)
229
+ function EditUserPage({ data, isPending }: RouteComponentPropsOf<typeof editUserRoute>) {
230
+ return (
231
+ <form method="post">
232
+ {data.updateResult?.error && (
233
+ <p className="error">{data.updateResult.error}</p>
234
+ )}
235
+ {data.updateResult?.success && (
236
+ <p className="success">User updated successfully!</p>
237
+ )}
238
+ <input name="name" defaultValue={data.user.name} />
239
+ <input name="email" defaultValue={data.user.email} />
240
+ <button type="submit" disabled={isPending}>
241
+ {isPending ? "Saving..." : "Save"}
242
+ </button>
243
+ </form>
244
+ );
245
+ }
246
+
247
+ // Route with action only (pure side effect, no data for component)
248
+ const deleteRoute = route({
249
+ path: "/users/:userId/delete",
250
+ action: async ({ params, signal }) => {
251
+ await fetch(\`/api/users/\${params.userId}\`, {
252
+ method: "DELETE",
253
+ signal,
254
+ });
255
+ },
256
+ component: DeleteConfirmation,
257
+ });`}</CodeBlock>
258
+ <p>Key behaviors:</p>
259
+ <ul>
260
+ <li>
261
+ Actions handle POST form submissions; loaders handle GET navigations
262
+ </li>
263
+ <li>Action results are never cached — each submission runs fresh</li>
264
+ <li>
265
+ After an action completes, all matched loaders are revalidated
266
+ </li>
267
+ <li>
268
+ POST submissions to routes without an action are not intercepted
269
+ (browser handles normally)
270
+ </li>
271
+ <li>
272
+ The deepest matched route with an action handles the submission
273
+ </li>
274
+ </ul>
275
+ </section>
276
+
277
+ <section>
278
+ <h2>Search Parameters</h2>
279
+ <p>Work with URL query parameters:</p>
280
+ <CodeBlock language="tsx">{`import { useSearchParams } from "@funstack/router";
281
+
282
+ function ProductList() {
283
+ const [searchParams, setSearchParams] = useSearchParams();
284
+
285
+ const category = searchParams.get("category") || "all";
286
+ const sortBy = searchParams.get("sort") || "name";
287
+
288
+ const handleCategoryChange = (newCategory: string) => {
289
+ setSearchParams({
290
+ category: newCategory,
291
+ sort: sortBy,
292
+ });
293
+ };
294
+
295
+ const handleSortChange = (newSort: string) => {
296
+ setSearchParams({
297
+ category,
298
+ sort: newSort,
299
+ });
300
+ };
301
+
302
+ return (
303
+ <div>
304
+ <select value={category} onChange={(e) => handleCategoryChange(e.target.value)}>
305
+ <option value="all">All Categories</option>
306
+ <option value="electronics">Electronics</option>
307
+ <option value="clothing">Clothing</option>
308
+ </select>
309
+
310
+ <select value={sortBy} onChange={(e) => handleSortChange(e.target.value)}>
311
+ <option value="name">Sort by Name</option>
312
+ <option value="price">Sort by Price</option>
313
+ </select>
314
+
315
+ <p>Showing {category} products, sorted by {sortBy}</p>
316
+ </div>
317
+ );
318
+ }`}</CodeBlock>
319
+ </section>
320
+
321
+ <section>
322
+ <h2>Pathless Routes</h2>
323
+ <p>
324
+ Routes without a <code>path</code> are called "pathless routes". They
325
+ always match and don't consume any pathname, making them ideal for
326
+ layout wrappers that don't affect the URL structure.
327
+ </p>
328
+ <CodeBlock language="tsx">{`import { route, Outlet } from "@funstack/router";
329
+
330
+ // A layout wrapper that provides authentication context
331
+ function AuthLayout() {
332
+ return (
333
+ <AuthProvider>
334
+ <Outlet />
335
+ </AuthProvider>
336
+ );
337
+ }
338
+
339
+ // A layout wrapper that provides a sidebar
340
+ function DashboardLayout() {
341
+ return (
342
+ <div className="dashboard">
343
+ <Sidebar />
344
+ <main>
345
+ <Outlet />
346
+ </main>
347
+ </div>
348
+ );
349
+ }
350
+
351
+ const routes = [
352
+ route({
353
+ path: "/",
354
+ component: RootLayout,
355
+ children: [
356
+ route({ path: "/", component: HomePage }),
357
+ route({ path: "/about", component: AboutPage }),
358
+ // Pathless route wraps authenticated pages
359
+ route({
360
+ component: AuthLayout,
361
+ children: [
362
+ // Another pathless route for dashboard layout
363
+ route({
364
+ component: DashboardLayout,
365
+ children: [
366
+ route({ path: "/dashboard", component: DashboardHome }),
367
+ route({ path: "/dashboard/settings", component: Settings }),
368
+ route({ path: "/dashboard/profile", component: Profile }),
369
+ ],
370
+ }),
371
+ ],
372
+ }),
373
+ ],
374
+ }),
375
+ ];`}</CodeBlock>
376
+ <p>Key behaviors of pathless routes:</p>
377
+ <ul>
378
+ <li>
379
+ <strong>Always match</strong> - They don't participate in path
380
+ matching
381
+ </li>
382
+ <li>
383
+ <strong>Consume no pathname</strong> - Children receive the full
384
+ remaining pathname
385
+ </li>
386
+ <li>
387
+ <strong>No params</strong> - The <code>params</code> prop is always
388
+ an empty object
389
+ </li>
390
+ <li>
391
+ <strong>Can have loaders</strong> - Pathless routes can still load
392
+ data
393
+ </li>
394
+ </ul>
395
+ <h3>Catch-All Routes</h3>
396
+ <p>
397
+ Use <code>path: "/*"</code> at the end of a route list to catch any
398
+ unmatched paths:
399
+ </p>
400
+ <CodeBlock language="tsx">{`const routes = [
401
+ route({ path: "/", component: HomePage }),
402
+ route({ path: "/about", component: AboutPage }),
403
+ // Catch-all: matches any unmatched path
404
+ route({ path: "/*", component: NotFoundPage }),
405
+ ];`}</CodeBlock>
406
+ </section>
407
+
408
+ <section>
409
+ <h2>Navigation Callback</h2>
410
+ <p>
411
+ React to navigation events. The callback receives the{" "}
412
+ <code>NavigateEvent</code> from the Navigation API and an info object
413
+ containing matched routes and whether the navigation will be
414
+ intercepted:
415
+ </p>
416
+ <CodeBlock language="tsx">{`import { Router, route, type OnNavigateCallback } from "@funstack/router";
417
+
418
+ function App() {
419
+ const handleNavigate: OnNavigateCallback = (event, info) => {
420
+ // Track page views
421
+ const url = new URL(event.destination.url);
422
+ analytics.track("page_view", {
423
+ path: url.pathname,
424
+ search: url.search,
425
+ });
426
+
427
+ // info.matches contains the matched routes (or null if no match)
428
+ // info.intercepting indicates if the router will handle this navigation
429
+ console.log("Matched routes:", info.matches);
430
+ console.log("Intercepting:", info.intercepting);
431
+
432
+ // You can call event.preventDefault() to cancel the navigation
433
+ };
434
+
435
+ return (
436
+ <Router
437
+ routes={routes}
438
+ onNavigate={handleNavigate}
439
+ />
440
+ );
441
+ }`}</CodeBlock>
442
+ </section>
443
+ </div>
444
+ );
445
+ }
@@ -26,8 +26,13 @@ yarn add @funstack/router`}</CodeBlock>
26
26
  <CodeBlock language="bash">{`npx funstack-router-skill-installer`}</CodeBlock>
27
27
  <p>
28
28
  The installer will guide you through setting up the skill for your
29
- preferred AI agent.
29
+ preferred AI agent. Alternatively, if you prefer{" "}
30
+ <a href="https://skills.sh/" target="_blank">
31
+ npx skills
32
+ </a>
33
+ , you can install it with:
30
34
  </p>
35
+ <CodeBlock language="bash">{`npx skills add uhyo/funstack-router`}</CodeBlock>
31
36
  </section>
32
37
 
33
38
  <section>
@@ -113,34 +118,6 @@ const routes = [
113
118
  component: UserProfile,
114
119
  }),
115
120
  ];`}</CodeBlock>
116
- <p>
117
- Alternatively, you can use the <code>useParams</code> hook to access
118
- parameters:
119
- </p>
120
- <CodeBlock language="tsx">{`import { useParams } from "@funstack/router";
121
-
122
- function UserProfile() {
123
- const params = useParams<{ userId: string }>();
124
- return <h1>User: {params.userId}</h1>;
125
- }`}</CodeBlock>
126
- </section>
127
-
128
- <section>
129
- <h2>Programmatic Navigation</h2>
130
- <p>
131
- Use the <code>useNavigate</code> hook for programmatic navigation:
132
- </p>
133
- <CodeBlock language="tsx">{`import { useNavigate } from "@funstack/router";
134
-
135
- function MyComponent() {
136
- const navigate = useNavigate();
137
-
138
- const handleClick = () => {
139
- navigate("/about");
140
- };
141
-
142
- return <button onClick={handleClick}>Go to About</button>;
143
- }`}</CodeBlock>
144
121
  </section>
145
122
 
146
123
  <section>
@@ -164,21 +141,21 @@ function UserProfilePage({
164
141
  data: Promise<User>;
165
142
  params: { userId: string };
166
143
  }) {
167
- const user = use(data);
168
144
  return (
169
145
  <Suspense fallback={<div>Loading...</div>}>
170
- <UserProfile user={user} params={params} />
146
+ <UserProfile data={data} params={params} />
171
147
  </Suspense>
172
148
  );
173
149
  }
174
150
 
175
151
  function UserProfile({
176
- user,
152
+ data,
177
153
  params,
178
154
  }: {
179
- user: User;
155
+ data: Promise<User>;
180
156
  params: { userId: string };
181
157
  }) {
158
+ const user = use(data);
182
159
  return (
183
160
  <div>
184
161
  <h1>{user.name}</h1>
@@ -155,15 +155,12 @@ function Navigation() {
155
155
  <p>
156
156
  While FUNSTACK Router handles navigation for you, you can interact
157
157
  directly with the Navigation API when needed. This is useful for
158
- features like scroll-to-top behavior or analytics tracking.
158
+ features like analytics tracking.
159
159
  </p>
160
160
  <CodeBlock language="tsx">{`import { useEffect } from "react";
161
161
 
162
162
  function App() {
163
163
  useEffect(() => {
164
- const navigation = window.navigation;
165
- if (!navigation) return;
166
-
167
164
  const controller = new AbortController();
168
165
 
169
166
  // Listen for successful navigation completion
@@ -338,7 +338,7 @@ const routes = [
338
338
  <CodeBlock language="tsx">{`import { use, Suspense } from "react";
339
339
  import { route, Outlet, useRouteData } from "@funstack/router";
340
340
 
341
- // Define the parent route with a loader
341
+ // Define the parent route with a loader and child routes
342
342
  const teamRoute = route({
343
343
  id: "team",
344
344
  path: "/teams/:teamId",
@@ -347,9 +347,14 @@ const teamRoute = route({
347
347
  const response = await fetch(\`/api/teams/\${params.teamId}\`);
348
348
  return response.json();
349
349
  },
350
+ children: [
351
+ route({ path: "/", component: TeamOverview }),
352
+ route({ path: "/members", component: TeamMembers }),
353
+ route({ path: "/settings", component: TeamSettings }),
354
+ ],
350
355
  });
351
356
 
352
- // Parent layout loads team data once
357
+ // Parent layout loads team data once, child routes render in <Outlet />
353
358
  function TeamLayoutContent({
354
359
  data,
355
360
  }: {
@@ -439,8 +444,7 @@ function TeamLayout(props: {
439
444
  Pathless routes also play a key role in server-side rendering. During
440
445
  SSR, only pathless routes render (since no URL is available on the
441
446
  server), making them ideal for defining the app shell. See the{" "}
442
- <a href="/learn/server-side-rendering">Server-Side Rendering</a> page
443
- for details.
447
+ <a href="/learn/ssr">Server-Side Rendering</a> page for details.
444
448
  </p>
445
449
  </section>
446
450