@rangojs/router 0.24.3 → 0.25.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.
Files changed (274) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/rango.js +363 -39
  3. package/dist/testing/vitest.js +37 -4
  4. package/dist/types/browser/event-controller.d.ts +24 -5
  5. package/dist/types/browser/intercept-utils.d.ts +5 -2
  6. package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
  7. package/dist/types/browser/navigation-transaction.d.ts +5 -5
  8. package/dist/types/browser/partial-update.d.ts +21 -13
  9. package/dist/types/browser/prefetch/cache.d.ts +8 -0
  10. package/dist/types/browser/rango-marker.d.ts +7 -0
  11. package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
  12. package/dist/types/browser/react/use-handle.d.ts +20 -0
  13. package/dist/types/browser/react/use-href.d.ts +6 -0
  14. package/dist/types/browser/react/use-router.d.ts +6 -1
  15. package/dist/types/browser/response-adapter.d.ts +5 -3
  16. package/dist/types/browser/scroll-restoration.d.ts +9 -3
  17. package/dist/types/browser/segment-reconciler.d.ts +10 -13
  18. package/dist/types/browser/types.d.ts +38 -8
  19. package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
  20. package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
  21. package/dist/types/cache/types.d.ts +8 -0
  22. package/dist/types/client-urls/client-root.d.ts +4 -1
  23. package/dist/types/client-urls/client-urls.d.ts +28 -2
  24. package/dist/types/client-urls/navigation.d.ts +6 -8
  25. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  26. package/dist/types/client-urls/server-projection.d.ts +20 -5
  27. package/dist/types/client-urls/types.d.ts +35 -17
  28. package/dist/types/client.d.ts +2 -2
  29. package/dist/types/client.rsc.d.ts +2 -2
  30. package/dist/types/decode-loader-results.d.ts +28 -7
  31. package/dist/types/href-client.d.ts +3 -1
  32. package/dist/types/index.rsc.d.ts +1 -1
  33. package/dist/types/loader-redirect.d.ts +2 -3
  34. package/dist/types/outlet-context.d.ts +7 -6
  35. package/dist/types/prefetch-markers.d.ts +14 -5
  36. package/dist/types/route-content-wrapper.d.ts +17 -14
  37. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  38. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  39. package/dist/types/route-definition/rewrite.d.ts +48 -5
  40. package/dist/types/route-types.d.ts +2 -2
  41. package/dist/types/router/handler-context.d.ts +14 -2
  42. package/dist/types/router/intercept-resolution.d.ts +19 -46
  43. package/dist/types/router/lazy-includes.d.ts +7 -0
  44. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  45. package/dist/types/router/middleware.d.ts +0 -16
  46. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  47. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  48. package/dist/types/router/revalidation.d.ts +8 -1
  49. package/dist/types/router/router-context.d.ts +4 -16
  50. package/dist/types/router/router-interfaces.d.ts +0 -6
  51. package/dist/types/router/router-options.d.ts +4 -7
  52. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  53. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  54. package/dist/types/router/segment-resolution.d.ts +1 -1
  55. package/dist/types/router/segment-wrappers.d.ts +3 -5
  56. package/dist/types/router/substitute-pattern-params.d.ts +19 -2
  57. package/dist/types/router/tracing.d.ts +2 -2
  58. package/dist/types/rsc/full-payload.d.ts +14 -2
  59. package/dist/types/rsc/helpers.d.ts +8 -0
  60. package/dist/types/rsc/manifest-init.d.ts +20 -0
  61. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  62. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  63. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  64. package/dist/types/rsc/server-action.d.ts +6 -0
  65. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  66. package/dist/types/rsc/shell-capture.d.ts +7 -0
  67. package/dist/types/rsc/types.d.ts +5 -0
  68. package/dist/types/segment-system.d.ts +15 -0
  69. package/dist/types/server/context.d.ts +64 -4
  70. package/dist/types/server/request-context.d.ts +41 -4
  71. package/dist/types/server/request-headers.d.ts +12 -0
  72. package/dist/types/server/rewrite-trail.d.ts +8 -1
  73. package/dist/types/testing/e2e/index.d.ts +1 -1
  74. package/dist/types/testing/flight.entry.d.ts +1 -1
  75. package/dist/types/testing/index.d.ts +1 -1
  76. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  77. package/dist/types/testing/render-route.d.ts +21 -11
  78. package/dist/types/testing/run-loader.d.ts +7 -0
  79. package/dist/types/testing/run-middleware.d.ts +7 -0
  80. package/dist/types/testing/run-revalidate.d.ts +5 -4
  81. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  82. package/dist/types/testing/shell-status.d.ts +17 -0
  83. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  84. package/dist/types/testing/vitest.d.ts +10 -1
  85. package/dist/types/thenable-status.d.ts +0 -6
  86. package/dist/types/types/handler-context.d.ts +12 -1
  87. package/dist/types/types/loader-types.d.ts +4 -3
  88. package/dist/types/types/route-entry.d.ts +2 -1
  89. package/dist/types/urls/path-helper-types.d.ts +4 -2
  90. package/dist/types/urls/path-helper.d.ts +5 -0
  91. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  92. package/dist/types/vite/discovery/state.d.ts +20 -0
  93. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  94. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  95. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  96. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  97. package/dist/vite/index.js +6816 -6547
  98. package/package.json +1 -1
  99. package/skills/app-structure/SKILL.md +255 -0
  100. package/skills/app-structure/content-site.md +333 -0
  101. package/skills/app-structure/e-commerce.md +435 -0
  102. package/skills/app-structure/large-app.md +885 -0
  103. package/skills/app-structure/multi-app.md +280 -0
  104. package/skills/breadcrumbs/SKILL.md +6 -1
  105. package/skills/caching/SKILL.md +3 -1
  106. package/skills/catalog.json +12 -0
  107. package/skills/client-urls/SKILL.md +195 -32
  108. package/skills/composability/SKILL.md +10 -8
  109. package/skills/handler-use/SKILL.md +48 -9
  110. package/skills/hooks/handle-and-actions.md +104 -0
  111. package/skills/hooks/navigation.md +24 -10
  112. package/skills/hooks/urls.md +1 -1
  113. package/skills/i18n/SKILL.md +2 -1
  114. package/skills/intercept/SKILL.md +60 -29
  115. package/skills/layout/SKILL.md +6 -3
  116. package/skills/links/SKILL.md +151 -17
  117. package/skills/loader/SKILL.md +32 -10
  118. package/skills/middleware/SKILL.md +111 -15
  119. package/skills/observability/SKILL.md +11 -0
  120. package/skills/parallel/SKILL.md +5 -5
  121. package/skills/ppr/SKILL.md +43 -8
  122. package/skills/prerender/SKILL.md +1 -2
  123. package/skills/rango/SKILL.md +3 -0
  124. package/skills/response-routes/SKILL.md +4 -1
  125. package/skills/scripts/SKILL.md +12 -1
  126. package/skills/server-actions/SKILL.md +60 -20
  127. package/skills/testing/SKILL.md +1 -0
  128. package/skills/testing/client-components.md +13 -10
  129. package/skills/testing/loader.md +25 -24
  130. package/skills/testing/middleware.md +11 -11
  131. package/skills/testing/response-routes.md +4 -1
  132. package/skills/testing/serve-shell-request.md +85 -21
  133. package/skills/ux-architecture/SKILL.md +980 -0
  134. package/skills/view-transitions/SKILL.md +43 -10
  135. package/src/__augment-tests__/augmented.check.ts +28 -0
  136. package/src/browser/event-controller.ts +44 -18
  137. package/src/browser/intercept-utils.ts +3 -2
  138. package/src/browser/merge-segment-loaders.ts +0 -85
  139. package/src/browser/navigation-bridge.ts +119 -98
  140. package/src/browser/navigation-client.ts +20 -15
  141. package/src/browser/navigation-store.ts +4 -2
  142. package/src/browser/navigation-transaction.ts +9 -8
  143. package/src/browser/partial-update.ts +90 -32
  144. package/src/browser/prefetch/cache.ts +8 -0
  145. package/src/browser/prefetch/fetch.ts +13 -3
  146. package/src/browser/rango-marker.ts +13 -0
  147. package/src/browser/react/NavigationProvider.tsx +25 -7
  148. package/src/browser/react/filter-segment-order.ts +13 -0
  149. package/src/browser/react/use-action.ts +9 -15
  150. package/src/browser/react/use-handle.ts +63 -71
  151. package/src/browser/react/use-href.tsx +24 -5
  152. package/src/browser/react/use-link-status.ts +1 -1
  153. package/src/browser/react/use-navigation.ts +30 -40
  154. package/src/browser/react/use-reverse.ts +4 -17
  155. package/src/browser/react/use-router.ts +37 -6
  156. package/src/browser/react/use-segments.ts +8 -12
  157. package/src/browser/response-adapter.ts +11 -5
  158. package/src/browser/rsc-router.tsx +3 -1
  159. package/src/browser/scroll-restoration.ts +34 -20
  160. package/src/browser/segment-reconciler.ts +11 -29
  161. package/src/browser/server-action-bridge.ts +52 -16
  162. package/src/browser/types.ts +40 -8
  163. package/src/build/generate-manifest.ts +12 -1
  164. package/src/build/route-types/codegen.ts +6 -2
  165. package/src/build/route-types/per-module-writer.ts +94 -61
  166. package/src/cache/cache-runtime.ts +9 -0
  167. package/src/cache/cache-scope.ts +2 -1
  168. package/src/cache/cf/cf-cache-store.ts +2 -0
  169. package/src/cache/cf/cf-shell-frame.ts +3 -0
  170. package/src/cache/types.ts +8 -0
  171. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  172. package/src/client-urls/client-root.tsx +10 -5
  173. package/src/client-urls/client-urls.ts +246 -166
  174. package/src/client-urls/navigation.ts +45 -38
  175. package/src/client-urls/revalidate-registry.ts +12 -3
  176. package/src/client-urls/server-projection.ts +286 -75
  177. package/src/client-urls/types.ts +40 -16
  178. package/src/client.rsc.tsx +11 -1
  179. package/src/client.tsx +9 -1
  180. package/src/decode-loader-results.ts +64 -31
  181. package/src/href-client.ts +3 -1
  182. package/src/index.rsc.ts +1 -0
  183. package/src/index.ts +12 -4
  184. package/src/loader-redirect.tsx +2 -3
  185. package/src/outlet-context.ts +7 -6
  186. package/src/prefetch-markers.ts +15 -5
  187. package/src/reverse.ts +3 -3
  188. package/src/route-content-wrapper.tsx +19 -17
  189. package/src/route-definition/dsl-helpers.ts +27 -7
  190. package/src/route-definition/helpers-types.ts +7 -2
  191. package/src/route-definition/resolve-handler-use.ts +23 -10
  192. package/src/route-definition/rewrite.ts +86 -19
  193. package/src/route-types.ts +1 -2
  194. package/src/router/find-match.ts +37 -1
  195. package/src/router/handler-context.ts +85 -40
  196. package/src/router/intercept-resolution.ts +161 -307
  197. package/src/router/lazy-includes.ts +43 -27
  198. package/src/router/loader-resolution.ts +21 -1
  199. package/src/router/manifest.ts +6 -8
  200. package/src/router/match-api.ts +11 -1
  201. package/src/router/match-middleware/background-revalidation.ts +2 -4
  202. package/src/router/match-middleware/cache-lookup.ts +16 -20
  203. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  204. package/src/router/match-result.ts +31 -2
  205. package/src/router/middleware.ts +6 -147
  206. package/src/router/navigation-snapshot.ts +38 -2
  207. package/src/router/prefetch-cache-ttl.ts +10 -18
  208. package/src/router/prerender-match.ts +2 -1
  209. package/src/router/revalidation.ts +24 -3
  210. package/src/router/router-context.ts +5 -18
  211. package/src/router/router-interfaces.ts +0 -7
  212. package/src/router/router-options.ts +4 -7
  213. package/src/router/segment-resolution/fresh.ts +6 -2
  214. package/src/router/segment-resolution/helpers.ts +7 -1
  215. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  216. package/src/router/segment-resolution/revalidation.ts +75 -35
  217. package/src/router/segment-resolution.ts +0 -1
  218. package/src/router/segment-wrappers.ts +8 -13
  219. package/src/router/substitute-pattern-params.ts +37 -2
  220. package/src/router/tracing.ts +2 -2
  221. package/src/router.ts +2 -9
  222. package/src/rsc/full-payload.ts +26 -4
  223. package/src/rsc/handler.ts +83 -41
  224. package/src/rsc/helpers.ts +13 -0
  225. package/src/rsc/manifest-init.ts +56 -3
  226. package/src/rsc/prefetch-render-error.ts +38 -0
  227. package/src/rsc/progressive-enhancement.ts +161 -211
  228. package/src/rsc/rewrite-passes.ts +56 -9
  229. package/src/rsc/rsc-rendering.ts +79 -23
  230. package/src/rsc/server-action.ts +25 -2
  231. package/src/rsc/shell-capture-constants.ts +22 -0
  232. package/src/rsc/shell-capture.ts +11 -0
  233. package/src/rsc/types.ts +5 -0
  234. package/src/segment-system.tsx +125 -119
  235. package/src/server/context.ts +96 -5
  236. package/src/server/handle-store.ts +3 -2
  237. package/src/server/request-context.ts +42 -3
  238. package/src/server/request-headers.ts +32 -0
  239. package/src/server/rewrite-trail.ts +55 -9
  240. package/src/ssr/ssr-root.tsx +6 -10
  241. package/src/testing/dispatch.ts +4 -5
  242. package/src/testing/e2e/index.ts +1 -0
  243. package/src/testing/flight.entry.ts +1 -0
  244. package/src/testing/index.ts +1 -0
  245. package/src/testing/internal/client-urls-projection.ts +31 -11
  246. package/src/testing/render-route.tsx +83 -40
  247. package/src/testing/run-loader.ts +26 -2
  248. package/src/testing/run-middleware.ts +10 -0
  249. package/src/testing/run-revalidate.ts +13 -6
  250. package/src/testing/serve-shell-request.ts +77 -4
  251. package/src/testing/shell-status.ts +40 -0
  252. package/src/testing/vitest-stubs/version.ts +1 -0
  253. package/src/testing/vitest.ts +74 -7
  254. package/src/thenable-status.ts +0 -11
  255. package/src/types/handler-context.ts +12 -1
  256. package/src/types/loader-types.ts +4 -3
  257. package/src/types/route-entry.ts +2 -1
  258. package/src/urls/include-helper.ts +38 -11
  259. package/src/urls/path-helper-types.ts +4 -2
  260. package/src/urls/path-helper.ts +16 -3
  261. package/src/vite/discovery/client-urls-projection.ts +205 -16
  262. package/src/vite/discovery/discover-routers.ts +2 -2
  263. package/src/vite/discovery/prerender-collection.ts +2 -6
  264. package/src/vite/discovery/state.ts +26 -0
  265. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  266. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  267. package/src/vite/plugins/hoist/source.ts +8 -7
  268. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  269. package/src/vite/plugins/version-plugin.ts +7 -0
  270. package/src/vite/plugins/version.d.ts +7 -0
  271. package/src/vite/plugins/virtual-entries.ts +20 -0
  272. package/src/vite/rango.ts +4 -1
  273. package/src/vite/router-discovery.ts +39 -7
  274. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -0,0 +1,885 @@
