@rangojs/router 0.0.0-experimental.138 → 0.0.0-experimental.139

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,81 +1,24 @@
1
1
  # Rango
2
2
 
3
- React RSC Route Wrangler
3
+ A code-first, type-safe React Server Components router. Django-inspired:
4
+ routes are expressed in one visible tree, URLs are built from names, and
5
+ everything past the core is opt-in.
4
6
 
5
- A code-first, type-safe React Server Components router
7
+ > **Experimental:** This package is under active development. APIs may change
8
+ > between releases. Install with `@experimental` tag.
6
9
 
7
- > **Experimental:** This package is under active development. APIs may change between releases. Install with `@experimental` tag.
10
+ This page is a tour: it builds one small shop and meets the entire core API
11
+ along the way — about six primitives. Everything else is opt-in and linked at
12
+ the end. For the design rationale behind these APIs, read
13
+ [Why Rango](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/why-rango.md); this page shows how it feels, that page
14
+ argues why it's right.
8
15
 
9
- ## Features
10
-
11
- - **Named routes** — `reverse("blogPost", { slug })` for type-safe URL generation (Django-style)
12
- - **Structural composability** — Attach routes, loaders, middleware, handles, caching, prerendering, and static generation without hiding the route tree
13
- - **Composable URL patterns** — Django-style `urls()` DSL with `path`, `layout`, `include`
14
- - **Data loaders** — `createLoader()` with automatic streaming and Suspense integration
15
- - **Server actions** — `"use server"` mutations with `useActionState`, `useOptimistic`, and per-segment + per-loader `revalidate()` rules
16
- - **Live data layer** — Pre-render or cache the UI shell while loaders stay live by default at request time
17
- - **Layouts & nesting** — Nested layouts with `<Outlet />` and parallel routes
18
- - **Segment-level caching** — `cache()` DSL with TTL/SWR and pluggable cache stores
19
- - **Middleware** — Route-level middleware with cookie and header access
20
- - **Pre-rendering** — `Prerender()` and `Static()` handlers for build-time rendering
21
- - **Theme support** — Light/dark mode with FOUC prevention and system detection
22
- - **Host routing** — Multi-app routing by domain/subdomain via `@rangojs/router/host`
23
- - **Response routes** — `path.json()`, `path.text()`, `path.xml()` for API endpoints
24
- - **Trailing slash control** — Per-route canonical URLs with `"never"`, `"always"`, or `"ignore"`
25
- - **CLI codegen** — `rango generate` for route type generation
26
-
27
- ## Design Docs
28
-
29
- - [Execution model](./docs/internal/execution-model.md)
30
- - [Semantic change checklist](./docs/internal/semantic-change-checklist.md)
31
- - [Stability roadmap](./docs/internal/stability-roadmap.md)
32
-
33
- ## Installation
34
-
35
- ```bash
36
- npm install @rangojs/router@experimental
37
- ```
38
-
39
- Peer dependencies:
40
-
41
- ```bash
42
- npm install react @vitejs/plugin-rsc
43
- ```
44
-
45
- For Cloudflare Workers:
16
+ ## Install
46
17
 
47
18
  ```bash
48
- npm install @cloudflare/vite-plugin
19
+ npm install @rangojs/router@experimental react @vitejs/plugin-rsc
49
20
  ```
50
21
 
51
- ## Import Paths
52
-
53
- Use these import paths consistently:
54
-
55
- - `@rangojs/router` — server/RSC router APIs, route DSL, `createRouter`, `urls`, `redirect`, `Prerender`, `Static`, shared types
56
- - `@rangojs/router/client` — hooks and components such as `Link`, `Outlet`, `href`, `useNavigation`, `useLoader`, `useAction`, `useLocationState`
57
- - `@rangojs/router/cache` — public cache APIs such as `CFCacheStore`, `MemorySegmentCacheStore`, `createDocumentCacheMiddleware`
58
- - `@rangojs/router/host`, `@rangojs/router/theme`, `@rangojs/router/vite` — specialized public subpaths
59
- - `@rangojs/router/rsc`, `@rangojs/router/ssr` — advanced server-only integration subpaths for custom request/HTML pipelines
60
-
61
- Use only subpaths that are explicitly exported from the package. Avoid deep imports such as `@rangojs/router/cache/cf`.
62
-
63
- `@rangojs/router` is conditionally resolved. Server-only root APIs such as
64
- `createRouter()`, `urls()`, `redirect()`, `Prerender()`, and `cookies()` rely on
65
- the `react-server` export condition and are meant to run in router definitions,
66
- handlers, and other RSC/server modules. Outside that environment the root entry
67
- falls back to stub implementations that throw guidance errors.
68
-
69
- If you hit a root-entrypoint stub error:
70
-
71
- - hooks and components like `Link`, `Outlet`, `useLoader`, `useNavigation`, and `MetaTags` belong in `@rangojs/router/client`
72
- - cache APIs like `CFCacheStore` and `createDocumentCacheMiddleware` belong in `@rangojs/router/cache`
73
- - host-router APIs belong in `@rangojs/router/host`
74
-
75
- ## Quick Start
76
-
77
- ### Vite Config
78
-
79
22
  ```ts
80
23
  // vite.config.ts
81
24
  import react from "@vitejs/plugin-react";
@@ -87,41 +30,51 @@ export default defineConfig({
87
30
  });
88
31
  ```
89
32
 
90
- ### Router
33
+ The `cloudflare` preset targets Cloudflare Workers (add
34
+ `@cloudflare/vite-plugin`); the `vercel` preset emits a ready-to-deploy
35
+ `.vercel/output` (Build Output API) from a plain `vite build` — see the
36
+ [`/vercel` skill](./skills/vercel/SKILL.md); omit `preset` for the default
37
+ Node setup.
38
+
39
+ ## 1. Pages
91
40
 
92
- This file is a server/RSC module and should import router construction APIs from
93
- `@rangojs/router`.
41
+ A router is a tree. `path()` places a page, `layout()` wraps children,
42
+ `{ name }` gives a route an identity:
94
43
 
95
44
  ```tsx
96
45
  // src/router.tsx
97
- import { createRouter } from "@rangojs/router";
46
+ import { createRouter, urls } from "@rangojs/router";
47
+ import { Document } from "./document";
48
+ import { ShopLayout } from "./layouts/shop";
49
+ import { HomePage } from "./routes/home";
50
+ import { ProductPage } from "./routes/product";
98
51
 
99
- export const router = createRouter().routes(({ path }) => [
100
- path("/", HomePage, { name: "home" }),
101
- path("/about", AboutPage, { name: "about" }),
52
+ const urlpatterns = urls(({ path, layout }) => [
53
+ layout(<ShopLayout />, () => [
54
+ path("/", HomePage, { name: "home" }),
55
+ path("/products/:slug", ProductPage, { name: "product" }),
56
+ ]),
102
57
  ]);
103
58
 
104
- export const reverse = router.reverse;
105
- // reverse("home") -> "/"
59
+ export const router = createRouter({ document: Document }).routes(urlpatterns);
106
60
  ```
107
61
 
108
- For larger apps, extract route modules with `urls()` and compose with `include()`:
109
-
110
62
  ```tsx
111
- import { createRouter, urls } from "@rangojs/router";
112
- import { blogPatterns } from "./urls/blog";
113
-
114
- const urlpatterns = urls(({ path, include }) => [
115
- path("/", HomePage, { name: "home" }),
116
- include("/blog", blogPatterns, { name: "blog" }),
117
- ]);
63
+ // src/layouts/shop.tsx
64
+ import { Outlet } from "@rangojs/router/client";
118
65
 
119
- export const router = createRouter().routes(urlpatterns);
120
- // reverse("blog.post", { slug: "hello-world" }) -> "/blog/hello-world"
66
+ export function ShopLayout() {
67
+ return (
68
+ <div>
69
+ <nav>Shop</nav>
70
+ <main>
71
+ <Outlet /> {/* child routes render here */}
72
+ </main>
73
+ </div>
74
+ );
75
+ }
121
76
  ```
