waymark 0.2.2 → 0.3.0

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.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark" width="400" />
2
+ <img src="https://raw.githubusercontent.com/strblr/waymark/master/banner.svg" alt="Waymark" />
3
3
  </p>
4
4
 
5
5
  <p align="center">
@@ -8,28 +8,30 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/v/waymark?style=flat-square&color=000&labelColor=000" alt="npm version" /></a>
11
- <a href="https://bundlephobia.com/package/waymark"><img src="https://img.shields.io/bundlephobia/minzip/waymark?style=flat-square&color=000&labelColor=000" alt="bundle size" /></a>
11
+ <a href="https://www.npmjs.com/package/waymark"><img src="https://img.badgesize.io/https://unpkg.com/waymark/dist/index.js?compression=gzip&label=gzip&style=flat-square&color=000&labelColor=000" alt="gzip size" /></a>
12
12
  <a href="https://www.npmjs.com/package/waymark"><img src="https://img.shields.io/npm/dm/waymark?style=flat-square&color=000&labelColor=000" alt="downloads" /></a>
13
13
  <a href="https://github.com/strblr/waymark/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/waymark?style=flat-square&color=000&labelColor=000" alt="license" /></a>
14
+ <a href="https://github.com/sponsors/strblr"><img src="https://img.shields.io/github/sponsors/strblr?style=flat-square&color=000&labelColor=000" alt="sponsors" /></a>
14
15
  </p>
15
16
 
16
17
  <p align="center">
17
- <a href="https://strblr.github.io/waymark">📖 Documentation</a>
18
+ <a href="https://waymark.strblr.workers.dev">📖 Documentation</a>
18
19
  </p>
19
20
 
20
21
  ---
21
22
 
22
23
  Waymark is a routing library for React built around three core ideas: **type safety**, **simplicity**, and **minimal overhead**.
23
24
 
24
- - **Fully type-safe** - Complete TypeScript inference for routes, params, and search queries
25
+ - **Fully type-safe** - Complete TypeScript inference for routes, path params, and search params
25
26
  - **Zero config** - No build plugins, no CLI tools, no configuration files, very low boilerplate
26
27
  - **Familiar API** - If you've used React Router or TanStack Router, you'll feel at home
27
- - **3.5kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so less than 4kB total
28
+ - **3.6kB gzipped** - Extremely lightweight with just one 0.4kB dependency, so around 4kB total
29
+ - **Not vibe-coded** - Built with careful design and attention to detail by a human
28
30
  - **Just works** - Define routes, get autocomplete everywhere
29
31
 
30
32
  ---
31
33
 
32
- ## Table of contents
34
+ # Table of contents
33
35
 