1
+ # A large app with a protected admin area
2
+
3
+ The shape for a signed-in product: a dashboard or SaaS app with public
4
+ marketing pages, sign-in, an onboarding flow, the app itself, settings, an
5
+ admin area for staff, and a JSON API.
6
+
7
+ - **A server (RSC) shell** renders the chrome of every signed-in page: the
8
+ top nav and the user menu. It renders on the document load and is kept on
9
+ every navigation inside the areas below it.
10
+ - **User and app data come from loaders**, read with `useLoader()` where they
11
+ are shown. Request-scoped values (the session user) come from middleware
12
+ `ctx.set()`.
13
+ - **Each large area is its own `clientUrls()` group**: `auth`, `onboarding`,
14
+ `app`, `settings`, `admin`, each mounted under the shell with a sync
15
+ `include()`.
16
+ - **Middleware gates each area**: the session is resolved globally, a
17
+ sign-in guard wraps every protected include (`auth` sits outside it), an
18
+ onboarding gate wraps the app and settings, and a role guard wraps admin.
19
+ - **Links prefetch**, and `prefetch: false` keeps the expensive loaders out
20
+ of the prefetches that rarely become clicks.
21
+
22
+ ## How navigations behave in this shape
23
+
24
+ - **Inside a group** (`/app` to `/app/projects/42`): the group's definition
25
+ matches in the browser, so the destination renders at the click, its loader
26
+ reads suspend behind their own `<Suspense>`, and the group layout's
27
+ `useOutlet().pending` turns on. Loaders whose `revalidate()` says hold keep
28
+ their data. The canonical request still goes to the server.
29
+ - **Between groups** (`/app/projects` to `/settings`): a normal server
30
+ navigation. Only the mounted group matches locally
31
+ (`src/client-urls/navigation.ts`), so there is no optimistic frame; the
32
+ shell is kept (its segment and its loader are not re-rendered) and the
33
+ destination group's layout mounts.
34
+ - **Every navigation sends a request, and route middleware wraps each one.**
35
+ A user whose session ended is redirected on the next click, inside a group
36
+ or not; the instant feel comes from held data and the optimistic frame, not
37
+ from skipping the server.
38
+
39
+ Where the loaders, `<Suspense>` boundaries and `revalidate()` rules go so
40
+ that these navigations show the next page early and never flash a fallback
41
+ is the `ux-architecture` skill's subject; this file places the routes, the
42
+ middleware and the data around them.
43
+
44
+ ## File tree
45
+
46
+ ```
47
+ src/
48
+ router.tsx createRouter(), global middleware
49
+ router.named-routes.gen.ts generated (Vite plugin / rango generate), committed
50
+ document.tsx "use client" Document
51
+ urls.tsx root: marketing, auth, the shell and its groups, api
52
+ middleware/
53
+ session.ts loadSession (global): CurrentUser from the cookie
54
+ guards.ts requireUser, requireOnboarded, requireAdmin, requireUserForFetch
55
+ maintenance.ts optional: throw rewrite("maintenance"), a global router.use()
56
+ same-origin.ts requireSameOrigin for /api (see /response-routes, CSRF)
57
+ shell/
58
+ app-shell.tsx server layout: nav, user menu slot, <Outlet />
59
+ user-menu.tsx "use client": useLoader(ViewerLoader)
60
+ boundaries.tsx AppError, AppNotFound
61
+ loaders/ viewer.ts, projects.ts, …
62
+ actions/ "use server": auth.ts, profile.ts, projects.ts, admin.ts, …
63
+ urls/
64
+ marketing.tsx urls(): public pages, ppr
65
+ auth.client.tsx clientUrls(): login, signup
66
+ onboarding.client.tsx clientUrls(): the onboarding steps
67
+ app.client.tsx clientUrls(): dashboard, projects
68
+ app.predicates.ts revalidate() predicates the app group shares
69
+ settings.client.tsx clientUrls(): profile, billing
70
+ admin.client.tsx clientUrls(): users
71
+ api.tsx urls(): path.json() routes
72
+ *.gen.ts generated per module by rango generate, committed
73
+ pages/ server pages (marketing, maintenance)
74
+ ```
75
+
76
+ ## router.tsx
77
+
78
+ ```tsx
79
+ // src/router.tsx
80
+ import { createRouter } from "@rangojs/router";
81
+ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
82
+ import { Document } from "./document";
83
+ import { urlpatterns } from "./urls";
84
+ import { loadSession } from "./middleware/session";
85
+ import { maintenance } from "./middleware/maintenance";
86
+ import { requireSameOrigin } from "./middleware/same-origin";
87
+
88
+ export const router = createRouter({
89
+ document: Document,
90
+ // The store behind ppr shells, cache() and "use cache". One process here;
91
+ // CFCacheStore on Workers, VercelCacheStore on Vercel.
92
+ cache: { store: new MemorySegmentCacheStore() },
93
+ })
94
+ .use(maintenance) // optional, runs first
95
+ .use(loadSession) // every request: renders, actions, fetchable loaders
96
+ .use("/api/*", requireSameOrigin)
97
+ .routes(urlpatterns);
98
+ ```
99
+
100
+ `defaultPrefetch` is left at its default: `"none"` in development,
101
+ `"viewport"` in production, so every visible `<Link>` prefetches its route.
102
+ On pages with long lists of links, pass `prefetch="hover"` on those links, or
103
+ set the router default to `"hover"` or `"adaptive"` (`/links`).
104
+
105
+ ## The middleware
106
+
107
+ ```ts
108
+ // src/middleware/session.ts
109
+ import { cookies, createVar } from "@rangojs/router";
110
+ import type { Middleware } from "@rangojs/router";
111
+ import { sessions, type User } from "../lib/db";
112
+
113
+ // cache: false: a cache() boundary or "use cache" function that reads it
114
+ // throws instead of storing one user's data for everyone.
115
+ export const CurrentUser = createVar<User>({ cache: false });
116
+
117
+ // Global: resolves identity on every request. It does not gate.
118
+ export const loadSession: Middleware = async (ctx, next) => {
119
+ const token = cookies().get("session")?.value;
120
+ const user = token ? await sessions.verify(token) : null;
121
+ if (user) ctx.set(CurrentUser, user);
122
+ return next();
123
+ };
124
+ ```
125
+
126
+ ```ts
127
+ // src/middleware/guards.ts
128
+ import { redirect } from "@rangojs/router";
129
+ import type { Middleware } from "@rangojs/router";
130
+ import { CurrentUser } from "./session";
131
+
132
+ // Route middleware: wraps every render of the routes below it, document loads
133
+ // and client navigations. It does not wrap server actions or fetchable-loader
134
+ // calls.
135
+ export const requireUser: Middleware = async (ctx, next) => {
136
+ if (!ctx.get(CurrentUser)) {
137
+ return redirect(ctx.reverse("auth.login", {}, { next: ctx.pathname }));
138
+ }
139
+ return next();
140
+ };
141
+
142
+ export const requireOnboarded: Middleware = async (ctx, next) => {
143
+ if (!ctx.get(CurrentUser)?.onboarded) {
144
+ return redirect(ctx.reverse("onboarding.start"));
145
+ }
146
+ return next();
147
+ };
148
+
149
+ export const requireAdmin: Middleware = async (ctx, next) => {
150
+ if (ctx.get(CurrentUser)?.role !== "admin") {
151
+ return redirect(ctx.reverse("app.dashboard"));
152
+ }
153
+ return next();
154
+ };
155
+
156
+ // Per-loader middleware for fetchable loaders. A redirect on that lane would
157
+ // hand the login page's HTML to load(); answer 401 instead.
158
+ export const requireUserForFetch: Middleware = async (ctx, next) => {
159
+ if (!ctx.get(CurrentUser)) {
160
+ return Response.json({ error: "unauthorized" }, { status: 401 });
161
+ }
162
+ return next();
163
+ };
164
+ ```
165
+
166
+ `ctx.reverse()` in middleware takes global names only and fills no params
167
+ from the request, so the guards name full routes (`auth.login`) and pass the
168
+ search object the login route's schema declares. (`getRequestContext().reverse`
169
+ in route middleware does fill mount params after the match.)
170
+
171
+ ```ts
172
+ // src/middleware/maintenance.ts (optional)
173
+ import { rewrite } from "@rangojs/router";
174
+ import type { Middleware } from "@rangojs/router";
175
+ import { flags } from "../lib/flags";
176
+
177
+ export const maintenance: Middleware = async (ctx, next) => {
178
+ if (!flags.maintenance || ctx.pathname === "/maintenance") return next();
179
+ // A rewrite moves a server action or form post to its target, where the
180
+ // action runs: a lock answers mutations itself (HEAD rewrites like GET).
181
+ if (ctx.request.method !== "GET" && ctx.request.method !== "HEAD") {
182
+ return new Response("Maintenance", { status: 503 });
183
+ }
184
+ throw rewrite("maintenance"); // renders /maintenance at the visitor's URL
185
+ };
186
+ ```
187
+
188
+ ## urls.tsx
189
+
190
+ ```tsx
191
+ // src/urls.tsx
192
+ import { urls } from "@rangojs/router";
193
+ import { marketingPatterns } from "./urls/marketing";
194
+ import authUrls from "./urls/auth.client";
195
+ import onboardingUrls from "./urls/onboarding.client";
196
+ import appUrls from "./urls/app.client";
197
+ import settingsUrls from "./urls/settings.client";
198
+ import adminUrls from "./urls/admin.client";
199
+ import { apiPatterns } from "./urls/api";
200
+ import { AppShell } from "./shell/app-shell";
201
+ import { AppError, AppNotFound } from "./shell/boundaries";
202
+ import { ViewerLoader } from "./loaders/viewer";
203
+ import { MaintenancePage } from "./pages/marketing";
204
+ import { updateProfile } from "./actions/profile";
205
+ import {
206
+ requireAdmin,
207
+ requireOnboarded,
208
+ requireUser,
209
+ } from "./middleware/guards";
210
+
211
+ export const urlpatterns = urls(
212
+ ({
213
+ path,
214
+ layout,
215
+ include,
216
+ middleware,
217
+ loader,
218
+ revalidate,
219
+ errorBoundary,
220
+ notFoundBoundary,
221
+ }) => [
222
+ // Public server routes with ppr shells.
223
+ include("/", marketingPatterns, { name: "marketing" }),
224
+
225
+ // Outside the guard: requireUser redirects here.
226
+ include("/auth", authUrls, { name: "auth" }),
227
+
228
+ // Every signed-in page shares one server shell.
229
+ layout(AppShell, () => [
230
+ middleware(requireUser),
231
+ loader(ViewerLoader, () => [
232
+ // A loader above the route re-runs after every action by default;
233
+ // only a profile change touches this one. Navigations hold it.
234
+ revalidate((ctx) =>
235
+ ctx.isAction() ? ctx.isAction(updateProfile) : undefined,
236
+ ),
237
+ ]),
238
+ errorBoundary(AppError),
239
+ notFoundBoundary(AppNotFound),
240
+
241
+ include("/onboarding", onboardingUrls, { name: "onboarding" }),
242
+ middleware(requireOnboarded, () => [
243
+ include("/app", appUrls, { name: "app" }),
244
+ include("/settings", settingsUrls, { name: "settings" }),
245
+ ]),
246
+ middleware(requireAdmin, () => [
247
+ include("/admin", adminUrls, { name: "admin" }),
248
+ ]),
249
+ ]),
250
+
251
+ include("/api", apiPatterns, { name: "api" }),
252
+ path("/maintenance", MaintenancePage, { name: "maintenance" }),
253
+ ],
254
+ );
255
+ ```
256
+
257
+ - `middleware(guard, () => [include(...)])` scopes the guard to the routes
258
+ of those includes: it is a transparent layout that carries the middleware.
259
+ - Route middleware runs outer to inner: a signed-out request for `/settings`
260
+ meets `requireUser` (redirect to `/auth/login?next=%2Fsettings`) before
261
+ `requireOnboarded`.
262
+ - `onboarding` sits inside `requireUser` but outside `requireOnboarded`, or
263
+ the gate would redirect to itself. A middleware around the `auth` include
264
+ can send signed-in users on to `/app` the same way.
265
+
266
+ ## The shell
267
+
268
+ ```tsx
269
+ // src/shell/app-shell.tsx: a server layout
270
+ import { Suspense } from "react";
271
+ import type { HandlerContext } from "@rangojs/router";
272
+ import { Link, Outlet } from "@rangojs/router/client";
273
+ import { UserMenu } from "./user-menu";
274
+
275
+ export function AppShell(ctx: HandlerContext) {
276
+ return (
277
+ <div className="app">
278
+ <header>
279
+ <nav>
280
+ <Link to={ctx.reverse("app.dashboard")}>Dashboard</Link>
281
+ <Link to={ctx.reverse("app.projects")}>Projects</Link>
282
+ <Link to={ctx.reverse("settings.profile")}>Settings</Link>
283
+ </nav>
284
+ <Suspense fallback={<span className="avatar-skeleton" />}>
285
+ <UserMenu adminHref={ctx.reverse("admin.users")} />
286
+ </Suspense>
287
+ </header>
288
+ <main>
289
+ <Outlet />
290
+ </main>
291
+ </div>
292
+ );
293
+ }
294
+ ```
295
+
296
+ ```tsx
297
+ // src/shell/user-menu.tsx
298
+ "use client";
299
+
300
+ import { Link, useLoader } from "@rangojs/router/client";
301
+ import { ViewerLoader } from "../loaders/viewer";
302
+ import { logout } from "../actions/auth";
303
+
304
+ export function UserMenu({ adminHref }: { adminHref: string }) {
305
+ const { data: viewer } = useLoader(ViewerLoader);
306
+ return (
307
+ <div className="user-menu">
308
+ <span>{viewer.name}</span>
309
+ {viewer.role === "admin" ? <Link to={adminHref}>Admin</Link> : null}
310
+ <form action={logout}>
311
+ <button type="submit">Sign out</button>
312
+ </form>
313
+ </div>
314
+ );
315
+ }
316
+ ```
317
+
318
+ ```ts
319
+ // src/loaders/viewer.ts
320
+ import { createLoader } from "@rangojs/router";
321
+ import { CurrentUser } from "../middleware/session";
322
+ import { requireUserForFetch } from "../middleware/guards";
323
+
324
+ // An options object makes the loader fetchable (refetch() works on it); the
325
+ // middleware guards that fetch lane.
326
+ export const ViewerLoader = createLoader(
327
+ async (ctx) => {
328
+ const user = ctx.get(CurrentUser)!;
329
+ return { name: user.name, email: user.email, role: user.role };
330
+ },
331
+ { middleware: [requireUserForFetch] },
332
+ );
333
+ ```
334
+
335
+ The shell renders no user data itself: the user menu reads the loader under
336
+ its own `<Suspense>`, so the nav paints before the loader answers. Any
337
+ component below the shell can read `useLoader(ViewerLoader)` too (the
338
+ settings form below does); the read walks up to the layout that registered
339
+ it (`src/use-loader.tsx`).
340
+
341
+ ## The groups
342
+
343
+ Each group is a `"use client"` module whose default export is a
344
+ `clientUrls()` definition: named function components, loaders from
345
+ `createLoader()`, no handlers.
346
+
347
+ ```tsx
348
+ // src/urls/auth.client.tsx
349
+ "use client";
350
+
351
+ import { useActionState } from "react";
352
+ import { clientUrls, useOutlet, useSearchParams } from "@rangojs/router/client";
353
+ import { login } from "../actions/auth";
354
+
355
+ function AuthLayout() {
356
+ const { content } = useOutlet();
357
+ return <div className="auth-card">{content}</div>;
358
+ }
359
+
360
+ function LoginPage() {
361
+ const [state, formAction, pending] = useActionState(login, null);
362
+ const [search] = useSearchParams();
363
+ return (
364
+ <form action={formAction}>
365
+ <input type="hidden" name="next" value={search.get("next") ?? ""} />
366
+ <input name="email" type="email" />
367
+ <input name="password" type="password" />
368
+ {state?.error ? <p role="alert">{state.error}</p> : null}
369
+ <button disabled={pending}>Sign in</button>
370
+ </form>
371
+ );
372
+ }
373
+
374
+ function SignupPage() {
375
+ return <form>{/* … */}</form>;
376
+ }
377
+
378
+ export default clientUrls(({ path, layout }) => [
379
+ layout(AuthLayout, () => [
380
+ path("/login", LoginPage, { name: "login", search: { next: "string?" } }),
381
+ path("/signup", SignupPage, { name: "signup" }),
382
+ ]),
383
+ ]);
384
+ ```
385
+
386
+ ```tsx
387
+ // src/urls/app.client.tsx
388
+ "use client";
389
+
390
+ import { Suspense, useActionState } from "react";
391
+ import {
392
+ clientUrls,
393
+ Link,
394
+ useLoader,
395
+ useOutlet,
396
+ useReverse,
397
+ } from "@rangojs/router/client";
398
+ import {
399
+ ActivityLoader,
400
+ ProjectLoader,
401
+ ProjectsLoader,
402
+ StatsLoader,
403
+ } from "../loaders/projects";
404
+ import { renameProject } from "../actions/projects";
405
+ import { projectData } from "./app.predicates";
406
+ import { routes } from "./app.client.gen";
407
+
408
+ function AppLayout() {
409
+ const { content, pending } = useOutlet();
410
+ const reverse = useReverse(routes); // mount-aware: "/app/projects"
411
+ return (
412
+ <div aria-busy={pending}>
413
+ <nav>
414
+ <Link to={reverse("dashboard")}>Overview</Link>
415
+ <Link to={reverse("projects")}>Projects</Link>
416
+ </nav>
417
+ {content}
418
+ </div>
419
+ );
420
+ }
421
+
422
+ function Stats() {
423
+ const { data } = useLoader(StatsLoader);
424
+ return <p>{data.projects} projects</p>;
425
+ }
426
+ function Dashboard() {
427
+ return (
428
+ <Suspense fallback={<p>…</p>}>
429
+ <Stats />
430
+ </Suspense>
431
+ );
432
+ }
433
+
434
+ function ProjectRows() {
435
+ const { data: projects } = useLoader(ProjectsLoader);
436
+ const reverse = useReverse(routes);
437
+ return (
438
+ <ul>
439
+ {projects.map((p) => (
440
+ <li key={p.id}>
441
+ <Link to={reverse("project", { projectId: p.id })}>{p.name}</Link>
442
+ </li>
443
+ ))}
444
+ </ul>
445
+ );
446
+ }
447
+ function ProjectList() {
448
+ return (
449
+ <Suspense fallback={<p>Loading projects…</p>}>
450
+ <ProjectRows />
451
+ </Suspense>
452
+ );
453
+ }
454
+
455
+ function ProjectHeader() {
456
+ const { data: project } = useLoader(ProjectLoader);
457
+ const [, formAction, pending] = useActionState(renameProject, null);
458
+ return (
459
+ <form action={formAction}>
460
+ <input type="hidden" name="projectId" value={project.id} />
461
+ <input name="name" defaultValue={project.name} />
462
+ <button disabled={pending}>Rename</button>
463
+ </form>
464
+ );
465
+ }
466
+
467
+ function Activity() {
468
+ const { data: items } = useLoader(ActivityLoader);
469
+ return (
470
+ <ul>
471
+ {items.map((i) => (
472
+ <li key={i.id}>{i.text}</li>
473
+ ))}
474
+ </ul>
475
+ );
476
+ }
477
+
478
+ function ProjectPage() {
479
+ return (
480
+ <>
481
+ <Suspense fallback={<h1>…</h1>}>
482
+ <ProjectHeader />
483
+ </Suspense>
484
+ <Suspense fallback={<p>Loading activity…</p>}>
485
+ <Activity />
486
+ </Suspense>
487
+ </>
488
+ );
489
+ }
490
+
491
+ export default clientUrls(({ path, layout, loader, revalidate }) => [
492
+ layout(AppLayout, () => [
493
+ path("/", Dashboard, { name: "dashboard" }, () => [loader(StatsLoader)]),
494
+ path(
495
+ "/projects",
496
+ ProjectList,
497
+ { name: "projects", search: { q: "string?" } },
498
+ () => [loader(ProjectsLoader)],
499
+ ),
500
+ path("/projects/:projectId", ProjectPage, { name: "project" }, () => [
501
+ loader(ProjectLoader, () => [revalidate(projectData)]),
502
+ // Below the fold and costly: kept out of link prefetches.
503
+ loader(ActivityLoader, { prefetch: false }, () => [
504
+ revalidate(projectData),
505
+ ]),
506
+ ]),
507
+ ]),
508
+ ]);
509
+ ```
510
+
511
+ ```ts
512
+ // src/urls/app.predicates.ts: no "use client"
513
+ import type { RevalidateParams } from "@rangojs/router";
514
+ import { renameProject } from "../actions/projects";
515
+
516
+ // After an action: re-run only for a rename of THIS project.
517
+ // On a navigation: re-run when the project changes.
518
+ export function projectData(ctx: RevalidateParams): boolean | undefined {
519
+ if (ctx.isAction()) {
520
+ return (
521
+ ctx.isAction(renameProject) &&
522
+ ctx.actionResult?.projectId === ctx.currentParams.projectId
523
+ );
524
+ }
525
+ return ctx.currentParams.projectId !== ctx.nextParams.projectId
526
+ ? ctx.defaultShouldRevalidate
527
+ : false;
528
+ }
529
+ ```
530
+
531
+ A group's `revalidate()` runs on the server; the build hoists it out of the
532
+ `"use client"` module, so its argument may reference imports and globals only.
533
+ A predicate that several loaders share lives in its own module without
534
+ `"use client"` (`/client-urls` → "Revalidation runs on the server").
535
+
536
+ `onboarding.client.tsx`, `settings.client.tsx` and `admin.client.tsx` follow
537
+ the same pattern:
538
+
539
+ ```tsx
540
+ // src/urls/settings.client.tsx (excerpt)
541
+ function ProfileForm() {
542
+ const { data: viewer } = useLoader(ViewerLoader); // registered on the shell
543
+ const [state, formAction, pending] = useActionState(updateProfile, null);
544
+ return (
545
+ <form action={formAction}>
546
+ <input name="name" defaultValue={viewer.name} />
547
+ {state && !state.ok ? <p role="alert">{state.error}</p> : null}
548
+ <button disabled={pending}>Save</button>
549
+ </form>
550
+ );
551
+ }
552
+
553
+ export default clientUrls(({ path, layout }) => [
554
+ layout(SettingsLayout, () => [
555
+ path("/", ProfilePage, { name: "profile" }), // ProfileForm under <Suspense>
556
+ path("/billing", BillingPage, { name: "billing" }),
557
+ ]),
558
+ ]);
559
+ ```
560
+
561
+ ```tsx
562
+ // src/urls/admin.client.tsx (excerpt)
563
+ export default clientUrls(({ path, layout, loader, revalidate }) => [
564
+ layout(AdminLayout, () => [
565
+ path("/", UsersPage, { name: "users", search: { q: "string?" } }, () => [
566
+ loader(UsersLoader, () => [
567
+ revalidate(({ isAction }) =>
568
+ isAction() ? isAction(setRole) : undefined,
569
+ ),
570
+ ]),
571
+ ]),
572
+ ]),
573
+ ]);
574
+ ```
575
+
576
+ ## Marketing and the API
577
+
578
+ ```tsx
579
+ // src/urls/marketing.tsx
580
+ import { urls } from "@rangojs/router";
581
+ import { HomePage, MarketingLayout, PricingPage } from "../pages/marketing";
582
+
583
+ // Public and the same for every visitor: each page serves from a ppr shell.
584
+ export const marketingPatterns = urls(({ path, layout }) => [
585
+ layout(MarketingLayout, () => [
586
+ path("/", HomePage, { name: "home", ppr: true }),
587
+ path("/pricing", PricingPage, { name: "pricing", ppr: true }),
588
+ ]),
589
+ ]);
590
+ ```
591
+
592
+ A marketing header that shows the signed-in state reads it from a live
593
+ loader under `<Suspense>`, never in a handler: a `cookies()` read the shell
594
+ capture waits for refuses the capture, and the page stays a MISS (`/ppr`).
595
+
596
+ ```ts
597
+ // src/urls/api.tsx
598
+ import { urls } from "@rangojs/router";
599
+ import { db } from "../lib/db";
600
+ import { CurrentUser } from "../middleware/session";
601
+
602
+ export const apiPatterns = urls(({ path }) => [
603
+ path.json("/health", () => ({ ok: true }), { name: "health" }),
604
+ path.json(
605
+ "/projects",
606
+ async (ctx) => {
607
+ const user = ctx.get(CurrentUser); // set by the global loadSession
608
+ if (!user)
609
+ throw Response.json({ error: "unauthorized" }, { status: 401 });
610
+ return db.projects.list(user.id);
611
+ },
612
+ { name: "projects" },
613
+ ),
614
+ ]);
615
+ ```
616
+
617
+ Response routes skip the RSC pipeline and the router's `originCheck`. A
618
+ cookie-authenticated route that changes state needs the same-origin guard
619
+ (`requireSameOrigin` in `/response-routes` → "CSRF"), mounted here with
620
+ `router.use("/api/*", …)`.
621
+
622
+ ## Loaders, caches and actions
623
+
624
+ ```ts
625
+ // src/loaders/projects.ts
626
+ import { cacheTag, createLoader, notFound } from "@rangojs/router";
627
+ import { db } from "../lib/db";
628
+ import { CurrentUser } from "../middleware/session";
629
+ import { requireUserForFetch } from "../middleware/guards";
630
+
631
+ // Shared by everyone who opens the project: cached and tagged.
632
+ async function getProject(id: string) {
633
+ "use cache";
634
+ cacheTag(`project:${id}`);
635
+ return db.projects.get(id);
636
+ }
637
+
638
+ export const ProjectLoader = createLoader(
639
+ async (ctx) => {
640
+ const project = await getProject(ctx.params.projectId!);
641
+ if (!project) notFound("No such project"); // AppNotFound renders
642
+ return project;
643
+ },
644
+ { middleware: [requireUserForFetch] }, // fetchable, guarded
645
+ );
646
+
647
+ // Per user: a live loader, never cached.
648
+ export const ProjectsLoader = createLoader(async (ctx) =>
649
+ db.projects.list(
650
+ ctx.get(CurrentUser)!.id,
651
+ ctx.searchParams.get("q") ?? undefined,
652
+ ),
653
+ );
654
+
655
+ // Costly: registered with { prefetch: false } in the app group.
656
+ export const ActivityLoader = createLoader(async (ctx) =>
657
+ db.activity.list(ctx.params.projectId!),
658
+ );
659
+
660
+ export const StatsLoader = createLoader(async (ctx) =>
661
+ db.stats.forUser(ctx.get(CurrentUser)!.id),
662
+ );
663
+ ```
664
+
665
+ ```ts
666
+ // src/actions/projects.ts
667
+ "use server";
668
+
669
+ import { getRequestContext, redirect, updateTag } from "@rangojs/router";
670
+ import { db } from "../lib/db";
671
+ import { CurrentUser } from "../middleware/session";
672
+
673
+ export async function renameProject(_prev: unknown, formData: FormData) {
674
+ // Route middleware does not wrap actions: check here.
675
+ const ctx = getRequestContext();
676
+ if (!ctx.get(CurrentUser)) throw redirect(ctx.reverse("auth.login"));
677
+ const projectId = String(formData.get("projectId"));
678
+ await db.projects.rename(projectId, String(formData.get("name")));
679
+ await updateTag(`project:${projectId}`); // every user's cached copy
680
+ return { ok: true as const, projectId };
681
+ }
682
+ ```
683
+
684
+ The `admin` actions check the role the same way: `requireAdmin` guards the
685
+ admin pages, not the actions they call, and any URL that matches a route can
686
+ carry an action (`/server-actions` → "Authorization in actions").
687
+
688
+ ## Prefetch and `prefetch: false`
689
+
690
+ A `<Link>` prefetch is the navigation sent early: the server runs the
691
+ destination's loaders, and a click that adopts it commits the complete page.
692
+ `loader(Def, { prefetch: false })` keeps one loader out of every prefetch; a
693
+ click then shows the page with that loader's fallback and sends one
694
+ follow-up request (the fill) for it.
695
+
696
+ The flag moves that loader's wait to after the click. In one measurement on a real app (development build, a
697
+ loaded machine, a hover then a click 150 ms later), a `clientUrls()` tab
698
+ whose loader was flagged showed its content 843 to 918 ms after the click,
699
+ and the same tab prefetched whole showed it in 77 to 157 ms. The gap is the
700
+ flagged loader's own latency, the fill's round trip, and React holding a
701
+ revealed fallback for at least 300 ms.
702
+
703
+ Rules of thumb:
704
+
705
+ - Flag a loader that is expensive or metered, and whose prefetches mostly
706
+ never become clicks: activity feeds, analytics panels, reviews below the
707
+ fold, a list behind a tab.
708
+ - Do not flag what the likely click needs first: the page's main content.
709
+ - Read a flagged loader inside its own `<Suspense>`; with no boundary, React
710
+ keeps the page being left on screen until the fill answers.
711
+ - A same-route click (`/app/projects/1` to `/app/projects/2`) prefetches a
712
+ flagged loader as usual: only data the page does not have yet is deferred.
713
+
714
+ Details: `/loader` → "`prefetch: false`", `/client-urls` → "`prefetch: false`".
715
+
716
+ ## Keeping data current
717
+
718
+ | Change | Mechanism |
719
+ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
720
+ | This user's own mutation | A server action plus `revalidate()` rules on the loaders it touches (`isAction(ref)`, `actionResult`): the server re-runs exactly those, in the action's response |
721
+ | The same data cached for many users | `"use cache"` with `cacheTag()`, dropped by `updateTag()` in the action (awaited: the action's own response reads fresh) |
722
+ | A change this tab did not make (a push, polling, another tab, a REST call) | `refetch()` / `load()` from `useLoader()`, `useRefreshLoaders()(group)` for several loaders, plus `invalidateClientCache()` for prefetches and history |
723
+ | "Re-render everything on this page" | `useRouter().refresh()`: refetches the current page with no held segments, so every segment and loader renders |
724
+ | An action that changed nothing visible (an autosave) | `keepClientCache()` in the action: the browser keeps its prefetches and history cache |
725
+
726
+ ```tsx
727
+ // src/shell/live-updates.tsx
728
+ "use client";
729
+
730
+ import { useEffect } from "react";
731
+ import { invalidateClientCache } from "@rangojs/router";
732
+ import { useLoader } from "@rangojs/router/client";
733
+ import { ProjectLoader } from "../loaders/projects";
734
+
735
+ // Another user renamed the project: their action already dropped the server
736
+ // cache (updateTag). This tab refetches what it shows and drops its
737
+ // prefetches and history cache.
738
+ export function ProjectLiveUpdates({ projectId }: { projectId: string }) {
739
+ const { refetch } = useLoader(ProjectLoader);
740
+ useEffect(() => {
741
+ // An SSE route (path.stream, see the streams-and-websockets skill).
742
+ const events = new EventSource(`/api/projects/${projectId}/events`);
743
+ events.onmessage = () => {
744
+ invalidateClientCache();
745
+ void refetch();
746
+ };
747
+ return () => events.close();
748
+ }, [projectId, refetch]);
749
+ return null;
750
+ }
751
+ ```
752
+
753
+ `load()`, `refetch` and `useRefreshLoaders()` call the loader through the
754
+ `_rsc_loader` endpoint, which serves only fetchable loaders:
755
+ `createLoader(fn, true)`, or `createLoader(fn, { middleware })`. A plain
756
+ `createLoader(fn)` answers 403 there. That request runs `router.use()`
757
+ middleware and the loader's own `middleware` list, and **no route
758
+ middleware** (`src/rsc/handler.ts`, `src/rsc/loader-fetch.ts`): the
759
+ `requireUser` around the include does not protect a refetch. The session
760
+ comes from the global `loadSession`, and each fetchable loader in a
761
+ protected group lists its guard (`requireUserForFetch` above). For several
762
+ loaders at once, tag the reads with `useLoader(Def, { refreshGroup: "account" })`
763
+ and call `useRefreshLoaders()("account")` (plain GET, no params).
764
+
765
+ ## Names, links and generated files
766
+
767
+ The include names make the route names: `auth.login`, `app.dashboard`,
768
+ `app.project`, `settings.profile`, `admin.users`, `api.projects`,
769
+ `marketing.home`.
770
+
771
+ - Server code links with `ctx.reverse("app.project", { projectId })` (handlers,
772
+ layouts, loaders; middleware with global names only).
773
+ - A group's components link inside the group with `useReverse(routes)` and
774
+ the group's generated map (`./app.client.gen`): mount-aware, params filled
775
+ from the URL.
776
+ - A server action redirects by name too: `getRequestContext().reverse("auth.login")`
777
+ (global names; see `renameProject`). Client components that need a URL from
778
+ another module take it as a prop from the server layout, as `UserMenu` does.
779
+ - Static paths with no route behind them: `href("/healthz")`.
780
+
781
+ Generated and committed: `src/router.named-routes.gen.ts`, and from
782
+ `rango generate src` one `*.gen.ts` per module (`urls/app.client.gen.ts`,
783
+ `urls/auth.client.gen.ts`, …, `urls/api.gen.ts`). Wire `rango generate src`
784
+ into a `predev` script so a new route never waits for a manual run.
785
+
786
+ ### URL-scoped tenants
787
+
788
+ If the account or project is in the URL, nest the includes and let every
789
+ module link by local name; none of them reads `:accountId` itself.
790
+
791
+ ```tsx
792
+ // router.tsx urls: include("/:accountId", accountUrls, { name: "account" })
793
+ // urls/account.tsx: include("/:projectId", projectUrls, { name: "project" })
794
+
795
+ // urls/project.tsx, on /acme/web/issues
796
+ ctx.reverse(".issue", { issueId: "7" }); // /acme/web/issues/7
797
+ ctx.reverse("account.settings"); // /acme/settings, cross-module: global name
798
+ // urls/project.client.tsx
799
+ const reverse = useReverse(routes); // ./project.client.gen
800
+ reverse(".issues", { projectId: "api" }); // /acme/api/issues
801
+ ```
802
+
803
+ - A link into another module (the project nav's "Account settings") is built
804
+ on the server and passed as a prop or loader data. `useReverse(accountRoutes)`
805
+ in a project component gives a wrong URL with no error (dc-t57).
806
+ - A server layout stays held when `:projectId` changes (partial rendering by
807
+ design), so links it rendered keep the old project. Render the project
808
+ switcher and nav in a client component with `useReverse(routes)`, or give
809
+ the layout a `revalidate()` comparing `projectId`.
810
+ - The account layout's `ctx.reverse(".settings")` resolves in the account
811
+ module on every page below it, the project's pages included: it links to
812
+ `/acme/settings`, not the project's settings route.
813
+
814
+ Links built on the server, from the RSC as a prop or from a loader as data,
815
+ carry every param already filled; the client component only renders them:
816
+
817
+ ```tsx
818
+ // urls/project.tsx (server): build the links where the names resolve
819
+ export const ProjectNavLoader = createLoader((ctx) => ({
820
+ issues: ctx.reverse(".issues"), // /acme/web/issues
821
+ settings: ctx.reverse(".settings"), // /acme/web/settings
822
+ account: ctx.reverse("account.settings"), // /acme/settings, another module
823
+ }));
824
+
825
+ export const projectUrls = urls(({ layout, path, loader, revalidate }) => [
826
+ layout(
827
+ // From the RSC: fine for links that do not follow :projectId.
828
+ (ctx) => <ProjectShell accountHref={ctx.reverse("account.settings")} />,
829
+ () => [
830
+ // From a loader: held like the layout, re-run when the project changes.
831
+ loader(ProjectNavLoader, () => [
832
+ revalidate(({ currentParams, nextParams }) =>
833
+ currentParams.projectId !== nextParams.projectId ? true : undefined,
834
+ ),
835
+ ]),
836
+ path("/issues", Issues, { name: "issues" }),
837
+ path("/settings", Settings, { name: "settings" }),
838
+ ],
839
+ ),
840
+ ]);
841
+ ```
842
+
843
+ ```tsx
844
+ "use client";
845
+ export function ProjectNav() {
846
+ const { data: links } = useLoader(ProjectNavLoader);
847
+ return (
848
+ <nav>
849
+ <Link to={links.issues}>Issues</Link>
850
+ <Link to={links.settings}>Settings</Link>
851
+ <Link to={links.account}>Account</Link>
852
+ </nav>
853
+ );
854
+ }
855
+ ```
856
+
857
+ ## Why each piece sits where it does
858
+
859
+ | Piece | Where | Why |
860
+ | ------------------------------------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
861
+ | Session lookup | `router.use(loadSession)` | The only layer that wraps actions and fetchable-loader calls too; one lookup, read everywhere with `ctx.get(CurrentUser)` |
862
+ | Sign-in, onboarding, role gates | `middleware()` around the includes | Runs on every document load and navigation into those routes, so every click is re-authorized; the group itself cannot hold middleware |
863
+ | Authorization of a mutation | inside the action, and the fetchable loader's `middleware` | Route middleware does not wrap either |
864
+ | Top nav, user menu | the server `AppShell` layout | Spans every group; rendered once, kept across navigations within and between groups |
865
+ | The user's data | `loader(ViewerLoader)` on the shell, `useLoader()` in the menu | Live per request; held on navigations; re-run only after the actions its `revalidate()` names |
866
+ | Area chrome (tabs, pending state) | the group's `layout()` | Matches in the browser: instant at the click, `useOutlet().pending`, instance kept across the group's routes |
867
+ | Error and not-found UI for signed-in pages | `errorBoundary()` / `notFoundBoundary()` on the shell | Groups reject DSL boundaries; one on the server layout around the mounts catches a group loader's throw or `notFound()`, and the shell's chrome stays |
868
+ | Marketing pages | server `urls()` with `ppr` | Same HTML for every visitor; the first byte comes from the stored shell |
869
+ | JSON API | `path.json()` in `urls/api.tsx` | Outside the RSC pipeline; guard state-changing routes against CSRF |
870
+ | A cached read shared by users | `"use cache"` + `cacheTag()` inside the loader | Loaders stay live; the expensive call is what caches, and `updateTag()` reaches every user |
871
+
872
+ ## Mistakes this shape avoids
873
+
874
+ - **Trusting the include's guard for actions and refetches.** It wraps renders
875
+ only. Authorize in the action body and in each fetchable loader's
876
+ `middleware`.
877
+ - **Putting `auth` (or `onboarding`) inside the gate that redirects to it**:
878
+ a redirect loop.
879
+ - **Reading the session in a `ppr` handler** (a marketing header with the
880
+ user's name): the capture is refused and the page never serves a shell.
881
+ - **`prefetch: false` on every loader**: each click then waits for a fill.
882
+ Flag the expensive, rarely-clicked ones.
883
+ - **A bare `isAction(refs)` predicate**: it is a hard `false` on navigation
884
+ too, so the loader stops following param changes. Use
885
+ `isAction() ? isAction(refs) : undefined` (`/loader`).