122
77
 
123
- ### Document
124
-
125
78
  ```tsx
126
79
  // src/document.tsx
127
80
  "use client";
@@ -145,950 +98,346 @@ export function Document({ children }: { children: ReactNode }) {
145
98
  }
146
99
  ```
147
100
 
148
- `<MetaTags />` and `<Scripts />` render the tags collected by the built-in `Meta`
149
- and `Script` handles (see [Meta Tags](#meta-tags) and [Scripts](#scripts)). The
150
- built-in `DefaultDocument` already includes all three sites, so this is only
151
- needed for a custom document.
152
-
153
- ## Defining Routes
154
-
155
- Rango is a named-route router first.
156
-
157
- Paths define where a route lives. Names define how the app refers to it.
158
-
159
- It is also structurally composable.
160
-
161
- As an app grows, routes can pull in external handlers, loaders, middleware, handles, cache policy, intercepts, prerendering, and static generation while keeping the route tree visible at the composition site.
162
-
163
- ### Named Routes
164
-
165
- ```tsx
166
- import { urls } from "@rangojs/router";
167
-
168
- const urlpatterns = urls(({ path }) => [
169
- path("/", HomePage, { name: "home" }),
170
- path("/product/:slug", ProductPage, { name: "product" }),
171
- path("/search/:query?", SearchPage, { name: "search" }),
172
- path("/files/*", FilesPage, { name: "files" }),
173
- ]);
174
- ```
175
-
176
- Use `ctx.reverse()` from handler context as the default way to link to routes from server code:
177
-
178
- ```tsx
179
- const ProductPage: Handler<"product"> = (ctx) => {
180
- const url = ctx.reverse("product", { slug: "widget" }); // "/product/widget"
181
- const searchUrl = ctx.reverse("search", undefined, { q: "rsc" }); // "/search?q=rsc"
182
- return <Link to={url}>Widget</Link>;
183
- };
184
- ```
185
-
186
- `router.reverse()` (exported from the router module) is the same function without a handler context, useful in scripts or tests. In request code, prefer `ctx.reverse()` — it auto-fills mount params from the current match.
187
-
188
- ### Composable URL Modules
189
-
190
- Local route names compose cleanly with `include(..., { name })`:
191
-
192
- ```tsx
193
- import { urls } from "@rangojs/router";
194
-
195
- export const blogPatterns = urls(({ path }) => [
196
- path("/", BlogIndexPage, { name: "index" }),
197
- path("/:slug", BlogPostPage, { name: "post" }),
198
- ]);
199
-
200
- export const urlpatterns = urls(({ path, include }) => [
201
- path("/", HomePage, { name: "home" }),
202
- include("/blog", blogPatterns, { name: "blog" }),
203
- ]);
204
-
205
- router.reverse("blog.index"); // "/blog"
206
- router.reverse("blog.post", { slug: "hello-world" }); // "/blog/hello-world"
207
- ```
208
-
209
- This is the core composition model:
210
-
211
- - Paths stay local to the module that defines them
212
- - Names become stable references across the app
213
- - `include()` scales those names without forcing raw path-string coupling
214
-
215
- ### Structural Composability
216
-
217
- Rango avoids the usual tradeoff between modularity and visibility.
218
-
219
- You can extract route behavior into separate files or packages and still keep one readable route definition that shows the structure of the app.
220
-
221
- ```tsx
222
- import { urls } from "@rangojs/router";
223
- import { ProductPage } from "./routes/product";
224
- import { ProductLoader } from "./loaders/product";
225
- import { productMiddleware } from "./middleware/product";
226
- import { productRevalidate } from "./revalidation/product";
227
-
228
- const shopPatterns = urls(({ path, loader, middleware, revalidate, cache }) => [
229
- path("/product/:slug", ProductPage, { name: "product" }, () => [
230
- middleware(productMiddleware),
231
- loader(ProductLoader),
232
- revalidate(productRevalidate),
233
- cache({ ttl: 300 }),
234
- ]),
235
- ]);
236
- ```
237
-
238
- The route tree stays explicit even when behavior is modular.
239
-
240
- This applies to:
241
-
242
- - external route modules mounted with `include()`
243
- - imported loaders, middleware, and handles attached at the route site
244
- - prerendering and static generation attached without turning the route tree opaque
245
-
246
- ### Loaders As the Live Data Layer
247
-
248
- Rango separates app structure from app data.
249
-
250
- Routes, layouts, and pre-rendered segments can be static or cached, while
251
- loaders stay live by default and re-resolve at request time.
252
-
253
- This means you can pre-render or cache the shell of a page without freezing its
254
- data.
255
-
256
- - `cache()` caches route structure and rendered UI segments
257
- - `Prerender()` skips loaders at build time
258
- - `loader()` provides fresh request-time data
259
- - individual loaders can opt into caching explicitly when needed
260
-
261
- ```tsx
262
- import { urls, Prerender } from "@rangojs/router";
263
- import { ArticleLoader } from "./loaders/article";
264
-
265
- const docsPatterns = urls(({ path, loader }) => [
266
- path("/docs/:slug", Prerender(DocsArticle), { name: "docs.article" }, () => [
267
- loader(ArticleLoader), // fresh by default
268
- ]),
269
- ]);
270
- ```
271
-
272
- Pre-render the page, keep the data live.
273
-
274
- ### Typed Handlers
101
+ (The built-in `DefaultDocument` already wires all of this a custom document
102
+ is optional.)
275
103
 
276
- Route handlers receive a typed context with params, search params, and `reverse()`:
104
+ A handler is a function of `ctx`. Typing it by route name gives typed params
105
+ — the Vite plugin generates the route map automatically, nothing to register:
277
106
 
278
107
  ```tsx
108
+ // src/routes/product.tsx
279
109
  import type { Handler } from "@rangojs/router";
280
110
 
281
111
  export const ProductPage: Handler<"product"> = (ctx) => {
282
- const { slug } = ctx.params; // typed from pattern
283
- const homeUrl = ctx.reverse("home"); // type-safe URL by route name
284
- return <h1>Product: {slug}</h1>;
285
- };
286
- ```
287
-
288
- ### Choosing a Handler Style
289
-
290
- All handler typing styles are supported, but they solve different problems:
291
-
292
- - `Handler<"product">` — default for named app routes
293
- - `Handler<".post", ScopedRouteMap<"blog">>` — best for reusable included modules
294
- - `Handler<"/blog/:slug">` — good for unnamed or local-only extracted handlers
295
- - `Handler<{ slug: string }>` — escape hatch for advanced or decoupled cases
296
-
297
- Example of a scoped local name inside a mounted module:
298
-
299
- ```tsx
300
- import type { Handler } from "@rangojs/router";
301
- import type { ScopedRouteMap } from "@rangojs/router/__internal";
302
-
303
- type BlogRoutes = ScopedRouteMap<"blog">;
304
-
305
- export const BlogPostPage: Handler<".post", BlogRoutes> = (ctx) => {
306
- return <a href={ctx.reverse(".index")}>Back to blog</a>;
307
- };
308
- ```
309
-
310
- See [`../../docs/named-routes.md`](../../docs/named-routes.md) for the recommended mental model.
311
-
312
- ### Search Params
313
-
314
- Define a search schema on the route for type-safe search parameters:
315
-
316
- ```tsx
317
- const urlpatterns = urls(({ path }) => [
318
- path("/search", SearchPage, {
319
- name: "search",
320
- search: { q: "string", page: "number?", sort: "string?" },
321
- }),
322
- ]);
323
-
324
- // Handler receives typed search params via ctx.search
325
- const SearchPage: Handler<"search"> = (ctx) => {
326
- const { q, page, sort } = ctx.search;
327
- // q: string, page: number | undefined, sort: string | undefined
112
+ return <h1>{ctx.params.slug}</h1>; // slug: string, from the pattern
328
113
  };
329
114
  ```
330
115
 
331
- ### Trailing Slash Handling
332
-
333
- Trailing slash behavior is a current `path()` feature.
334
-
335
- Set it per route with `trailingSlash`:
116
+ And because routes have names, URLs are built, never hand-written:
336
117
 
337
118
  ```tsx
338
- const urlpatterns = urls(({ path }) => [
339
- path("/about", AboutPage, {
340
- name: "about",
341
- trailingSlash: "never",
342
- }),
343
- path("/docs/", DocsPage, {
344
- name: "docs",
345
- trailingSlash: "always",
346
- }),
347
- path("/webhook", WebhookHandler, {
348
- name: "webhook",
349
- trailingSlash: "ignore",
350
- }),
351
- ]);
119
+ const url = ctx.reverse("product", { slug: "espresso-cup" });
120
+ // "/products/espresso-cup" name and params compile-time checked
352
121
  ```
353
122
 
354
- Modes:
355
-
356
- - `"never"` canonical URL has no trailing slash, redirects `/about/` to `/about`
357
- - `"always"` — canonical URL has a trailing slash, redirects `/docs` to `/docs/`
358
- - `"ignore"` — matches both forms without redirect
359
-
360
- Default behavior when `trailingSlash` is omitted:
361
-
362
- - There is no separate global default mode
363
- - If the pattern is defined without a trailing slash, the canonical URL is the no-slash form
364
- - If the pattern is defined with a trailing slash, the canonical URL is the slash form
365
- - The router redirects to the canonical form based on the pattern you defined
366
-
367
- The recommended public API is the per-route `path(..., { trailingSlash })` option. Use `"ignore"` sparingly, especially on content pages, because `/x` and `/x/` are distinct URLs.
368
-
369
- ### Response Routes
123
+ Rename `/products/:slug` to `/shop/:slug` in the one place it's defined and
124
+ every link, redirect, and prefetch follows. In client components, `href()`
125
+ validates static paths against the registered patterns:
126
+ `<Link to={href("/")}>Home</Link>`.
370
127
 
371
- Define API endpoints that bypass the RSC pipeline:
128
+ The tree is also lazy-first, which is the shape serverless cold starts want.
129
+ `include()` mounts a whole route module under a prefix — and with the async
130
+ form, `include("/shop", () => import("./shop"))`, the group is code-split:
131
+ its module doesn't load or run until a request matches it, a group nobody
132
+ visits never evaluates at all, and warm requests run zero route handlers.
133
+ Boot cost stays flat as the app grows — one module body at startup, not one
134
+ per group — while matching stays an `O(path length)` prefix trie, identical
135
+ in dev and production. None of this is assumed: the trie is benchmarked
136
+ in-repo against multi-thousand-route manifests, and the lazy guarantees are
137
+ pinned by run-count tests (see
138
+ [matching & lazy discovery](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/internal/matching-and-lazy-discovery.md)).
139
+ Grow the tree without watching the boot time.
372
140
 
373
- ```tsx
374
- const urlpatterns = urls(({ path }) => [
375
- path.json("/api/health", () => ({ status: "ok" }), { name: "health" }),
376
- path.text("/robots.txt", () => "User-agent: *\nAllow: /", { name: "robots" }),
377
- path.xml("/feed.xml", () => "<rss>...</rss>", { name: "feed" }),
378
- ]);
379
- ```
141
+ That's a working site. Everything below adds to this app.
380
142
 
381
- Response types available: `path.json()`, `path.text()`, `path.html()`, `path.xml()`, `path.image()`, `path.stream()`, `path.any()`.
143
+ ## 2. Data
382
144
 
383
- ## Layouts & Nesting
384
-
385
- ### Layouts with Outlet
145
+ The product page needs data. A handler is an async server component — fetch
146
+ where you render:
386
147
 
387
148
  ```tsx
388
- import { urls } from "@rangojs/router";
389
-
390
- const urlpatterns = urls(({ path, layout }) => [
391
- layout(<MainLayout />, () => [
392
- path("/", HomePage, { name: "home" }),
393
- path("/about", AboutPage, { name: "about" }),
394
- ]),
395
- ]);
396
- ```
397
-
398
- ```tsx
399
- "use client";
400
- import { Outlet } from "@rangojs/router/client";
401
-
402
- function MainLayout() {
403
- return (
404
- <div>
405
- <nav>...</nav>
406
- <Outlet />
407
- </div>
408
- );
409
- }
410
- ```
411
-
412
- ### Loading Skeletons
413
-
414
- ```tsx
415
- const urlpatterns = urls(({ path, loading }) => [
416
- path("/product/:slug", ProductPage, { name: "product" }, () => [
417
- loading(<ProductSkeleton />),
418
- ]),
419
- ]);
420
- ```
421
-
422
- ### Parallel Routes
423
-
424
- ```tsx
425
- const urlpatterns = urls(({ path, layout, parallel, loader, loading }) => [
426
- layout(BlogLayout, () => [
427
- parallel({ "@sidebar": BlogSidebarHandler }, () => [
428
- loader(BlogSidebarLoader),
429
- loading(<SidebarSkeleton />),
430
- ]),
431
- path("/blog", BlogIndexPage, { name: "blog" }),
432
- path("/blog/:slug", BlogPostPage, { name: "blogPost" }),
433
- ]),
434
- ]);
149
+ // src/routes/product.tsx
150
+ export const ProductPage: Handler<"product"> = async (ctx) => {
151
+ const product = await db.products.find(ctx.params.slug);
152
+ ctx.use(Meta)({ title: product.name }); // metadata where the data is
153
+ return <ProductView product={product} />;
154
+ };
435
155
  ```
436
156
 
437
- ## Data Loaders
157
+ That's the default data path. React Router and Remix split data into a
158
+ loader beside the component because components couldn't fetch; RSC collapses
159
+ the split, and Rango doesn't reintroduce it. (That `ctx.use(Meta)` line is
160
+ also the whole metadata story: push tags where the data already is, layouts
161
+ set title templates, deeper segments override — no separate metadata export,
162
+ no second fetch.)
438
163
 
439
- ### Creating a Loader
164
+ Loaders enter when data needs a life of its own. First case: a **client
165
+ component** needs server data — the stock badge is interactive, but the
166
+ stock lives in the database:
440
167
 
441
168
  ```tsx
169
+ // src/loaders/stock.ts
442
170
  import { createLoader } from "@rangojs/router";
443
171
 
444
- export const BlogSidebarLoader = createLoader(async (ctx) => {
445
- const posts = await db.getRecentPosts();
446
- return { posts, loadedAt: new Date().toISOString() };
172
+ export const StockLoader = createLoader(async (ctx) => {
173
+ "use server";
174
+ return db.stockFor(ctx.params.slug);
447
175
  });
448
176
  ```
449
177
 
450
- ### Using in Server Components (Handlers)
451
-
452
178
  ```tsx
453
- import type { HandlerContext } from "@rangojs/router";
454
- import { BlogSidebarLoader } from "./loaders/blog";
455
-
456
- async function BlogSidebarHandler(ctx: HandlerContext) {
457
- const { posts } = await ctx.use(BlogSidebarLoader);
458
- return (
459
- <ul>
460
- {posts.map((p) => (
461
- <li key={p.slug}>{p.title}</li>
462
- ))}
463
- </ul>
464
- );
465
- }
179
+ path("/products/:slug", ProductPage, { name: "product" }, () => [
180
+ loader(StockLoader),
181
+ loading(<ProductSkeleton />),
182
+ ]),
466
183
  ```
467
184
 
468
- ### Using in Client Components
469
-
470
185
  ```tsx
186
+ // src/components/stock-badge.tsx
471
187
  "use client";
472
188
  import { useLoader } from "@rangojs/router/client";
473
- import { BlogSidebarLoader } from "./loaders/blog";
189
+ import { StockLoader } from "../loaders/stock";
474
190
 
475
- function BlogSidebar() {
476
- const { data } = useLoader(BlogSidebarLoader);
477
- return (
478
- <ul>
479
- {data.posts.map((p) => (
480
- <li key={p.slug}>{p.title}</li>
481
- ))}
482
- </ul>
483
- );
191
+ export function StockBadge() {
192
+ const { data } = useLoader(StockLoader);
193
+ return <span>{data.inStock ? "In stock" : "Sold out"}</span>;
484
194
  }
485
195
  ```
486
196
 
487
- ### Attaching Loaders to Routes
197
+ Loaders run in parallel with the handler and stream; `loading()` opts the
198
+ segment into skeleton-then-stream. Without it, document requests arrive
199
+ **ready** — the HTML ships with data in place; the skeleton is a per-segment
200
+ choice, not the first impression.
488
201
 
489
- ```tsx
490
- const urlpatterns = urls(({ path, loader }) => [
491
- path("/blog", BlogIndexPage, { name: "blog" }, () => [
492
- loader(BlogSidebarLoader),
493
- ]),
494
- ]);
495
- ```
202
+ The rule of thumb: fetch in the **handler** when the data belongs to the
203
+ rendered page it will be frozen with the shell if you cache it (step 4).
204
+ Put data in a **loader** when it must outlive the shell: shared with client
205
+ components, fresh on every hit even when the segment is cached, refetchable
206
+ from the client, or revalidated on its own after actions.
496
207
 
497
- ## Server Actions
208
+ ## 3. Mutations
498
209
 
499
- Server actions are React's RSC mutation primitive. Define them with the
500
- `"use server"` directive Rango uses standard React 19 hooks
501
- (`useActionState`, `useFormStatus`, `useOptimistic`) with no framework wrapper.
210
+ Users add to cart. A server action is a plain `"use server"` function; the
211
+ form posts to it with standard React 19 hooks — and it works without
212
+ JavaScript:
502
213
 
503
214
  ```tsx
504
- // app/actions/cart.ts
215
+ // src/actions/cart.ts
505
216
  "use server";
506
217
 
507
- import { getRequestContext } from "@rangojs/router";
508
-
509
- export async function addToCart(productId: string): Promise<void> {
510
- const ctx = getRequestContext();
511
- const userId = ctx.get("user").id;
512
- await db.cart.insert({ userId, productId });
218
+ export async function addToCart(productId: string) {
219
+ await db.cart.insert({ productId });
513
220
  }
514
221
  ```
515
222
 
516
223
  ```tsx
517
- // Client form with progressive enhancement + pending state
224
+ // src/components/add-to-cart.tsx
518
225
  "use client";
519
226
  import { useActionState } from "react";
520
- import { saveProfile } from "../actions/profile";
227
+ import { addToCart } from "../actions/cart";
521
228
 
522
- export function ProfileForm() {
523
- const [state, action, pending] = useActionState(saveProfile, null);
229
+ export function AddToCart({ productId }: { productId: string }) {
230
+ const [, action, pending] = useActionState(() => addToCart(productId), null);
524
231
  return (
525
232
  <form action={action}>
526
- <input name="name" defaultValue={state?.values?.name} />
527
- {state?.errors?.name && <p role="alert">{state.errors.name}</p>}
528
- <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
233
+ <button disabled={pending}>{pending ? "Adding…" : "Add to cart"}</button>
529
234
  </form>
530
235
  );
531
236
  }
532
237
  ```
533
238
 
534
- After an action runs, matched route segments (path/layout/parallel/intercept)
535
- and loaders can re-render/re-resolve so the UI reflects the new state.
536
- Attach a `revalidate(({ actionId }) => ...)` rule on any segment or loader
537
- that owns data the action touched:
239
+ After an action, route segments and loaders re-render by default so the UI
240
+ reflects the new state. `revalidate()` narrows that to the segments that
241
+ actually own the data matched by action **reference**, so renames are
242
+ compile errors, not stale predicates:
538
243
 
539
244
  ```tsx
540
- urls(({ path, loader, revalidate }) => [
541
- // Segment-level: re-render the cart page handler after cart actions.
542
- // Nest loaders that belong to this route inside the same path() so the
543
- // segment owns its data dependencies.
544
- path("/cart", CartPage, { name: "cart" }, () => [
545
- revalidate(
546
- ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
547
- ),
548
- loader(CartLoader, () => [
549
- revalidate(
550
- ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
551
- ),
552
- ]),
553
- ]),
554
- ]);
555
- ```
556
-
557
- For the full guide — validation with Zod, error handling, file uploads,
558
- `useOptimistic`, redirects, and progressive enhancement — see the
559
- `/server-actions` skill.
560
-
561
- ## Navigation & Links
562
-
563
- ### Named Routes with `ctx.reverse()` (Server)
245
+ import * as CartActions from "./actions/cart";
564
246
 
565
- In server components and handlers, use `ctx.reverse()` to generate URLs by route name. This is the default — it is typed, auto-fills mount params from the current match, and resolves both local (`.name`) and absolute (`name.sub`) names:
566
-
567
- ```tsx
568
- import { Link } from "@rangojs/router/client";
569
- import type { Handler } from "@rangojs/router";
570
-
571
- const BlogPostPage: Handler<"blogPost"> = (ctx) => {
572
- const backUrl = ctx.reverse("blog");
573
- return <Link to={backUrl}>Back to blog</Link>;
574
- };
575
- ```
576
-
577
- `reverse()` is type-safe — route names and required params are checked at compile time. Included routes use dotted names: `ctx.reverse("api.health")`.
578
-
579
- For scripts, tests, or other code without a handler context, import the router-level `reverse`:
580
-
581
- ```tsx
582
- import { reverse } from "./router";
583
- reverse("blogPost", { slug: "my-post" });
584
- ```
585
-
586
- ### Client Components
587
-
588
- **`reverse()` is server-only.** It depends on the route manifest and handler context — neither is available in the browser bundle. Client components receive URLs as props, loader data, or server-action return values:
589
-
590
- ```tsx
591
- // server
592
- function BlogIndex(ctx: HandlerContext) {
593
- return (
594
- <Nav
595
- home={ctx.reverse("home")}
596
- post={ctx.reverse("blogPost", { slug: "my-post" })}
597
- />
598
- );
599
- }
600
- ```
601
-
602
- ```tsx
603
- "use client";
604
- import { Link } from "@rangojs/router/client";
605
-
606
- export function Nav({ home, post }: { home: string; post: string }) {
607
- return (
608
- <nav>
609
- <Link to={home}>Home</Link>
610
- <Link to={post}>My Post</Link>
611
- </nav>
612
- );
613
- }
614
- ```
615
-
616
- For client-side navigation to static paths (no named-route lookup), use `href()` — see below. For URLs tied to named routes, you have two options: import the per-module generated `routes` map and use `useReverse(routes)` for in-module names (see [`/links` skill](./skills/links/SKILL.md)), or generate the URL on the server and pass the string in for cross-module URLs.
617
-
618
- ### `href()` for Path Validation (Client Components)
619
-
620
- In client components, use `href()` for compile-time path validation on static path strings:
621
-
622
- ```tsx
623
- "use client";
624
- import { Link, href } from "@rangojs/router/client";
625
-
626
- function Nav() {
627
- return (
628
- <nav>
629
- <Link to={href("/")}>Home</Link>
630
- <Link to={href("/blog")} prefetch="adaptive">
631
- Blog
632
- </Link>
633
- <Link to={href("/about")}>About</Link>
634
- </nav>
635
- );
636
- }
637
- ```
638
-
639
- `href()` validates that the path matches a registered route pattern at compile time (e.g. `/blog/my-post` matches `/blog/:slug`).
640
-
641
- ### Navigation Hooks
642
-
643
- ```tsx
644
- "use client";
645
- import { useNavigation, useRouter } from "@rangojs/router/client";
646
-
647
- function SearchForm() {
648
- const router = useRouter();
649
- const nav = useNavigation();
650
-
651
- function handleSubmit(query: string) {
652
- router.push(`/search?q=${encodeURIComponent(query)}`);
653
- }
654
-
655
- return <form onSubmit={...}>{nav.state !== "idle" && <Spinner />}</form>;
656
- }
657
- ```
658
-
659
- ### Scroll Restoration
660
-
661
- ```tsx
662
- "use client";
663
- import { ScrollRestoration } from "@rangojs/router/client";
664
-
665
- function Document({ children }) {
666
- return (
667
- <html>
668
- <body>
669
- {children}
670
- <ScrollRestoration />
671
- </body>
672
- </html>
673
- );
674
- }
675
- ```
676
-
677
- ## Includes (Composable Modules)
678
-
679
- Split URL patterns into composable modules with `include()`:
680
-
681
- ```tsx
682
- // src/api/urls.tsx
683
- import { urls } from "@rangojs/router";
684
-
685
- export const apiPatterns = urls(({ path }) => [
686
- path.json("/health", () => ({ status: "ok" }), { name: "health" }),
687
- path.json("/products", getProducts, { name: "products" }),
688
- ]);
689
-
690
- // src/urls.tsx
691
- import { urls } from "@rangojs/router";
692
- import { apiPatterns } from "./api/urls";
693
-
694
- export const urlpatterns = urls(({ path, include }) => [
695
- path("/", HomePage, { name: "home" }),
696
- include("/api", apiPatterns, { name: "api" }),
697
- // Mounts apiPatterns under /api: /api/health, /api/products
698
- ]);
247
+ path("/cart", CartPage, { name: "cart" }, () => [
248
+ loader(CartLoader, () => [
249
+ revalidate((ctx) => ctx.isAction(CartActions) || undefined),
250
+ ]),
251
+ ]),
699
252
  ```
700
253
 
701
- Included route names are prefixed with the include name: `reverse("api.health")`, `reverse("api.products")`.
254
+ Notice what you didn't write: no API endpoint, no fetch wrapper, and no
255
+ client-cache invalidation call. Actions invalidate the client-side caches
256
+ (history entries, prefetches, HTTP cache key) automatically — a no-op action
257
+ can opt out per invocation with `keepClientCache()`.
702
258
 
703
- ### Include name scoping
259
+ ## 4. Speed
704
260
 
705
- The `name` option controls how child route names appear globally:
706
-
707
- | Form | Child names | Generated types | Reverse resolution |
708
- | ---------------------------------- | ------------------- | ---------------------- | -------------------------------------------------------------------- |
709
- | `include("/x", p, { name: "ns" })` | `ns.child` | Exported as `ns.child` | `reverse("ns.child")` globally, `reverse(".child")` inside |
710
- | `include("/x", p, { name: "" })` | `child` (flattened) | Exported as-is | `reverse("child")` globally, `reverse(".child")` inside (root-scope) |
711
- | `include("/x", p)` | Private scope | Not exported | `reverse(".child")` inside only |
712
-
713
- Without a `name`, included routes are local to the mounted module. They still match requests and render normally, but their names are hidden from the generated route map and cannot be reversed globally. Use `{ name: "" }` to merge children into the parent namespace without adding a prefix.
714
-
715
- **`{ name: "" }` is flattening, not isolation.** Flattened routes behave as if defined inline at the include site — dot-local reverse (`.name`) can reach any sibling route at root scope, including routes from other `{ name: "" }` mounts. If you need module-level isolation, omit the `name` option or use a namespace.
716
-
717
- ## Middleware
261
+ Production traffic. Wrap a segment in `cache()` and the rendered shell —
262
+ including everything the handler fetched — is stored, while every loader on
263
+ it keeps running fresh on each hit. This is where the handler-vs-loader
264
+ choice from step 2 pays off: handler data freezes with the shell, the
265
+ `StockLoader` stays live. Cached shell, live data, one line:
718
266
 
719
267
  ```tsx
720
- const urlpatterns = urls(({ path, middleware }) => [
721
- middleware(
722
- async (ctx, next) => {
723
- const start = Date.now();
724
- const response = await next();
725
- console.log(
726
- `${ctx.request.method} ${ctx.url.pathname} ${Date.now() - start}ms`,
727
- );
728
- return response;
729
- },
730
- () => [path("/dashboard", DashboardPage, { name: "dashboard" })],
731
- ),
732
- ]);
733
- ```
734
-
735
- ## Caching
736
-
737
- ### Route-Level Caching
738
-
739
- ```tsx
740
- const urlpatterns = urls(({ path, cache }) => [
741
- cache({ ttl: 60, swr: 300 }, () => [
742
- path("/blog", BlogIndexPage, { name: "blog" }),
743
- path("/blog/:slug", BlogPostPage, { name: "blogPost" }),
268
+ const urlpatterns = urls(({ path, layout, loader, loading, cache }) => [
269
+ layout(<ShopLayout />, () => [
270
+ path("/", HomePage, { name: "home" }),
271
+ cache({ ttl: 600, swr: 3600, tags: ["products"] }, () => [
272
+ path("/products/:slug", ProductPage, { name: "product" }, () => [
273
+ loader(StockLoader), // never cached: re-runs on every hit
274
+ loading(<ProductSkeleton />),
275
+ ]),
276
+ ]),
744
277
  ]),
745
278
  ]);
746
279
  ```
747
280
 
748
- ### Cache Store Configuration
281
+ Wire a store once on the router (`MemorySegmentCacheStore` for dev,
282
+ `CFCacheStore` for Cloudflare — see the [`/caching` skill](./skills/caching/SKILL.md)),
283
+ and bust by tag from the mutation that changes the data:
749
284
 
750
285
  ```tsx
751
- import { createRouter } from "@rangojs/router";
752
- import {
753
- CFCacheStore,
754
- createDocumentCacheMiddleware,
755
- } from "@rangojs/router/cache";
756
-
757
- export const router = createRouter({
758
- document: Document,
759
- cache: (env) => ({
760
- store: new CFCacheStore({
761
- defaults: { ttl: 60, swr: 300 },
762
- ctx: env.ctx,
763
- }),
764
- }),
765
- })
766
- .use(createDocumentCacheMiddleware())
767
- .routes(urlpatterns);
768
- ```
769
-
770
- Available cache stores:
771
-
772
- - `CFCacheStore` — Cloudflare edge cache (production)
773
- - `MemorySegmentCacheStore` — In-memory cache (development/testing)
774
-
775
- ## Pre-rendering
776
-
777
- Pre-rendering generates route segments at build time. The worker handles all requests — there are no static files served from assets.
778
-
779
- ### Static Segments
780
-
781
- Use `Static()` for segments rendered once at build time (no params). Works on `path()`, `layout()`, and `parallel()`:
782
-
783
- ```tsx
784
- import { Static } from "@rangojs/router";
785
-
786
- export const AboutPage = Static(async () => {
787
- return <article>...</article>;
788
- });
286
+ // src/actions/products.ts
287
+ "use server";
288
+ import { updateTag } from "@rangojs/router";
789
289
 
790
- export const DocsNav = Static(async () => {
791
- const items = await readDocsNavItems();
792
- return (
793
- <nav>
794
- {items.map((i) => (
795
- <a key={i.slug} href={i.slug}>
796
- {i.title}
797
- </a>
798
- ))}
799
- </nav>
800
- );
801
- });
290
+ export async function renameProduct(id: string, name: string) {
291
+ await db.products.rename(id, name);
292
+ await updateTag("products"); // awaitable, read-your-own-writes
293
+ }
802
294
  ```
803
295
 
804
- ### Dynamic Routes with Prerender
805
-
806
- Use `Prerender()` for route-scoped pre-rendering. With params, provide `getParams` first, handler second:
296
+ Navigation speed is a `Link` prop away:
807
297
 
808
298
  ```tsx
809
- import { Prerender } from "@rangojs/router";
810
-
811
- export const BlogPost = Prerender(
812
- async () => {
813
- const slugs = await getAllBlogSlugs();
814
- return slugs.map((slug) => ({ slug }));
815
- },
816
- async (ctx) => {
817
- const post = await getPost(ctx.params.slug);
818
- return <article>{post.content}</article>;
819
- },
820
- );
299
+ <Link to={url} prefetch="viewport">
300
+ {product.name}
301
+ </Link>
821
302
  ```
822
303
 
823
- ### Passthrough for Unknown Params
304
+ A fully-prefetched navigation commits a **finished page** — no skeleton, no
305
+ loading flash — and staying correct is the router's job: every action
306
+ invalidates the prefetch caches by default, so a prefetched page can't show
307
+ pre-mutation data.
824
308
 
825
- Wrap a `Prerender` definition with `Passthrough()` to add a live handler for unknown params at runtime. The build handler runs at build time, the live handler runs at request time for params not in the prerender cache.
309
+ To move the shell's cost to build time entirely, `Prerender()` bakes it while
310
+ loaders stay live at runtime — same mental model, earlier cache write. See
311
+ the [`/prerender` skill](./skills/prerender/SKILL.md).
826
312
 
827
- ```tsx
828
- import { Prerender, Passthrough } from "@rangojs/router";
829
-
830
- export const ProductPageDef = Prerender(
831
- async () => {
832
- const featured = await db.getFeaturedProducts();
833
- return featured.map((p) => ({ id: p.id }));
834
- },
835
- async (ctx) => {
836
- const product = await db.getProduct(ctx.params.id);
837
- return <Product data={product} />;
838
- },
839
- );
840
-
841
- // In route definition:
842
- path(
843
- "/products/:id",
844
- Passthrough(ProductPageDef, async (ctx) => {
845
- const product = await ctx.env.DB.getProduct(ctx.params.id);
846
- return <Product data={product} />;
847
- }),
848
- );
849
- ```
313
+ ## 5. An API, when you need one
850
314
 
851
- Build handlers can also skip individual param sets with `ctx.passthrough()`, deferring them to the live handler:
315
+ Response routes live in the same tree `path.json()`, `path.text()`,
316
+ `path.xml()`, `path.image()`, `path.stream()`:
852
317
 
853
318
  ```tsx
854
- export const ProductPageDef = Prerender(
855
- async () => {
856
- const all = await db.getAllProducts();
857
- return all.map((p) => ({ id: p.id }));
858
- },
859
- async (ctx) => {
860
- const product = await db.getProduct(ctx.params.id);
861
- if (!product.published) return ctx.passthrough();
862
- return <Product data={product} />;
863
- },
864
- );
319
+ path("/products/:slug", ProductPage, { name: "product" }),
320
+ path.json("/products/:slug", (ctx) => db.products.find(ctx.params.slug), {
321
+ name: "productJson",
322
+ }),
865
323
  ```
866
324
 
867
- ### Build-Time Environment Bindings
868
-
869
- Prerender handlers can access platform bindings (KV, D1, R2) at build time when `buildEnv` is configured in the Vite plugin:
325
+ Same URL: browsers get the page, API clients get JSON, negotiated by
326
+ `Accept` header in the route trie. Handlers return bare values; errors
327
+ serialize as RFC 9457 `application/problem+json`. The payload type is
328
+ inferred from the handler — no codegen:
870
329
 
871
330
  ```ts
872
- // vite.config.ts
873
- import { rango } from "@rangojs/router/vite";
874
-
875
- rango({ preset: "cloudflare", buildEnv: "auto" });
876
- ```
877
-
878
- With `buildEnv: "auto"`, the plugin calls `wrangler.getPlatformProxy()` to provide local bindings. Handlers then access `ctx.env` during build:
879
-
880
- ```tsx
881
- export const BlogPosts = Prerender<{ slug: string }>(
882
- async (ctx) => {
883
- const rows = await ctx.env.DB.prepare("SELECT slug FROM posts").all();
884
- return rows.map((r) => ({ slug: r.slug }));
885
- },
886
- async (ctx) => {
887
- const post = await ctx.env.DB.prepare("SELECT * FROM posts WHERE slug = ?")
888
- .bind(ctx.params.slug)
889
- .first();
890
- return <BlogPost post={post} />;
891
- },
892
- );
893
- ```
894
-
895
- `buildEnv` also accepts a factory function or plain object:
331
+ type Product = RouteResponse<typeof urlpatterns, "productJson">;
332
+ ```
333
+
334
+ See the [`/api-client` skill](./skills/api-client/SKILL.md) for a small typed
335
+ client over these endpoints.
336
+
337
+ ## Everything else, when you need it
338
+
339
+ That was the core: `path`/`layout`/`include`, names, loaders, actions +
340
+ `revalidate`, `cache`, response routes. The rest is opt-in — reach for it
341
+ when the requirement appears:
342
+
343
+ | I need to… | Skill |
344
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------- |
345
+ | guard or shape requests (auth, headers) | [`/middleware`](./skills/middleware/SKILL.md) |
346
+ | multi-column layouts, independent slots | [`/parallel`](./skills/parallel/SKILL.md) |
347
+ | open a route as a modal on soft navigation | [`/intercept`](./skills/intercept/SKILL.md) |
348
+ | compose route modules / sub-apps | [`/route`](./skills/route/SKILL.md), [`/composability`](./skills/composability/SKILL.md) |
349
+ | cache a single function or component | [`/use-cache`](./skills/use-cache/SKILL.md), [`/cache-guide`](./skills/cache-guide/SKILL.md) |
350
+ | feed live loaders from a cached shell | [`/shell-manifest`](./skills/shell-manifest/SKILL.md) |
351
+ | edge caching with Cache-Control | [`/document-cache`](./skills/document-cache/SKILL.md) |
352
+ | light/dark mode without FOUC | [`/theme`](./skills/theme/SKILL.md) |
353
+ | analytics / third-party scripts with CSP nonce | [`/scripts`](./skills/scripts/SKILL.md) |
354
+ | locale routing | [`/i18n`](./skills/i18n/SKILL.md) |
355
+ | SSE and WebSockets | [`/streams-and-websockets`](./skills/streams-and-websockets/SKILL.md) |
356
+ | multi-app routing by domain | [`/host-router`](./skills/host-router/SKILL.md) |
357
+ | animate navigations | [`/view-transitions`](./skills/view-transitions/SKILL.md) |
358
+ | test loaders, middleware, handlers, Flight | [`/testing`](./skills/testing/SKILL.md) |
359
+ | see where request time goes | [`/observability`](./skills/observability/SKILL.md) |
360
+ | deploy to Vercel (cache store, tracing, output) | [`/vercel`](./skills/vercel/SKILL.md) |
361
+ | compare Rango with Next.js / TanStack / Waku | [`/comparison`](./skills/comparison/SKILL.md) |
362
+
363
+ The [`/rango` skill](./skills/rango/SKILL.md) is the full catalog and the
364
+ mental model that ties it together.
365
+
366
+ ## Reference
367
+
368
+ ### Imports and subpaths
369
+
370
+ | Export | Description |
371
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
372
+ | `@rangojs/router` | Server/RSC core and shared types: `createRouter`, `urls`, `createLoader`, `Handler`, `Prerender`, `Meta` |
373
+ | `@rangojs/router/client` | Client: `Link`, `Outlet`, `href`, `useNavigation`, `useLoader`, `MetaTags` |
374
+ | `@rangojs/router/cache` | Cache: `CFCacheStore`, `VercelCacheStore`, `MemorySegmentCacheStore`, `createDocumentCacheMiddleware` |
375
+ | `@rangojs/router/theme` | Theme: `useTheme`, `ThemeProvider`, `ThemeScript` |
376
+ | `@rangojs/router/host` | Host routing: `createHostRouter`, `defineHosts`, `isNoRouteMatchError` |
377
+ | `@rangojs/router/vercel` | Vercel: `createVercelTracing` (phase spans via `@vercel/otel`'s global tracer) |
378
+ | `@rangojs/router/vite` | Vite plugin: `rango()` |
379
+ | `@rangojs/router/testing` | Consumer testing primitives: `runLoader`, `runMiddleware`, `dispatch` (plus `/testing/dom`, `/testing/flight`, `/testing/e2e`) |
380
+ | `@rangojs/router/rsc` | Advanced server pipeline APIs: `createRSCHandler`, request-context access |
381
+ | `@rangojs/router/ssr` | Advanced SSR bridge APIs: `createSSRHandler` |
382
+
383
+ Use only subpaths that are explicitly exported; avoid deep imports.
384
+
385
+ The root entry is conditionally resolved: server-only APIs (`createRouter`,
386
+ `urls`, `redirect`, `Prerender`, `cookies`) run under the `react-server`
387
+ condition and throw guidance errors elsewhere. If you hit a root-entrypoint
388
+ stub error: hooks and components (`Link`, `Outlet`, `useLoader`, `MetaTags`)
389
+ live in `@rangojs/router/client`; cache APIs in `@rangojs/router/cache`;
390
+ host APIs in `@rangojs/router/host`.
391
+
392
+ ### Type safety
393
+
394
+ The Vite plugin generates `router.named-routes.gen.ts` automatically (on dev
395
+ startup, HMR, and builds), registering route names, params, and search
396
+ schemas globally via `Rango.GeneratedRouteMap`. That powers `Handler<"name">`,
397
+ `ctx.reverse()`, and `RouteParams<"name">` with no manual registration.
398
+
399
+ For response-aware and path-based utilities (`href()`, `Rango.Path`,
400
+ `RouteResponse`), augment `Rango.RegisteredRoutes` once:
896
401
 
897
402
  ```ts
898
- // Custom factory
899
- rango({
900
- buildEnv: async (ctx) => {
901
- const { getPlatformProxy } = await import("wrangler");
902
- const proxy = await getPlatformProxy();
903
- return { env: proxy.env, dispose: proxy.dispose };
904
- },
905
- });
906
-
907
- // Plain object (Node.js)
908
- rango({ buildEnv: { DATABASE_URL: process.env.DATABASE_URL } });
909
- ```
910
-
911
- Build-time env applies to both production builds and dev on-demand prerender. Without `buildEnv`, accessing `ctx.env` in a Prerender handler throws with a clear error.
912
-
913
- ## Theme
914
-
915
- ### Router Configuration
916
-
917
- ```tsx
918
- export const router = createRouter({
919
- document: Document,
920
- theme: {
921
- defaultTheme: "light",
922
- themes: ["light", "dark", "system"],
923
- attribute: "class",
924
- enableSystem: true,
925
- },
926
- }).routes(urlpatterns);
927
- ```
928
-
929
- ### Theme Toggle
930
-
931
- ```tsx
932
- "use client";
933
- import { useTheme } from "@rangojs/router/theme";
934
-
935
- function ThemeToggle() {
936
- const { theme, setTheme, themes } = useTheme();
937
- return (
938
- <select value={theme} onChange={(e) => setTheme(e.target.value)}>
939
- {themes.map((t) => (
940
- <option key={t}>{t}</option>
941
- ))}
942
- </select>
943
- );
944
- }
945
- ```
946
-
947
- ## Host Routing
948
-
949
- Route requests to different apps based on domain/subdomain patterns using `@rangojs/router/host`:
950
-
951
- ```tsx
952
- // worker.rsc.tsx
953
- import { createHostRouter } from "@rangojs/router/host";
954
-
955
- const hostRouter = createHostRouter();
956
-
957
- hostRouter.host(["*.localhost"]).lazy(() => import("./apps/admin/handler.js"));
958
- hostRouter.host(["localhost"]).lazy(() => import("./apps/site/handler.js"));
959
- hostRouter.fallback().lazy(() => import("./apps/site/handler.js"));
960
-
961
- export default {
962
- async fetch(request, env, ctx) {
963
- return hostRouter.match(request, { env, ctx });
964
- },
965
- };
966
- ```
967
-
968
- Use `.lazy(() => import("./sub-app"))` to mount a lazily-imported sub-app (a module whose `default` export is a handler or nested host router), and `.map((request) => Response)` for an inline request handler. Only `.lazy()` mounts are imported during build-time discovery; `.map(() => import(...))` is a type error. Each sub-app has its own `createRouter()` and `urls()`. Patterns are matched in registration order — register more specific patterns (subdomains) before catch-alls.
969
-
970
- ## Meta Tags
971
-
972
- Accumulate meta tags across route segments using the built-in `Meta` handle:
973
-
974
- ```tsx
975
- import { Meta } from "@rangojs/router";
976
- import type { HandlerContext } from "@rangojs/router";
977
-
978
- export function BlogPostPage(ctx: HandlerContext) {
979
- const meta = ctx.use(Meta);
980
- meta({ title: "My Blog Post" });
981
- meta({ name: "description", content: "A great blog post" });
982
- meta({ property: "og:title", content: "My Blog Post" });
983
-
984
- return <article>...</article>;
985
- }
986
- ```
987
-
988
- Render collected tags in the document with `<MetaTags />` from `@rangojs/router/client`.
989
-
990
- ## Scripts
991
-
992
- Inject `<script>` tags (analytics, GTM, widgets) the same way, using the built-in
993
- `Script` handle — push a config from a handler, render with `<Scripts />`:
994
-
995
- ```tsx
996
- import { Script } from "@rangojs/router";
997
- import type { HandlerContext } from "@rangojs/router";
998
- import { Outlet } from "@rangojs/router/client";
999
-
1000
- export function RootLayout(ctx: HandlerContext) {
1001
- // Inline bootstrap (GTM/GA4/Segment) — rendered with the request CSP nonce.
1002
- ctx.use(Script)({ id: "gtm", children: gtmBootstrap("GTM-XXXX") });
1003
- // External async resource (loads on first encounter, deduped by src).
1004
- ctx.use(Script)({
1005
- id: "plausible",
1006
- src: "https://plausible.io/js/script.js",
1007
- async: true,
1008
- attributes: { "data-domain": "example.com" },
1009
- });
1010
- return <Outlet />;
1011
- }
1012
- ```
1013
-
1014
- Render with `<Scripts />` (head) and `<Scripts position="body" />` (body) from
1015
- `@rangojs/router/client` (both are wired in `DefaultDocument`). The request CSP
1016
- nonce is applied automatically to document-rendered scripts. `ScriptConfig` is a
1017
- discriminated union (inline / external-async / external-ordered), and inline +
1018
- ordered scripts are document-load while async externals are React resources — see
1019
- the [`/scripts` skill](./skills/scripts/SKILL.md) for the full execution contract
1020
- and CSP guidance.
1021
-
1022
- ## CLI: `rango generate`
1023
-
1024
- Route types are generated automatically by the Vite plugin. The CLI is a manual fallback for generating types outside the dev server (e.g. in CI or for IDE support before first `pnpm dev`):
1025
-
1026
- ```bash
1027
- npx rango generate src/router.tsx
1028
- npx rango generate src/ # recursive scan
1029
- npx rango generate src/urls.tsx src/api/ # mix files and directories
1030
- ```
1031
-
1032
- Auto-detects file type:
1033
-
1034
- - Files with `createRouter` → `*.named-routes.gen.ts` with global route map
1035
- - Files with `urls()` → `*.gen.ts` with per-module route names, params, and search types
1036
-
1037
- ## Type Safety
1038
-
1039
- The Vite plugin automatically generates a `router.named-routes.gen.ts` file that globally registers route names, patterns, and search schemas via `Rango.GeneratedRouteMap`. This powers server-side named-route typing such as `Handler<"name">`, `ctx.reverse()`, `getRequestContext().reverse()`, and `RouteParams<"name">` without any manual route registration. The gen file is updated on dev server startup, HMR, and production builds.
1040
-
1041
- Use the generated map by default. Augment `Rango.RegisteredRoutes` only when you need the richer `typeof router.routeMap` shape globally, especially for response-aware and path-based utilities.
1042
-
1043
- ```typescript
1044
403
  // router.tsx
1045
404
  const router = createRouter<AppBindings>({}).routes(urlpatterns);
1046
405
 
1047
406
  declare global {
1048
407
  namespace Rango {
1049
408
  interface Env extends AppEnv {}
1050
- interface Vars extends AppVars {}
1051
409
  interface RegisteredRoutes extends typeof router.routeMap {}
1052
410
  }
1053
411
  }
1054
412
  ```
1055
413
 
1056
- Quick rule of thumb:
1057
-
1058
- - `GeneratedRouteMap` (auto-generated) — use for server-side named-route typing: `Handler<"name">`, `ctx.reverse()`, `Prerender<"name">`
1059
- - `typeof router.routeMap` — use when you need route entries with response metadata
1060
- - `RegisteredRoutes` (manual augmentation) — use to expose `typeof router.routeMap` globally for `href()`, `Rango.Path`, `Rango.PathResponse`, and other path/response-aware utilities
414
+ See the [`/typesafety` skill](./skills/typesafety/SKILL.md) for the full
415
+ surface breakdown.
1061
416
 
1062
- For extracted reusable loaders or middleware, prefer global dotted names on
1063
- `ctx.reverse()` by default. If you want type-safe local names for a specific
1064
- module, use `scopedReverse<typeof localPatterns>(ctx.reverse)` or
1065
- `scopedReverse<routes>(ctx.reverse)` with a generated local route type.
417
+ ### CLI
1066
418
 
1067
- ## Subpath Exports
419
+ Route types are generated by the Vite plugin; the CLI is the manual fallback
420
+ for CI or pre-first-run IDE support:
1068
421
 
1069
- | Export | Description |
1070
- | ------------------------ | -------------------------------------------------------------------------------------------------------- |
1071
- | `@rangojs/router` | Server/RSC core and shared types: `createRouter`, `urls`, `createLoader`, `Handler`, `Prerender`, `Meta` |
1072
- | `@rangojs/router/client` | Client: `Link`, `Outlet`, `href`, `useNavigation`, `useLoader`, `MetaTags` |
1073
- | `@rangojs/router/cache` | Cache: `CFCacheStore`, `MemorySegmentCacheStore`, `createDocumentCacheMiddleware` |
1074
- | `@rangojs/router/theme` | Theme: `useTheme`, `ThemeProvider`, `ThemeScript` |
1075
- | `@rangojs/router/host` | Host routing: `createHostRouter`, `defineHosts` |
1076
- | `@rangojs/router/vite` | Vite plugin: `rango()` |
1077
- | `@rangojs/router/rsc` | Advanced server pipeline APIs: `createRSCHandler`, request-context access |
1078
- | `@rangojs/router/ssr` | Advanced SSR bridge APIs: `createSSRHandler` |
1079
- | `@rangojs/router/server` | Internal build/runtime utilities for advanced integrations |
1080
- | `@rangojs/router/build` | Build utilities |
422
+ ```bash
423
+ npx rango generate src/router.tsx # global named-route map
424
+ npx rango generate src/ # recursive scan
425
+ ```
1081
426
 
1082
- The root entrypoint is not a generic client/runtime barrel. If you need hooks
1083
- or components, import from `@rangojs/router/client`; if you need cache or host
1084
- APIs, use their dedicated subpaths.
427
+ ### Examples
1085
428
 
1086
- ## Examples
429
+ - [`e2e/mini`](https://github.com/ivogt/vite-rsc/tree/main/packages/rangojs-router/e2e/mini) — single-file demo app
430
+ - [`cloudflare-basic`](https://github.com/ivogt/vite-rsc/tree/main/tests/cloudflare-basic) — Cloudflare Workers with caching, loaders, theme, and pre-rendering
431
+ - [`cloudflare-multi-router`](https://github.com/ivogt/vite-rsc/tree/main/examples/cloudflare-multi-router) — multi-app host routing
432
+ - [`vercel-basic`](https://github.com/ivogt/vite-rsc/tree/main/examples/vercel-basic) — Vercel deployment with `preset: "vercel"`, `VercelCacheStore`, and OTel tracing
433
+ - [`vercel-multi-router`](https://github.com/ivogt/vite-rsc/tree/main/examples/vercel-multi-router) — multi-app host routing on Vercel (single function, routed by Host header)
1087
434
 
1088
- See the example and demo apps for full working applications:
435
+ ### Going deeper
1089
436
 
1090
- - [`cloudflare-basic`](../../tests/cloudflare-basic) — Cloudflare Workers with caching, loaders, theme, and pre-rendering
1091
- - [`cloudflare-multi-router`](../../examples/cloudflare-multi-router) — Multi-app host routing
437
+ - [Why Rango](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/why-rango.md) — the design rationale, claim by claim
438
+ - [Framework comparison](./skills/comparison/references/framework-comparison.md) — Rango vs Next.js App Router, TanStack Start, and Waku, capability by capability
439
+ - [Docs index](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/README.md) — architecture, caching, prerender, testing
440
+ - [Execution model](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/internal/execution-model.md) — the runtime contract
1092
441
 
1093
442
  ## License
1094
443