34
36
  - [Showcase](#showcase)
35
37
  - [Installation](#installation)
@@ -37,39 +39,46 @@ Waymark is a routing library for React built around three core ideas: **type saf
37
39
  - [Nested routes and layouts](#nested-routes-and-layouts)
38
40
  - [Setting up the router](#setting-up-the-router)
39
41
  - [Code organization](#code-organization)
42
+ - [Path params](#path-params)
43
+ - [Search params](#search-params)
44
+ - [Basic usage](#basic-usage)
45
+ - [JSON-first approach](#json-first-approach)
46
+ - [Inheritance](#inheritance)
47
+ - [Idempotency requirement](#idempotency-requirement)
40
48
  - [Navigation](#navigation)
41
49
  - [The Link component](#the-link-component)
42
50
  - [Active state detection](#active-state-detection)
43
- - [Link preloading](#link-preloading)
51
+ - [Route preloading](#route-preloading)
44
52
  - [Programmatic navigation](#programmatic-navigation)
45
53
  - [Declarative navigation](#declarative-navigation)
46
- - [Path parameters](#path-parameters)
47
- - [Search queries](#search-queries)
48
54
  - [Lazy loading](#lazy-loading)
55
+ - [Data preloading](#data-preloading)
49
56
  - [Error boundaries](#error-boundaries)
50
57
  - [Suspense boundaries](#suspense-boundaries)
51
58
  - [Route handles](#route-handles)
52
59
  - [Route matching and ranking](#route-matching-and-ranking)
53
60
  - [History implementations](#history-implementations)
54
61
  - [Cookbook](#cookbook)
62
+ - [Quick start example](#quick-start-example)
63
+ - [Server-side rendering (SSR)](#server-side-rendering-ssr)
55
64
  - [Scroll to top on navigation](#scroll-to-top-on-navigation)
65
+ - [Matching a route anywhere](#matching-a-route-anywhere)
56
66
  - [Global link configuration](#global-link-configuration)
57
67
  - [History middleware](#history-middleware)
58
68
  - [View transitions](#view-transitions)
59
- - [Matching a route anywhere](#matching-a-route-anywhere)
60
69
  - [API reference](#api-reference)
61
- - [Types](#types)
62
70
  - [Router class](#router-class)
63
- - [History interface](#history-interface)
64
71
  - [Route class](#route-class)
65
72
  - [Hooks](#hooks)
66
73
  - [Components](#components)
74
+ - [History interface](#history-interface)
75
+ - [Types](#types)
67
76
  - [Roadmap](#roadmap)
68
77
  - [License](#license)
69
78
 
70
79
  ---
71
80
 
72
- ## Showcase
81
+ # Showcase
73
82
 
74
83
  Here's what a small routing setup looks like. Define some routes, render them, and get full type safety:
75
84
 
@@ -106,11 +115,11 @@ declare module "waymark" {
106
115
  }
107
116
  ```
108
117
 
109
- Links, navigation, params, search queries - everything autocompletes and type-checks automatically. That's it. No config files, no build plugins, no CLI.
118
+ Links, navigation, path params, search params - everything autocompletes and type-checks automatically. That's it. No config files, no build plugins, no CLI.
110
119
 
111
120
  ---
112
121
 
113
- ## Installation
122
+ # Installation
114
123
 
115
124
  ```bash
116
125
  npm install waymark
@@ -120,7 +129,7 @@ Waymark requires React 18 or higher.
120
129
 
121
130
  ---
122
131
 
123
- ## Defining routes
132
+ # Defining routes
124
133
 
125
134
  Routes are created using the `route()` function, following the [builder pattern](https://dev.to/superviz/design-pattern-7-builder-pattern-10j4). You pass it a path and chain methods to configure the route.
126
135
 
@@ -154,11 +163,11 @@ Route building is immutable: every method on a route returns a new route instanc
154
163
 
155
164
  ---
156
165
 
157
- ## Nested routes and layouts
166
+ # Nested routes and layouts
158
167
 
159
- Nesting is the core mechanism for building layouts and route hierarchies in Waymark. When you call `.route()` on an existing route, you create a child route that inherits everything from the parent: its path as a prefix, its params, its components, its handles, and its search mappers.
168
+ Nesting is the core mechanism for building layouts and route hierarchies in Waymark. When you call `.route()` on an existing route, you create a child route that inherits everything from the parent: its path as a prefix, its params, its components, etc.
160
169
 
161
- Here's how it works. Let's start with a layout route:
170
+ Here's how it works. Start with any route:
162
171
 
163
172
  ```tsx
164
173
  const dashboard = route("/dashboard").component(DashboardLayout);
@@ -174,7 +183,7 @@ const profile = dashboard.route("/profile").component(Profile);
174
183
 
175
184
  The child routes combine the parent's path pattern with their own. So `overview` has the full pattern `/dashboard`, `settings` has `/dashboard/settings`, and `profile` has `/dashboard/profile`.
176
185
 
177
- For this to work, the parent component must render an `<Outlet />` where these children should appear:
186
+ The parent component must render an `<Outlet />` where child routes should appear:
178
187
 
179
188
  ```tsx
180
189
  function DashboardLayout() {
@@ -189,7 +198,7 @@ function DashboardLayout() {
189
198
  }
190
199
  ```
191
200
 
192
- When the URL is `/dashboard/settings`, Waymark renders `DashboardLayout` with `Settings` inside the outlet. The layout stays mounted (and doesn't even rerender) as users navigate between child routes, preserving any state it holds.
201
+ When the URL is `/dashboard/settings`, Waymark renders `DashboardLayout` with `Settings` inside the outlet. The layout stays mounted (and doesn't even rerender) as users navigate between child routes.
193
202
 
194
203
  You can nest as deep as you need:
195
204
 
@@ -213,7 +222,7 @@ Each level must include an `<Outlet />` to render the next level.
213
222
 
214
223
  ---
215
224
 
216
- ## Setting up the router
225
+ # Setting up the router
217
226
 
218
227
  Before setting up the router, you need to collect your navigable routes into an array. When building nested route hierarchies, you'll often create intermediate parent routes solely for grouping and shared layouts. These intermediate routes shouldn't be included in your routes array - only the final, navigable routes should be:
219
228
 
@@ -229,7 +238,7 @@ const about = layout.route("/about").component(About);
229
238
  const routes = [home, about]; // ✅ Don't include `layout`
230
239
  ```
231
240
 
232
- This keeps your route list clean and makes sure that only actual pages can be matched and appear in autocomplete when using `Link` or `navigate`. The intermediate routes still exist - they're part of the hierarchy - they just aren't directly navigable.
241
+ This makes sure that only actual pages can be matched and appear in autocomplete. The intermediate routes still exist as part of the hierarchy, they just aren't directly navigable. Note that the order of routes in the array doesn't matter - Waymark uses a [ranking algorithm](#route-matching-and-ranking) to pick the most specific match.
233
242
 
234
243
  The `RouterRoot` component is the entry point to Waymark. It listens to URL changes, matches the current path against your routes, and renders the matching route's component hierarchy.
235
244
 
@@ -251,7 +260,7 @@ You can also pass a `basePath` if your app lives under a subpath:
251
260
  <RouterRoot routes={routes} basePath="/my-app" />
252
261
  ```
253
262
 
254
- The second approach is to create a `Router` instance outside of React. This is useful when you need to access the router from anywhere in your code, for example to navigate programmatically from a non-React context, or when you don't want to bother with `useRouter` / `useNavigate`:
263
+ The second approach is to create a `Router` instance outside of React. This gives you a global router instance that can be accessed from non-React contexts (e.g., utility functions, service modules, or other non-React code):
255
264
 
256
265
  ```tsx
257
266
  import { Router, RouterRoot } from "waymark";
@@ -281,7 +290,7 @@ With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs
281
290
 
282
291
  ---
283
292
 
284
- ## Code organization
293
+ # Code organization
285
294
 
286
295
  There's no prescribed way to organize your routing code. Since Waymark isn't file-based routing, the structure is entirely up to you.
287
296
 
@@ -330,13 +339,183 @@ declare module "waymark" {
330
339
  }
331
340
  ```
332
341
 
333
- But again, this is just one approach. You could keep all routes in a single file, split them by feature, organize them by route depth, whatever fits your project. Waymark doesn't care where the route objects come from or how you structure your files.
342
+ But again, this is just one approach. You could keep all routes in a single file, split them by feature, organize them by route depth, whatever fits your project. Waymark doesn't care where the routes come from or how you structure your files.
343
+
344
+ ---
345
+
346
+ # Path params
347
+
348
+ Dynamic segments in route patterns become typed path params. Define them with a colon prefix. They can also be made optional.
349
+
350
+ ```tsx
351
+ const post = route("/posts/:id").component(PostPage);
352
+ const comment = route("/posts/:postId/comments/:commentId?").component(
353
+ CommentPage
354
+ );
355
+ ```
356
+
357
+ Access parameters with `useParams`, passing the route pattern or object as an argument:
358
+
359
+ ```tsx
360
+ function PostPage() {
361
+ const { id } = useParams(post);
362
+ // id is typed as string
363
+
364
+ const { id } = useParams("/posts/:id");
365
+ // Also works
366
+ }
367
+
368
+ function CommentPage() {
369
+ const { postId, commentId } = useParams(comment);
370
+ // postId: string
371
+ // commentId?: string | undefined
372
+ }
373
+ ```
374
+
375
+ Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
376
+
377
+ ```tsx
378
+ const files = route("/files/*").component(FileBrowser);
379
+
380
+ function FileBrowser() {
381
+ const params = useParams(files);
382
+ const path = params["*"]; // e.g., "documents/report.pdf"
383
+ }
384
+ ```
385
+
386
+ ---
387
+
388
+ # Search params
389
+
390
+ ## Basic usage
391
+
392
+ Search params (the `?key=value` part of URLs) can be typed and validated using the `.search()` method on a route. You can pass either a [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) validator like Zod, or a plain validation function.
393
+
394
+ With Zod:
395
+
396
+ ```tsx
397
+ import { z } from "zod";
398
+
399
+ const searchPage = route("/search")
400
+ .search(
401
+ z.object({
402
+ q: z.string().catch(""),
403
+ page: z.coerce.number().catch(1)
404
+ })
405
+ )
406
+ .component(SearchPage);
407
+ ```
408
+
409
+ With a plain function:
410
+
411
+ ```tsx
412
+ const searchPage = route("/search")
413
+ .search(raw => ({
414
+ q: String(raw.q ?? ""),
415
+ page: Number(raw.page ?? 1)
416
+ }))
417
+ .component(SearchPage);
418
+ ```
419
+
420
+ Since you can't control what users put in the URL, your validator should handle missing or malformed values gracefully - validate and normalize rather than reject.
421
+
422
+ Access validated search params with `useSearch`, which returns a tuple of the current values and a setter function:
423
+
424
+ ```tsx
425
+ function SearchPage() {
426
+ const [search, setSearch] = useSearch(searchPage);
427
+ // search.q: string
428
+ // search.page: number
429
+ }
430
+ ```
431
+
432
+ The setter merges your updates with existing values:
433
+
434
+ ```tsx
435
+ setSearch({ page: 2 }); // Only updates page
436
+ setSearch(prev => ({ page: prev.page + 1 })); // Increment page
437
+ ```
438
+
439
+ Pass `true` as the second argument to replace the history entry instead of pushing:
440
+
441
+ ```tsx
442
+ setSearch({ page: 1 }, true);
443
+ ```
444
+
445
+ ## JSON-first approach
446
+
447
+ Waymark uses a JSON-first approach for search params, similar to TanStack Router. When serializing and deserializing values from the URL:
448
+
449
+ - Plain strings that aren't valid JSON are kept as-is (and URL-encoded): `"John"` → `?name=John` → `"John"`
450
+ - Everything else is JSON-encoded (then URL-encoded):
451
+ - `true` → `?enabled=true` → `true`
452
+ - `"true"` → `?enabled=%22true%22` → `"true"`
453
+ - `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
454
+ - `42` → `count=42` → `42`
455
+
456
+ This means you can store complex data structures like arrays and objects in search params without manual serialization. When reading from the URL, Waymark automatically parses JSON values back to their original types.
457
+
458
+ The resulting parsed object is what gets passed to the `.search()` function or schema on the route builder. It's typed as `Record<string, unknown>`, which is why validation is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
459
+
460
+ ## Inheritance
461
+
462
+ When you define search params with a validator on a route, all child routes automatically inherit that validator along with its typing.
463
+
464
+ Here's how it works. Start with a parent route that defines a search param:
465
+
466
+ ```tsx
467
+ const dashboard = route("/dashboard")
468
+ .search(
469
+ z.object({
470
+ view: z.enum(["grid", "list"]).catch("grid")
471
+ })
472
+ )
473
+ .component(DashboardLayout);
474
+ ```
475
+
476
+ Any child route created from `dashboard` inherits the `view` search param and its validation:
477
+
478
+ ```tsx
479
+ const projects = dashboard.route("/projects").component(ProjectsPage);
480
+
481
+ function ProjectsPage() {
482
+ const [search] = useSearch(projects);
483
+ // search.view is typed as "grid" | "list"
484
+ }
485
+ ```
486
+
487
+ If a child route needs additional search params, define a new validator with `.search()`. Your validator receives the raw params from the URL merged with the parent's already-validated params. After validation, your result is combined with the parent's validated params to produce the final search params object.
488
+
489
+ In practice, this means you only need to validate the new params you're adding - the parent's params are automatically included in the final result:
490
+
491
+ ```tsx
492
+ const projects = dashboard
493
+ .route("/projects")
494
+ .search(
495
+ z.object({
496
+ status: z.enum(["active", "archived"]).catch("active")
497
+ })
498
+ )
499
+ .component(ProjectsPage);
500
+
501
+ function ProjectsPage() {
502
+ const [search] = useSearch(projects);
503
+ // search.view: "grid" | "list" (from parent)
504
+ // search.status: "active" | "archived" (from child)
505
+ }
506
+ ```
507
+
508
+ ## Idempotency requirement
509
+
510
+ The validation function or schema you pass to `.search()` must be **idempotent**, meaning `fn(fn(x))` should equal `fn(x)`.
511
+
512
+ When you read search params, the values are passed through your validator. When you update search params, the navigation APIs expect values in that same validated format, which are then JSON-encoded back into the URL. On the next read, those encoded values are decoded and passed through your validator again - meaning your validator may receive its own output as input.
334
513
 
335
514
  ---
336
515
 
337
- ## Navigation
516
+ # Navigation
338
517
 
339
- ### The Link component
518
+ ## The Link component
340
519
 
341
520
  The `Link` component renders an anchor tag that navigates without a full page reload. It accepts a `to` prop that can be either a route pattern string or a route object:
342
521
 
@@ -385,7 +564,7 @@ The `asChild` prop lets you use your own component while keeping Link's behavior
385
564
  </Link>
386
565
  ```
387
566
 
388
- ### Active state detection
567
+ ## Active state detection
389
568
 
390
569
  Links automatically track whether they match the current URL. When active, they receive a `data-active="true"` attribute and can apply different styles.
391
570
 
@@ -419,11 +598,13 @@ Or use the `activeClassName` and `activeStyle` props directly:
419
598
  </Link>
420
599
  ```
421
600
 
422
- ### Link preloading
601
+ ## Route preloading
602
+
603
+ Links can optionally trigger route preloading before navigation occurs. When preloading is enabled, any [lazy-loaded components](#lazy-loading) (defined with `.lazy()`) and [preload functions](#data-preloading) (defined with `.preload()`) are called early. This improves perceived performance by loading component bundles and running preparation logic like prefetching data ahead of time.
423
604
 
424
- When a route has preloaders, e.g. when using lazy-loaded routes, you can preload them before the user actually navigates. This makes the subsequent navigation instant. The `preload` prop controls when preloading happens:
605
+ The `preload` prop controls when preloading happens:
425
606
 
426
- **`preload="intent"`** triggers preloading when the user shows intent to navigate by hovering over the link or focusing it. This is the most common choice as it balances eager loading with not wasting bandwidth:
607
+ **`preload="intent"`** preloads when the user shows intent to navigate by hovering or focusing the link. This is the most common choice as it balances eager loading with not wasting bandwidth:
427
608
 
428
609
  ```tsx
429
610
  <Link to="/heavy-page" preload="intent">
@@ -449,15 +630,26 @@ When a route has preloaders, e.g. when using lazy-loaded routes, you can preload
449
630
 
450
631
  **`preload={false}`** disables preloading entirely. This is the default.
451
632
 
452
- You can also preload routes programmatically by calling the route's `.preload()` method:
633
+ To prevent unwanted preloads from quick hover/focus interactions, Link waits 50ms before triggering. You can customize this with `preloadDelay`:
634
+
635
+ ```tsx
636
+ <Link to="/heavy-page" preload="intent" preloadDelay={100}>
637
+ Heavy page
638
+ </Link>
639
+ ```
640
+
641
+ You can also preload programmatically using `router.preload()`:
453
642
 
454
643
  ```tsx
455
- userProfile.preload();
644
+ const router = useRouter();
645
+ router.preload({ to: userProfile, params: { id: "42" } });
456
646
  ```
457
647
 
458
- ### Programmatic navigation
648
+ To set a preload strategy globally for all links in your app, see [Global link configuration](#global-link-configuration).
649
+
650
+ ## Programmatic navigation
459
651
 
460
- For navigation triggered by code rather than user clicks, use the `useNavigate` hook (or `router.navigate`):
652
+ For navigation triggered by code rather than user clicks, use the `useNavigate` hook:
461
653
 
462
654
  ```tsx
463
655
  import { useNavigate } from "waymark";
@@ -474,7 +666,7 @@ function LoginForm() {
474
666
  }
475
667
  ```
476
668
 
477
- The navigate function accepts the same options as `Link`:
669
+ The navigate function accepts the same navigation options as `Link`:
478
670
 
479
671
  ```tsx
480
672
  navigate({ to: userProfile, params: { id: "42" }, search: { tab: "posts" } });
@@ -490,20 +682,24 @@ navigate(1); // Go forward
490
682
  navigate(-2); // Go back two steps
491
683
  ```
492
684
 
493
- You can also access the router directly via `useRouter()` (or import the router if created outside of React) and call its `navigate` method, which works the same way.
685
+ You can also access the router directly via `useRouter()` (or import the router if created outside of React) and call its `navigate` method, which works the same way:
686
+
687
+ ```tsx
688
+ router.navigate({ to: "/login" });
689
+ ```
494
690
 
495
- For unsafe navigation that bypasses type checking, you can pass `url` instead of `to`, `params` and `search`. This is useful when you don't know the target URL statically (e.g. external redirects):
691
+ For unsafe navigation that bypasses type checking, you can pass `url` instead of `to`, `params` and `search`. This is useful when you don't know the target URL statically (e.g., URLs from user input or API responses):
496
692
 
497
693
  ```tsx
498
694
  // Type-safe navigation
499
695
  navigate({ to: userProfile, params: { id: "42" } });
500
696
 
501
697
  // Unsafe navigation - no type checking
502
- navigate({ url: "/some/unknown/path" });
698
+ navigate({ url: "/some/path?tab=settings" });
503
699
  navigate({ url: "/callback", replace: true, state: { data: 123 } });
504
700
  ```
505
701
 
506
- ### Declarative navigation
702
+ ## Declarative navigation
507
703
 
508
704
  For redirects triggered by rendering rather than events, use the `Navigate` component. It navigates as soon as it mounts, making it useful for conditional redirects based on application state:
509
705
 
@@ -521,7 +717,7 @@ function ProtectedPage() {
521
717
  }
522
718
  ```
523
719
 
524
- The `Navigate` component accepts the same props as the `Link` component, minus the visual and interaction properties. You can pass route patterns, params, search parameters, and state:
720
+ The `Navigate` component accepts the same navigation props as the `Link` component:
525
721
 
526
722
  ```tsx
527
723
  <Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
@@ -533,186 +729,115 @@ Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation
533
729
 
534
730
  ---
535
731
 
536
- ## Path parameters
732
+ # Lazy loading
537
733
 
538
- Dynamic segments in route patterns become typed path parameters. Define them with a colon prefix. They can also be made optional.
734
+ Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
539
735
 
540
736
  ```tsx
541
- const post = route("/posts/:id").component(PostPage);
542
- const comment = route("/posts/:postId/comments/:commentId?").component(
543
- CommentPage
544
- );
737
+ const analytics = route("/analytics").lazy(() => import("./AnalyticsPage"));
545
738
  ```
546
739
 
547
- Access parameters with `useParams`, passing the route pattern or object as an argument:
740
+ The imported module should use a default export:
548
741
 
549
742
  ```tsx
550
- function PostPage() {
551
- const { id } = useParams(post);
552
- // id is typed as string
553
-
554
- const { id } = useParams("/posts/:id");
555
- // Also works
556
- }
557
-
558
- function CommentPage() {
559
- const { postId, commentId } = useParams(comment);
560
- // postId: string
561
- // commentId?: string | undefined
562
- }
743
+ // AnalyticsPage.tsx
744
+ export default function AnalyticsPage() { ... }
563
745
  ```
564
746
 
565
- Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
747
+ If you're using a named export, you need to explicitly select which component to use by chaining `.then()` on the import:
566
748
 
567
749
  ```tsx
568
- const files = route("/files/*").component(FileBrowser);
750
+ const analytics = route("/analytics").lazy(() =>
751
+ import("./AnalyticsPage").then(m => m.AnalyticsPage)
752
+ );
569
753
 
570
- function FileBrowser() {
571
- const params = useParams(files);
572
- const path = params["*"]; // e.g., "documents/report.pdf"
573
- }
754
+ // AnalyticsPage.tsx
755
+ export function AnalyticsPage() { ... }
574
756
  ```
575
757
 
576
- ---
758
+ Lazy routes work like any other route. Child routes inherit the parent's lazy-loaded components:
577
759
 
578
- ## Search queries
760
+ ```tsx
761
+ const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
762
+ const settings = dashboard.route("/settings").component(Settings);
763
+ ```
579
764
 
580
- Search parameters (the `?key=value` part of URLs) can be typed and validated using the `.search()` method on a route. You can pass either a [Standard Schema](https://github.com/standard-schema/standard-schema) validator like Zod, or a plain mapping function.
765
+ When navigating to `/dashboard/settings`, React loads the dashboard component first, then renders settings inside it. The Dashboard component must include an `<Outlet />` for the child route to appear.
581
766
 
582
- With Zod:
767
+ See [Route preloading](#route-preloading) for ways to load these components before the user navigates.
583
768
 
584
- ```tsx
585
- import { z } from "zod";
769
+ ---
586
770
 
587
- const searchPage = route("/search")
588
- .search(
589
- z.object({
590
- q: z.string().catch(""),
591
- page: z.coerce.number().catch(1)
592
- })
593
- )
594
- .component(SearchPage);
595
- ```
771
+ # Data preloading
596
772
 
597
- With a plain function:
773
+ Use `.preload()` to run logic before navigation occurs, typically to prefetch data. Preload functions receive the target route's typed params and search values:
598
774
 
599
775
  ```tsx
600
- const searchPage = route("/search")
601
- .search(raw => ({
602
- q: String(raw.q ?? ""),
603
- page: Number(raw.page ?? 1)
604
- }))
605
- .component(SearchPage);
776
+ const userProfile = route("/users/:id")
777
+ .search(z.object({ tab: z.enum(["posts", "comments"]).catch("posts") }))
778
+ .preload(async ({ params, search }) => {
779
+ await queryClient.prefetchQuery({
780
+ queryKey: ["user", params.id, search.tab],
781
+ queryFn: () => fetchUser(params.id, search.tab)
782
+ });
783
+ })
784
+ .component(UserProfile);
606
785
  ```
607
786
 
608
- Access search params with `useSearch`, which returns a tuple of the current values and a setter function:
787
+ See [Route preloading](#route-preloading) for how to trigger preload functions.
788
+
789
+ Depending on when and how preloading is triggered, these functions may run repeatedly. Waymark intentionally doesn't cache or deduplicate the calls - that's the job of your data layer. Libraries like TanStack Query, SWR, or Apollo handle this well. For example, TanStack Query's `staleTime` prevents refetches when data is still fresh:
609
790
 
610
791
  ```tsx
611
- function SearchPage() {
612
- const [search, setSearch] = useSearch(searchPage);
613
- // search.q: string
614
- // search.page: number
615
- }
792
+ await queryClient.prefetchQuery({
793
+ queryKey: ["user", params.id],
794
+ queryFn: () => fetchUser(params.id),
795
+ staleTime: 60_000 // No refetch within 60s
796
+ });
616
797
  ```
617
798
 
618
- The setter merges your updates with existing values:
799
+ Preload functions inherit to child routes:
619
800
 
620
801
  ```tsx
621
- setSearch({ page: 2 }); // Only updates page
622
- setSearch(prev => ({ page: prev.page + 1 })); // Increment page
802
+ const dashboard = route("/dashboard")
803
+ .preload(prefetchDashboardData)
804
+ .component(DashboardLayout);
805
+
806
+ const settings = dashboard.route("/settings").component(Settings);
807
+ // Preloading /dashboard/settings runs prefetchDashboardData
623
808
  ```
624
809
 
625
- Pass `true` as the second argument to replace the history entry instead of pushing:
810
+ ---
626
811
 
627
- ```tsx
628
- setSearch({ page: 1 }, true);
629
- ```
812
+ # Error boundaries
630
813
 
631
- **JSON-first search params**
814
+ Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
632
815
 
633
- Waymark uses a JSON-first approach for search parameters, similar to TanStack Router. When serializing and deserializing values from the URL:
816
+ ```tsx
817
+ const fragile = route("/fragile").error(ErrorFallback).component(FragilePage);
634
818
 
635
- - Plain strings that aren't valid JSON are kept as-is: `"John"` `?name=John` → `"John"`
636
- - Everything else is JSON-encoded (and URL-encoded):
637
- - `true` → `?enabled=true` → `true`
638
- - `"true"` → `?enabled=%22true%22` → `"true"`
639
- - `[1, 2]` → `?filters=%5B1%2C2%5D` → `[1, 2]`
640
- - `42` → `count=42` `42`
819
+ function ErrorFallback({ error }: { error: unknown }) {
820
+ return (
821
+ <div>
822
+ <h2>Something went wrong</h2>
823
+ <pre>{String(error)}</pre>
824
+ <button onClick={() => window.location.reload()}>Retry</button>
825
+ </div>
826
+ );
827
+ }
828
+ ```
641
829
 
642
- This means you can store complex data structures like arrays and objects in search params without manual serialization. When reading from the URL, Waymark automatically parses JSON values back to their original types.
830
+ Error boundaries catch errors from all nested content. A common pattern is to place one at the root to catch any unhandled errors:
831
+
832
+ ```tsx
833
+ const app = route("/").error(ErrorPage).component(AppLayout);
834
+ ```
643
835
 
644
- The resulting parsed object is what gets passed to the `.search()` function or schema on the route builder. It's typed as `Record<string, unknown>`, which is why validation with Zod or a mapping function is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
836
+ To give new routes a fresh start, the error boundary automatically resets when navigation occurs.
645
837
 
646
838
  ---
647
839
 
648
- ## Lazy loading
649
-
650
- Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
651
-
652
- ```tsx
653
- const analytics = route("/analytics").lazy(() => import("./AnalyticsPage"));
654
- ```
655
-
656
- The imported module should use a default export:
657
-
658
- ```tsx
659
- // AnalyticsPage.tsx
660
- export default function AnalyticsPage() { ... }
661
- ```
662
-
663
- If you're using a named export, you need to explicitly select which component to use by chaining `.then()` on the import:
664
-
665
- ```tsx
666
- const analytics = route("/analytics").lazy(() =>
667
- import("./AnalyticsPage").then(m => m.AnalyticsPage)
668
- );
669
-
670
- // AnalyticsPage.tsx
671
- export function AnalyticsPage() { ... }
672
- ```
673
-
674
- Lazy routes work seamlessly with nesting. Child routes inherit the lazy-loaded parent's components:
675
-
676
- ```tsx
677
- const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
678
- const settings = dashboard.route("/settings").component(Settings);
679
- ```
680
-
681
- When navigating to `/dashboard/settings`, React loads the dashboard component first, then renders settings inside it. The Dashboard component must include an `<Outlet />` for the child route to appear.
682
-
683
- See [Link preloading](#link-preloading) for ways to load these components before the user navigates.
684
-
685
- ---
686
-
687
- ## Error boundaries
688
-
689
- Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
690
-
691
- ```tsx
692
- const fragile = route("/fragile").error(ErrorFallback).component(FragilePage);
693
-
694
- function ErrorFallback({ error }: { error: unknown }) {
695
- return (
696
- <div>
697
- <h2>Something went wrong</h2>
698
- <pre>{String(error)}</pre>
699
- <button onClick={() => window.location.reload()}>Retry</button>
700
- </div>
701
- );
702
- }
703
- ```
704
-
705
- Error boundaries catch errors from all nested content. A common pattern is to place one at the root to catch any unhandled errors:
706
-
707
- ```tsx
708
- const app = route("/").error(ErrorPage).component(AppLayout);
709
- ```
710
-
711
- The error boundary automatically resets when navigation occurs, giving the new route a fresh start.
712
-
713
- ---
714
-
715
- ## Suspense boundaries
840
+ # Suspense boundaries
716
841
 
717
842
  When using lazy loading or React's `use()` hook for data fetching, you may want to add suspense boundaries to show loading states. Add them with `.suspense()`:
718
843
 
@@ -741,7 +866,7 @@ Note: React 19 has a [known throttling behavior](https://github.com/facebook/rea
741
866
 
742
867
  ---
743
868
 
744
- ## Route handles
869
+ # Route handles
745
870
 
746
871
  Handles let you attach static arbitrary metadata to routes. This is useful for breadcrumbs, page titles, access control flags, or any other static data you want to associate with a route.
747
872
 
@@ -767,8 +892,8 @@ function Breadcrumbs() {
767
892
  <nav>
768
893
  {handles.map((h, i) => (
769
894
  <span key={i}>
895
+ {i !== 0 && " / "}
770
896
  {h.title}
771
- {i < handles.length - 1 && " / "}
772
897
  </span>
773
898
  ))}
774
899
  </nav>
@@ -791,7 +916,7 @@ declare module "waymark" {
791
916
 
792
917
  ---
793
918
 
794
- ## Route matching and ranking
919
+ # Route matching and ranking
795
920
 
796
921
  When a user navigates to a URL, Waymark needs to determine which route matches. Since multiple routes can potentially match the same path (think `/users/:id` vs `/users/new`), Waymark uses a ranking algorithm to pick the most specific one.
797
922
 
@@ -843,7 +968,7 @@ const routes = [
843
968
 
844
969
  ---
845
970
 
846
- ## History implementations
971
+ # History implementations
847
972
 
848
973
  History is an abstraction layer that sits between the router and the actual low-level navigation logic. It handles reading and updating the current location, managing navigation state, and notifying when the URL changes. This abstraction allows Waymark to work in different environments (browser, hash-based, in-memory, server-side, tests, etc.) without changing the router's core logic. You can switch between environments simply by swapping the history implementation - the rest of your app stays exactly the same.
849
974
 
@@ -865,7 +990,7 @@ import { HashHistory } from "waymark";
865
990
  <RouterRoot routes={routes} history={new HashHistory()} />;
866
991
  ```
867
992
 
868
- **MemoryHistory** keeps the history in memory without touching the URL. It also doesn't rely on any browser API. Perfect for testing, server-side rendering, or embedded applications:
993
+ **MemoryHistory** keeps the history in memory without touching the URL. It also doesn't rely on any browser API. Perfect for testing, server-side rendering (SSR), or embedded applications:
869
994
 
870
995
  ```tsx
871
996
  import { MemoryHistory } from "waymark";
@@ -877,9 +1002,94 @@ All history implementations conform to the `HistoryLike` interface, so you can c
877
1002
 
878
1003
  ---
879
1004
 
880
- ## Cookbook
1005
+ # Cookbook
1006
+
1007
+ ## Quick start example
1008
+
1009
+ Here's a minimal but complete routing setup with a layout and two pages:
1010
+
1011
+ ```tsx
1012
+ import { route, RouterRoot, Outlet, Link } from "waymark";
1013
+
1014
+ // Layout route
1015
+ const app = route("/").component(AppLayout);
1016
+
1017
+ function AppLayout() {
1018
+ return (
1019
+ <div>
1020
+ <nav>
1021
+ <Link to="/">Home</Link>
1022
+ <Link to="/about">About</Link>
1023
+ </nav>
1024
+ <main>
1025
+ <Outlet />
1026
+ </main>
1027
+ </div>
1028
+ );
1029
+ }
1030
+
1031
+ // Page routes
1032
+ const home = app.route("/").component(() => <h1>Welcome home</h1>);
1033
+ const about = app.route("/about").component(() => <h1>About us</h1>);
1034
+
1035
+ // Router setup
1036
+ const routes = [home, about];
1037
+
1038
+ export function App() {
1039
+ return <RouterRoot routes={routes} />;
1040
+ }
1041
+
1042
+ declare module "waymark" {
1043
+ interface Register {
1044
+ routes: typeof routes;
1045
+ }
1046
+ }
1047
+ ```
1048
+
1049
+ ## Server-side rendering (SSR)
1050
+
1051
+ Waymark supports server-side rendering using `MemoryHistory`. The key is to use `MemoryHistory` on the server (initialized with the request URL) and `BrowserHistory` on the client:
1052
+
1053
+ ```tsx
1054
+ // server.tsx
1055
+ import { renderToString } from "react-dom/server";
1056
+ import { RouterRoot, MemoryHistory, type SSRContext } from "waymark";
1057
+ import { routes } from "./routes";
1058
+
1059
+ function handleRequest(req: Request) {
1060
+ const ssrContext: SSRContext = {};
1061
+ const html = renderToString(
1062
+ <RouterRoot
1063
+ routes={routes}
1064
+ history={new MemoryHistory(req.url)}
1065
+ ssrContext={ssrContext}
1066
+ />
1067
+ );
1068
+ if (ssrContext.redirect) {
1069
+ return Response.redirect(ssrContext.redirect);
1070
+ }
1071
+ return new Response(html, {
1072
+ headers: { "Content-Type": "text/html" }
1073
+ });
1074
+ }
1075
+ ```
881
1076
 
882
- ### Scroll to top on navigation
1077
+ The `ssrContext` object captures information during server rendering. When a `Navigate` component renders on the server (typically from conditional logic), it populates `ssrContext.redirect` with the target URL. Your server can then return an HTTP redirect instead of the rendered HTML.
1078
+
1079
+ On the client, use the default (`BrowserHistory`) for hydration:
1080
+
1081
+ ```tsx
1082
+ // client.tsx
1083
+ import { hydrateRoot } from "react-dom/client";
1084
+ import { RouterRoot } from "waymark";
1085
+ import { routes } from "./routes";
1086
+
1087
+ hydrateRoot(rootElement, <RouterRoot routes={routes} />);
1088
+ ```
1089
+
1090
+ You can also manually set `ssrContext.statusCode` in your components during SSR to control the response status (like 404 for not found pages).
1091
+
1092
+ ## Scroll to top on navigation
883
1093
 
884
1094
  Create a component that scrolls to top when the path changes and include it in your layout:
885
1095
 
@@ -904,15 +1114,57 @@ function AppLayout() {
904
1114
  }
905
1115
  ```
906
1116
 
907
- ### Global link configuration
1117
+ ## Matching a route anywhere
1118
+
1119
+ Use `useMatch` to check if a route matches the current path from anywhere in your component tree. You can pass either a route pattern string or a route object, just like with `Link` and `navigate`. This is useful for conditional rendering, styling, access control, and more. It's also used internally by `useParams` and `Link`.
908
1120
 
909
- Set defaults for all `Link` components using `defaultLinkOptions` on the router. This is useful for consistent styling and preload behavior across your app:
1121
+ By default, `useMatch` uses loose matching where the current path only needs to start with the route's path. To require an exact match instead, pass `strict: true`:
1122
+
1123
+ ```tsx
1124
+ import { useMatch } from "waymark";
1125
+
1126
+ const dashboard = route("/dashboard").component(Dashboard);
1127
+ const settings = route("/settings").component(Settings);
1128
+
1129
+ function Sidebar() {
1130
+ // Loose matching: matches /dashboard and /dashboard/literally/anything
1131
+ const dashboardMatch = useMatch({ from: "/dashboard" });
1132
+
1133
+ // Strict matching: matches only /settings
1134
+ const settingsMatch = useMatch({ from: settings, strict: true });
1135
+
1136
+ return (
1137
+ <nav>
1138
+ {dashboardMatch && <DashboardMenu />}
1139
+ {settingsMatch && <SettingsSubmenu />}
1140
+ </nav>
1141
+ );
1142
+ }
1143
+ ```
1144
+
1145
+ You can also filter by param values to match only specific instances:
1146
+
1147
+ ```tsx
1148
+ const adminMatch = useMatch({
1149
+ from: "/users/:id",
1150
+ params: { id: "admin" }
1151
+ });
1152
+
1153
+ if (adminMatch) {
1154
+ // Currently viewing the admin user
1155
+ }
1156
+ ```
1157
+
1158
+ ## Global link configuration
1159
+
1160
+ Set defaults for all `Link` components using `defaultLinkOptions` on the router. Useful for consistent styling and preload behavior across your app:
910
1161
 
911
1162
  ```tsx
912
1163
  <RouterRoot
913
1164
  routes={routes}
914
1165
  defaultLinkOptions={{
915
1166
  preload: "intent",
1167
+ preloadDelay: 75,
916
1168
  className: "app-link",
917
1169
  activeClassName: "active"
918
1170
  }}
@@ -921,7 +1173,7 @@ Set defaults for all `Link` components using `defaultLinkOptions` on the router.
921
1173
 
922
1174
  Individual links can override any of these defaults by passing their own props.
923
1175
 
924
- ### History middleware
1176
+ ## History middleware
925
1177
 
926
1178
  This is a design pattern rather than a feature. You can extend history behavior for logging, analytics, or other side effects by monkey-patching the history instance:
927
1179
 
@@ -960,9 +1212,9 @@ const router = new Router({
960
1212
  });
961
1213
  ```
962
1214
 
963
- ### View transitions
1215
+ ## View transitions
964
1216
 
965
- Use the View Transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
1217
+ You can use the view transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
966
1218
 
967
1219
  ```tsx
968
1220
  import { flushSync } from "react-dom";
@@ -1000,132 +1252,37 @@ Add CSS to control the transition:
1000
1252
 
1001
1253
  For more advanced techniques, see the [MDN documentation on View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API).
1002
1254
 
1003
- ### Matching a route anywhere
1004
-
1005
- Use `useMatch` to check if a route matches the current path from anywhere in your component tree. You can pass either a route pattern string or a route object, just like with `Link` and `navigate`. This is useful for conditional rendering, styling, access control, and more. It's also used internally by `useParams` and `Link`.
1006
-
1007
- ```tsx
1008
- import { useMatch } from "waymark";
1009
-
1010
- const dashboard = route("/dashboard").component(Dashboard);
1011
- const settings = route("/settings").component(Settings);
1012
-
1013
- function Sidebar() {
1014
- // Using route patterns
1015
- const dashboardMatch = useMatch({ from: "/dashboard" });
1016
- const settingsMatch = useMatch({ from: "/settings", strict: true });
1017
-
1018
- // Using route objects
1019
- const dashboardMatch = useMatch({ from: dashboard });
1020
- const settingsMatch = useMatch({ from: settings, strict: true });
1021
-
1022
- return (
1023
- <nav>
1024
- {dashboardMatch && <DashboardMenu />}
1025
- {settingsMatch && <SettingsSubmenu />}
1026
- </nav>
1027
- );
1028
- }
1029
- ```
1030
-
1031
- You can also filter by param values to match only specific instances:
1032
-
1033
- ```tsx
1034
- const adminMatch = useMatch({
1035
- from: "/users/:id",
1036
- params: { id: "admin" }
1037
- });
1038
-
1039
- if (adminMatch) {
1040
- // Currently viewing the admin user
1041
- }
1042
- ```
1043
-
1044
1255
  ---
1045
1256
 
1046
- ## API reference
1047
-
1048
- ### Types
1049
-
1050
- **`NavigateOptions<P>`** is the main type for type-safe navigation:
1051
-
1052
- ```tsx
1053
- type NavigateOptions<P extends Pattern> = {
1054
- to: P | Route<P>; // Route pattern or route object
1055
- params?: Params<P>; // Required if route has dynamic segments
1056
- search?: Search<P>; // Search params if route defines them
1057
- replace?: boolean; // Replace history instead of push
1058
- state?: any; // Arbitrary state to pass
1059
- };
1060
- ```
1061
-
1062
- **`HistoryPushOptions`** is for untyped navigation:
1063
-
1064
- ```tsx
1065
- interface HistoryPushOptions {
1066
- url: string; // The URL to navigate to
1067
- replace?: boolean; // Replace history instead of push
1068
- state?: any; // Arbitrary state to pass
1069
- }
1070
- ```
1071
-
1072
- **`MatchOptions<P>`** is used for route matching:
1073
-
1074
- ```tsx
1075
- type MatchOptions<P extends Pattern> = {
1076
- from: P | Route<P>; // Route to match against
1077
- strict?: boolean; // Require exact match (not just prefix)
1078
- params?: Partial<Params<P>>; // Filter by specific param values
1079
- };
1080
- ```
1081
-
1082
- **`Match<P>`** is the result of a successful match:
1257
+ # API reference
1083
1258
 
1084
- ```tsx
1085
- type Match<P extends Pattern> = {
1086
- route: Route<P>; // The matched route
1087
- params: Params<P>; // Extracted parameters
1088
- };
1089
- ```
1090
-
1091
- **`LinkOptions`** controls link behavior and styling:
1092
-
1093
- ```tsx
1094
- interface LinkOptions {
1095
- strict?: boolean; // Strict active matching
1096
- preload?: "intent" | "render" | "viewport" | false;
1097
- style?: CSSProperties;
1098
- className?: string;
1099
- activeStyle?: CSSProperties;
1100
- activeClassName?: string;
1101
- }
1102
- ```
1103
-
1104
- ### Router class
1259
+ ## Router class
1105
1260
 
1106
1261
  The `Router` class is the core of Waymark. You can create an instance directly or let `RouterRoot` create one.
1107
1262
 
1108
- **Constructor:**
1109
-
1110
- ```tsx
1111
- const router = new Router({
1112
- routes: Route[], // Required: array of routes
1113
- basePath: string, // Optional: base path prefix (default: "/")
1114
- history: HistoryLike, // Optional: history implementation (default: BrowserHistory)
1115
- defaultLinkOptions: LinkOptions // Optional: defaults for all Links
1116
- });
1117
- ```
1118
-
1119
1263
  **Properties:**
1120
1264
 
1121
1265
  - `router.basePath` - The configured base path
1122
1266
  - `router.routes` - The array of routes
1123
1267
  - `router.history` - The history instance
1268
+ - `router.ssrContext` - The SSR context (if provided)
1124
1269
  - `router.defaultLinkOptions` - Default link options
1125
1270
 
1126
- **Methods:**
1271
+ **`new Router(options)`** creates a new router.
1272
+
1273
+ - `options` - `RouterOptions` - Router configuration
1274
+ - Returns: `Router` - A new router instance
1275
+
1276
+ ```tsx
1277
+ const router = new Router({ routes });
1278
+ const router = new Router({ routes, basePath: "/app" });
1279
+ const router = new Router({ routes, history: new HashHistory() });
1280
+ ```
1281
+
1282
+ **`router.navigate(options)`** navigates to a new location.
1127
1283
 
1128
- `router.navigate(options)` navigates to a new location:
1284
+ - `options` - `NavigateOptions | HistoryPushOptions | number` - Type-safe navigation options, untyped navigation options, or a history delta
1285
+ - Returns: `void`
1129
1286
 
1130
1287
  ```tsx
1131
1288
  // Type-safe navigation
@@ -1139,103 +1296,64 @@ router.navigate(-1); // Back
1139
1296
  router.navigate(1); // Forward
1140
1297
  ```
1141
1298
 
1142
- `router.createUrl(options)` builds a URL string without navigating:
1299
+ **`router.createUrl(options)`** builds a URL string.
1300
+
1301
+ - `options` - `NavigateOptions` - Type-safe navigation options
1302
+ - Returns: `string` - The constructed URL
1143
1303
 
1144
1304
  ```tsx
1145
1305
  const url = router.createUrl({ to: userProfile, params: { id: "42" } });
1146
1306
  // Returns "/users/42"
1147
1307
  ```
1148
1308
 
1149
- `router.match(path, options)` checks if a path matches a specific route:
1309
+ **`router.match(path, options)`** checks if a path matches a specific route.
1310
+
1311
+ - `path` - `string` - The path to match against
1312
+ - `options` - `MatchOptions` - Matching options
1313
+ - Returns: `Match | null` - The match result or null if no match
1150
1314
 
1151
1315
  ```tsx
1152
1316
  const match = router.match("/users/42", { from: "/users/:id" });
1153
1317
  // Returns { route, params: { id: "42" } } or null
1154
1318
  ```
1155
1319
 
1156
- `router.matchAll(path)` finds the best matching route from all registered routes:
1320
+ **`router.matchAll(path)`** finds the best match from all registered routes.
1321
+
1322
+ - `path` - `string` - The path to match against
1323
+ - Returns: `Match | null` - The best match or null if no route matches
1157
1324
 
1158
1325
  ```tsx
1159
1326
  const match = router.matchAll("/users/42");
1160
1327
  // Returns the best match or null
1161
1328
  ```
1162
1329
 
1163
- `router.getRoute(pattern)` retrieves a route by its pattern:
1330
+ **`router.getRoute(pattern)`** get a route by its pattern.
1164
1331
 
1165
- ```tsx
1166
- const route = router.getRoute("/users/:id");
1167
- ```
1168
-
1169
- ### History interface
1170
-
1171
- The `History` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
1172
-
1173
- **Interface:**
1332
+ - `pattern` - `Pattern | Route` - A route pattern string or a route object
1333
+ - Returns: `Route` - The route object; throws if not found
1174
1334
 
1175
1335
  ```tsx
1176
- interface HistoryLike {
1177
- getPath: () => string;
1178
- getSearch: () => Record<string, unknown>;
1179
- getState: () => any;
1180
- go: (delta: number) => void;
1181
- push: (options: HistoryPushOptions) => void;
1182
- subscribe: (listener: () => void) => () => void;
1183
- }
1184
- ```
1185
-
1186
- **Methods:**
1187
-
1188
- `history.getPath()` returns the current pathname:
1189
-
1190
- ```tsx
1191
- const path = history.getPath();
1192
- // Returns "/users/42"
1193
- ```
1194
-
1195
- `history.getSearch()` returns the current search parameters as a parsed object:
1196
-
1197
- ```tsx
1198
- const search = history.getSearch();
1199
- // Returns { tab: "posts", page: 2 }
1200
- ```
1201
-
1202
- `history.getState()` returns the current history state:
1203
-
1204
- ```tsx
1205
- const state = history.getState();
1206
- // Returns any state passed during navigation
1336
+ const route = router.getRoute("/users/:id");
1207
1337
  ```
1208
1338
 
1209
- `history.go(delta)` navigates forward or back in history:
1339
+ **`router.preload(options)`** triggers preloading for a route.
1210
1340
 
1211
- ```tsx
1212
- history.go(-1); // Go back
1213
- history.go(1); // Go forward
1214
- history.go(-2); // Go back two steps
1215
- ```
1216
-
1217
- `history.push(options)` pushes or replaces a history entry:
1341
+ - `options` - `NavigateOptions` - Type-safe navigation options
1342
+ - Returns: `Promise<void>` - Resolves when preloaded
1218
1343
 
1219
1344
  ```tsx
1220
- history.push({ url: "/users/42", state: { from: "list" } });
1221
- history.push({ url: "/login", replace: true });
1222
- ```
1223
-
1224
- `history.subscribe(listener)` subscribes to navigation events and returns an unsubscribe function:
1225
-
1226
- ```tsx
1227
- const unsubscribe = history.subscribe(() => {
1228
- console.log("Navigation occurred");
1229
- });
1230
-
1231
- // Later: unsubscribe()
1345
+ await router.preload({ to: "/user/:id", params: { id: "42" } });
1346
+ await router.preload({ to: searchPage, search: { q: "test" } });
1232
1347
  ```
1233
1348
 
1234
- ### Route class
1349
+ ## Route class
1235
1350
 
1236
1351
  Routes are created with the `route()` function and configured by chaining methods.
1237
1352
 
1238
- **`route(pattern)`** creates a new route:
1353
+ **`route(pattern)`** creates a new route.
1354
+
1355
+ - `pattern` - `string` - The route path pattern (e.g., `"/users"`, `"/users/:id"`, `"/*"`)
1356
+ - Returns: `Route` - A new route object
1239
1357
 
1240
1358
  ```tsx
1241
1359
  const users = route("/users");
@@ -1243,38 +1361,62 @@ const user = route("/users/:id");
1243
1361
  const catchAll = route("/*");
1244
1362
  ```
1245
1363
 
1246
- **`.route(subPattern)`** creates a nested child route:
1364
+ **`.route(pattern)`** creates a nested child route.
1365
+
1366
+ - `pattern` - `string` - The child path pattern to append
1367
+ - Returns: `Route` - A new route object
1247
1368
 
1248
1369
  ```tsx
1249
1370
  const userSettings = user.route("/settings");
1250
1371
  // Pattern becomes "/users/:id/settings"
1251
1372
  ```
1252
1373
 
1253
- **`.component(component)`** adds a React component to render:
1374
+ **`.component(component)`** adds a component to render when this route matches.
1375
+
1376
+ - `component` - `ComponentType` - A React component
1377
+ - Returns: `Route` - A new route object
1254
1378
 
1255
1379
  ```tsx
1256
1380
  const users = route("/users").component(UsersPage);
1257
1381
  ```
1258
1382
 
1259
- **`.lazy(loader)`** adds a lazy-loaded component to render:
1383
+ **`.lazy(loader)`** adds a lazy-loaded component to render when this route matches.
1384
+
1385
+ - `loader` - `ComponentLoader` - A function returning a dynamic import promise
1386
+ - Returns: `Route` - A new route object
1260
1387
 
1261
1388
  ```tsx
1262
1389
  const users = route("/users").lazy(() => import("./UsersPage"));
1390
+ const admin = route("/admin").lazy(() =>
1391
+ import("./Admin").then(m => m.AdminPage)
1392
+ );
1263
1393
  ```
1264
1394
 
1265
- **`.search(validator)`** adds search parameter validation:
1395
+ **`.search(validate)`** adds search parameter validation.
1396
+
1397
+ - `validate` - `StandardSchema | ((search) => ValidatedSearch)` - A Standard Schema (like Zod) or a validation function
1398
+ - Returns: `Route` - A new route object
1266
1399
 
1267
1400
  ```tsx
1268
1401
  const search = route("/search").search(z.object({ q: z.string() }));
1402
+ const filter = route("/filter").search(raw => ({
1403
+ term: String(raw.term ?? "")
1404
+ }));
1269
1405
  ```
1270
1406
 
1271
- **`.handle(data)`** attaches static metadata:
1407
+ **`.handle(handle)`** attaches static metadata to the route.
1408
+
1409
+ - `handle` - `Handle` - Arbitrary metadata
1410
+ - Returns: `Route` - A new route object
1272
1411
 
1273
1412
  ```tsx
1274
1413
  const admin = route("/admin").handle({ requiresAuth: true });
1275
1414
  ```
1276
1415
 
1277
- **`.suspense(fallback)`** wraps children in a suspense boundary:
1416
+ **`.suspense(fallback)`** wraps nested content in a Suspense boundary.
1417
+
1418
+ - `fallback` - `ComponentType` - The fallback component to show while suspended
1419
+ - Returns: `Route` - A new route object
1278
1420
 
1279
1421
  ```tsx
1280
1422
  const lazy = route("/lazy")
@@ -1282,35 +1424,42 @@ const lazy = route("/lazy")
1282
1424
  .lazy(() => import("./Page"));
1283
1425
  ```
1284
1426
 
1285
- **`.error(fallback)`** wraps children in an error boundary:
1427
+ **`.error(fallback)`** wraps nested content in an error boundary.
1428
+
1429
+ - `fallback` - `ComponentType<{ error: unknown }>` - The fallback component, receives the caught error as a prop
1430
+ - Returns: `Route` - A new route object
1286
1431
 
1287
1432
  ```tsx
1288
1433
  const risky = route("/risky").error(ErrorPage).component(RiskyPage);
1289
1434
  ```
1290
1435
 
1291
- **`.preloader(loader)`** registers a preloader function that will be called when `.preload()` is invoked or when a `Link` with a preload strategy triggers it:
1292
-
1293
- ```tsx
1294
- const users = route("/users").preloader(async () => {
1295
- await prefetchData();
1296
- });
1297
- ```
1436
+ **`.preload(preload)`** registers a preload function for the route.
1298
1437
 
1299
- **`.preload()`** manually triggers all registered preloaders (including lazy component loading):
1438
+ - `preload` - `(context: PreloadContext) => Promise<any>` - An async function receiving typed `params` and `search`
1439
+ - Returns: `Route` - A new route object
1300
1440
 
1301
1441
  ```tsx
1302
- await userProfile.preload();
1442
+ const user = route("/users/:id")
1443
+ .search(z.object({ tab: z.string().catch("profile") }))
1444
+ .preload(async ({ params, search }) => {
1445
+ // params.id: string, search.tab: string - fully typed
1446
+ await prefetchUser(params.id, search.tab);
1447
+ });
1303
1448
  ```
1304
1449
 
1305
- ### Hooks
1450
+ ## Hooks
1451
+
1452
+ **`useRouter()`** returns the Router instance from context.
1306
1453
 
1307
- **`useRouter()`** returns the Router instance:
1454
+ - Returns: `Router` - The router instance
1308
1455
 
1309
1456
  ```tsx
1310
1457
  const router = useRouter();
1311
1458
  ```
1312
1459
 
1313
- **`useNavigate()`** returns a navigation function:
1460
+ **`useNavigate()`** returns a navigation function.
1461
+
1462
+ - Returns: `(options: NavigateOptions | HistoryPushOptions | number) => void` - The navigate function
1314
1463
 
1315
1464
  ```tsx
1316
1465
  const navigate = useNavigate();
@@ -1318,34 +1467,47 @@ navigate({ to: "/home" });
1318
1467
  navigate(-1);
1319
1468
  ```
1320
1469
 
1321
- **`useLocation()`** returns the current location:
1470
+ **`useLocation()`** returns the current location, subscribes to changes.
1471
+
1472
+ - Returns: `{ path: string, search: Record<string, unknown>, state: any }` - The current path, parsed search params, and history state
1322
1473
 
1323
1474
  ```tsx
1324
1475
  const { path, search, state } = useLocation();
1325
- // path: string, search: Record<string, unknown>, state: any
1326
1476
  ```
1327
1477
 
1328
- **`useOutlet()`** returns the nested route content (used internally by `Outlet`):
1478
+ **`useOutlet()`** returns the child route content.
1479
+
1480
+ - Returns: `ReactNode` - The child route's content or null
1329
1481
 
1330
1482
  ```tsx
1331
1483
  const outlet = useOutlet();
1332
1484
  ```
1333
1485
 
1334
- **`useParams(route)`** returns typed parameters for a route:
1486
+ **`useParams(route)`** returns typed path params for a route.
1487
+
1488
+ - `route` - `Pattern | Route` - A route pattern string or route object
1489
+ - Returns: `Params` - The extracted path params, fully typed
1335
1490
 
1336
1491
  ```tsx
1337
1492
  const { id } = useParams(userRoute);
1338
1493
  ```
1339
1494
 
1340
- **`useSearch(route)`** returns search params and a setter:
1495
+ **`useSearch(route)`** returns validated search params and a setter function.
1496
+
1497
+ - `route` - `Pattern | Route` - A route pattern string or route object
1498
+ - Returns: `[Search, SetSearch]` - A tuple of the validated search params and a setter; the setter accepts a partial update or an updater function, with an optional second argument to replace instead of push
1341
1499
 
1342
1500
  ```tsx
1343
1501
  const [search, setSearch] = useSearch(searchRoute);
1344
1502
  setSearch({ page: 2 });
1345
1503
  setSearch(prev => ({ page: prev.page + 1 }));
1504
+ setSearch({ page: 1 }, true); // Replace instead of push
1346
1505
  ```
1347
1506
 
1348
- **`useMatch(options)`** checks if a route matches the current path:
1507
+ **`useMatch(options)`** checks if a route matches the current path.
1508
+
1509
+ - `options` - `MatchOptions` - Matching options
1510
+ - Returns: `Match | null` - The match result or null if no match
1349
1511
 
1350
1512
  ```tsx
1351
1513
  const match = useMatch({ from: "/users/:id" });
@@ -1353,22 +1515,26 @@ const strictMatch = useMatch({ from: "/users", strict: true });
1353
1515
  const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
1354
1516
  ```
1355
1517
 
1356
- **`useHandles()`** returns all handles from the matched route chain in order:
1518
+ **`useHandles()`** returns the handles from the matched route chain.
1519
+
1520
+ - Returns: `Handle[]` - Array of handles
1357
1521
 
1358
1522
  ```tsx
1359
1523
  const handles = useHandles();
1360
1524
  ```
1361
1525
 
1362
- ### Components
1526
+ ## Components
1527
+
1528
+ **`RouterRoot`** sets up routing context and renders your routes.
1363
1529
 
1364
- **`RouterRoot`** is the root provider. Pass either router options or a router instance:
1530
+ - `props` - `RouterOptions | { router: Router }` - Either router options (same as the `Router` constructor) or a router instance
1365
1531
 
1366
1532
  ```tsx
1367
1533
  <RouterRoot routes={routes} basePath="/app" history={history} />
1368
1534
  <RouterRoot router={router} />
1369
1535
  ```
1370
1536
 
1371
- **`Outlet`** renders child route content:
1537
+ **`Outlet`** renders the child route content.
1372
1538
 
1373
1539
  ```tsx
1374
1540
  function Layout() {
@@ -1380,7 +1546,9 @@ function Layout() {
1380
1546
  }
1381
1547
  ```
1382
1548
 
1383
- **`Link`** navigates on click. Props extend `NavigateOptions` and `LinkOptions`:
1549
+ **`Link`** renders an anchor tag for navigation.
1550
+
1551
+ - `props` - `NavigateOptions & LinkOptions & { asChild?: boolean }` - Navigation options, link options, and optional `asChild` to use a child element as the anchor; other props are passed through
1384
1552
 
1385
1553
  ```tsx
1386
1554
  <Link to="/path" params={...} search={...} replace strict preload="intent">
@@ -1388,23 +1556,187 @@ function Layout() {
1388
1556
  </Link>
1389
1557
  ```
1390
1558
 
1391
- **`Navigate`** redirects on render. Props are `NavigateOptions`:
1559
+ **`Navigate`** redirects on render.
1560
+
1561
+ - `props` - `NavigateOptions` - The navigation target
1392
1562
 
1393
1563
  ```tsx
1394
1564
  <Navigate to="/login" replace />
1395
1565
  ```
1396
1566
 
1397
- ---
1567
+ ## History interface
1568
+
1569
+ The `HistoryLike` interface defines how Waymark interacts with navigation. All history implementations conform to this interface.
1398
1570
 
1399
- ## Roadmap
1571
+ **Available implementations:**
1572
+
1573
+ ```tsx
1574
+ new BrowserHistory(); // Browser History API (/posts/123). Default.
1575
+ new HashHistory(); // URL hash (/#/posts/123).
1576
+ new MemoryHistory("/initial"); // In-memory only.
1577
+ ```
1578
+
1579
+ See [History implementations](#history-implementations) for detailed usage.
1580
+
1581
+ **`history.getPath()`** returns the current path.
1582
+
1583
+ - Returns: `string` - The current path
1584
+
1585
+ ```tsx
1586
+ const path = history.getPath();
1587
+ // Returns "/users/42"
1588
+ ```
1589
+
1590
+ **`history.getSearch()`** returns the current search params as a parsed JSON object.
1591
+
1592
+ - Returns: `Record<string, unknown>` - The parsed search params
1593
+
1594
+ ```tsx
1595
+ const search = history.getSearch();
1596
+ // Returns { tab: "posts", page: 2 }
1597
+ ```
1598
+
1599
+ **`history.getState()`** returns the current history state.
1600
+
1601
+ - Returns: `any` - The state passed during navigation, or undefined
1602
+
1603
+ ```tsx
1604
+ const state = history.getState();
1605
+ // Returns any state passed during navigation
1606
+ ```
1607
+
1608
+ **`history.go(delta)`** navigates forward or back in history.
1609
+
1610
+ - `delta` - `number` - The number of entries to move
1611
+ - Returns: `void`
1612
+
1613
+ ```tsx
1614
+ history.go(-1); // Go back
1615
+ history.go(1); // Go forward
1616
+ history.go(-2); // Go back two steps
1617
+ ```
1618
+
1619
+ **`history.push(options)`** pushes or replaces a history entry.
1620
+
1621
+ - `options` - `HistoryPushOptions` - The URL to navigate to, with optional `replace` and `state`
1622
+ - Returns: `void`
1623
+
1624
+ ```tsx
1625
+ history.push({ url: "/users/42", state: { from: "list" } });
1626
+ history.push({ url: "/login", replace: true });
1627
+ ```
1628
+
1629
+ **`history.subscribe(listener)`** subscribes to navigation events.
1630
+
1631
+ - `listener` - `() => void` - Callback invoked when any navigation occurs
1632
+ - Returns: `() => void` - An unsubscribe function
1633
+
1634
+ ```tsx
1635
+ const unsubscribe = history.subscribe(() => {
1636
+ console.log("Navigation occurred");
1637
+ });
1638
+
1639
+ // Later: unsubscribe()
1640
+ ```
1641
+
1642
+ ## Types
1643
+
1644
+ **`RouterOptions`** are options for creating a `Router` instance or passing to `RouterRoot`.
1645
+
1646
+ ```tsx
1647
+ interface RouterOptions {
1648
+ routes: Route[]; // Array of navigable routes (required)
1649
+ basePath?: string; // Base path prefix (default: "/")
1650
+ history?: HistoryLike; // History implementation (default: BrowserHistory)
1651
+ ssrContext?: SSRContext; // Context for server-side rendering
1652
+ defaultLinkOptions?: LinkOptions; // Default options for all Link components
1653
+ }
1654
+ ```
1655
+
1656
+ **`NavigateOptions`** are options for type-safe navigation.
1657
+
1658
+ ```tsx
1659
+ type NavigateOptions = {
1660
+ to: Pattern | Route; // Route pattern string or route object
1661
+ params?: Params; // Path params
1662
+ search?: Search; // Search params
1663
+ replace?: boolean; // Replace history entry instead of pushing
1664
+ state?: any; // Arbitrary state to pass
1665
+ };
1666
+ ```
1667
+
1668
+ **`HistoryPushOptions`** are options for untyped navigation.
1669
+
1670
+ ```tsx
1671
+ interface HistoryPushOptions {
1672
+ url: string; // The URL to navigate to
1673
+ replace?: boolean; // Replace history entry instead of pushing
1674
+ state?: any; // Arbitrary state to pass
1675
+ }
1676
+ ```
1677
+
1678
+ **`MatchOptions`** are options for route matching.
1679
+
1680
+ ```tsx
1681
+ type MatchOptions = {
1682
+ from: Pattern | Route; // The route to match against
1683
+ strict?: boolean; // Require exact match (default: false, matches prefixes)
1684
+ params?: Partial<Params>; // Optional param values to filter by
1685
+ };
1686
+ ```
1687
+
1688
+ **`Match`** is the result of a successful route match.
1689
+
1690
+ ```tsx
1691
+ type Match = {
1692
+ route: Route; // Matched route object
1693
+ params: Params; // Extracted path params
1694
+ };
1695
+ ```
1696
+
1697
+ **`LinkOptions`** controls link behavior and styling.
1698
+
1699
+ ```tsx
1700
+ interface LinkOptions {
1701
+ strict?: boolean; // Strict matching for active state detection
1702
+ preload?: "intent" | "render" | "viewport" | false; // When to trigger preloading
1703
+ preloadDelay?: number; // Delay in ms before preloading starts (default: 50)
1704
+ style?: CSSProperties; // Base styles for the link
1705
+ className?: string; // Base class name for the link
1706
+ activeStyle?: CSSProperties; // Additional styles when active
1707
+ activeClassName?: string; // Additional class name when active
1708
+ }
1709
+ ```
1710
+
1711
+ **`SSRContext`** captures context during server-side rendering.
1712
+
1713
+ ```tsx
1714
+ type SSRContext = {
1715
+ redirect?: string; // Set by Navigate component during SSR
1716
+ statusCode?: number; // Can be set manually for HTTP status
1717
+ };
1718
+ ```
1719
+
1720
+ **`PreloadContext`** is the context passed to preload functions.
1721
+
1722
+ ```tsx
1723
+ interface PreloadContext {
1724
+ params: Params; // Path params for the route
1725
+ search: Search; // Validated search params
1726
+ }
1727
+ ```
1728
+
1729
+ ---
1400
1730
 
1401
- Future improvements planned for Waymark:
1731
+ # Roadmap
1402
1732
 
1403
- - **Preloader context** - Pass route params and search queries to preloader functions, enabling data fetching based on the target route's dynamic segments
1404
- - **Server-side rendering guide** - Add documentation for using Waymark in SSR environments
1733
+ - Possibility to pass an arbitrary context to the Router instance for later use in preloads?
1734
+ - Relative path navigation? Not sure it's indispensable given that users can export/import route objects and pass them as navigation option.
1735
+ - Document usage in test environments
1736
+ - Open to suggestions, we can discuss them [here](https://github.com/strblr/waymark/discussions).
1405
1737
 
1406
1738
  ---
1407
1739
 
1408
- ## License
1740
+ # License
1409
1741
 
1410
1742
  MIT