Skip to content

docs(skills): app-structure — concrete urls() layouts for SaaS, multi-app, content and e-commerce apps - #1135

Merged
ivogt merged 3 commits into
mainfrom
docs/skill-app-structure
Oct 13, 2026
Merged

ivogt merged 3 commits into
mainfrom
docs/skill-app-structure

Conversation

@ivogt

@ivogt ivogt commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: dc-t41 · session dc-s37

Draft for the user's review of the recipes. Also aligns /composability with the user's guidance: it said "Prefer the async form"; it now says default to sync include() and use async for large, rarely visited groups. The four plain-text mentions of ux-architecture (#1134) become /ux-architecture links once both land.

docs(skills): app-structure — concrete urls() layouts for SaaS, multi-app, content and e-commerce apps

Problem

An agent or a developer starting a Rango app finds one skill per primitive (/composability, /router-setup, /client-urls, /host-router, /prerender, /ppr, /middleware, …) and no whole app. The questions they actually have, "where does the auth guard go relative to the clientUrls() group", "what does the root urls.tsx of a dashboard look like", "how do I split marketing, app, admin and docs across hosts", "which pages get ppr and which Prerender", get answered by assembling five skills, and the assembly is where mistakes land: a guard expected to protect actions, a shared loader on a group layout(), a route module that imports the router.

Before and after

Before: a consumer reads /composability for include(), /client-urls for groups, /middleware for guards, and composes a tree, unverified.

After: /app-structure gives the tree, verified end to end:

// src/urls.tsx (recipe 1, abridged)
export const urlpatterns = urls(({ path, layout, include, middleware, loader, revalidate, errorBoundary, notFoundBoundary }) => [
  include("/", marketingPatterns, { name: "marketing" }), // ppr pages
  include("/auth", authUrls, { name: "auth" }), // clientUrls(), outside the guard
  layout(AppShell, () => [
    middleware(requireUser),
    loader(ViewerLoader, () => [revalidate((ctx) => (ctx.isAction() ? ctx.isAction(updateProfile) : undefined))]),
    errorBoundary(AppError),
    notFoundBoundary(AppNotFound),
    include("/onboarding", onboardingUrls, { name: "onboarding" }),
    middleware(requireOnboarded, () => [
      include("/app", appUrls, { name: "app" }),
      include("/settings", settingsUrls, { name: "settings" }),
    ]),
    middleware(requireAdmin, () => [include("/admin", adminUrls, { name: "admin" })]),
  ]),
  include("/api", apiPatterns, { name: "api" }),
  path("/maintenance", MaintenancePage, { name: "maintenance" }),
]);

End-to-end consumer example

skills/app-structure/large-app.md is the full app: file tree, router.tsx (router.use(loadSession), /api/* CSRF guard, optional throw rewrite() maintenance), the guard modules, the server shell and its user-menu loader, every clientUrls() group with its revalidate() predicates, the marketing and API modules, loaders with "use cache" + cacheTag(), actions with updateTag(), out-of-band refresh (refetch(), useRefreshLoaders(), invalidateClientCache()), and the generated files to commit.

Outline

packages/rangojs-router/skills/app-structure/ (2,086 lines):

  • SKILL.md (214): not-this-skill-if; recipe picker; the six rules every recipe follows (route tree in its own module, named sync include per section, identity in router.use() and gates in route middleware(), shared chrome in a server layout, data in loaders, generated files); a small app in one module; sync vs async include() (table with measured costs, when async pays, rango generate limits); naming includes (name nested includes through 0.24.2, Nested unnamed include() never matches: its routes answer 404 with no warning #1104); where shared chrome lives; growing an app (6 steps); related skills.
  • large-app.md (823): the user's target architecture as the primary recipe. How navigations behave inside vs between groups; file tree; router.tsx; session, guards, fetch-lane guard, maintenance rewrite; root urls.tsx; the server shell; the auth and app groups in full, settings and admin excerpts; marketing (ppr) and API (path.json, CSRF); loaders, caches, actions; prefetch: false with the measured trade-off and rules of thumb; keeping data current (actions + revalidate(), "use cache" tags, refetch() / useRefreshLoaders() / invalidateClientCache(), useRouter().refresh(), keepClientCache()); fetchable loaders guard themselves; names and generated files; placement table; the group layout() loader gap through 0.24.2 (dc-t38) with its workaround; mistakes the shape avoids.
  • multi-app.md (280): createHostRouter() with apex, app., admin., docs.; Cloudflare entry and the node/vercel variant; each app's router.tsx/handler.ts/urls.tsx; what is shared and how (components, Document, CSS, theme config, cache store, parent-domain session; theme choice and client caches are not); one TypeScript program per app; the host-override cookie in dev; cross-app links as document navigations (fix(router): swap app shell on cross-router SPA navigation #476 → fix(router): redirect on cross-app navigation so the target document is fully re-established #555), absolute URLs vs reloadDocument + prefetch="none"; deploys.
  • content-site.md (333): docs with Prerender + ppr and a Static nav in a parallel slot; a blog with Prerender onDemand + Passthrough, drafts via notFound() (and why not Skip in a refresh); the publish webhook beside the Worker entry; machine routes; locales in the URL.
  • e-commerce.md (436): ppr category (typed search, one shell per query, tracking params excluded) and product pages (tagged "use cache" handler output, live loader holes, prefetch: false reviews); the cart as a store-layout loader narrowed to cart actions; per-segment action outcomes; checkout guarded and server-rendered with an external redirect; variants: prerendered products, a clientUrls() shop.

Code-change summary

  • New: skills/app-structure/{SKILL.md,large-app.md,multi-app.md,content-site.md,e-commerce.md}.
  • skills/rango/SKILL.md: a row in "Structure & routing" and in "Pick a primitive".
  • skills/catalog.json: regenerated (node tools/generate-skills-catalog.mjs).
  • README.md: one row in "Everything else, when you need it".
  • tools/check-docs-api.mjs: the five app-structure files join the guarded docs (404 identifiers verified, up from the previous set).
  • tools/check-skills.mjs: /app and /settings added to URL_EXAMPLES (route examples in the recipes).

Verification

Every snippet was written first as a scratch app per recipe, typechecked against the package's built declarations with rango generate route maps, and exercised through @rangojs/router/testing/flight (serveShellRequest, router.prerender()). The final check extracted every full-file code block from the five markdown files over those apps and re-ran tsc and the probes: 5 programs plus 4 per-app programs clean, 25 probe tests passing. Blocks marked "(excerpt)" (settings and admin groups in recipe 1, the site urls in recipe 2) were not extracted; the full versions they abridge are in the scratch apps and typecheck.

Claim Evidence
middleware(guard, () => [include(...)]) scopes the guard to the include's routes src/route-definition/dsl-helpers.ts:511-555 (wrapping mode), :69-77 (an include counts as routes); e2e test-app mounts clientUrlsVarsPatterns this way
Route middleware runs outer to inner probe: signed-out /settings → /auth/login?next=%2Fsettings (requireUser before requireOnboarded)
Guard redirects: document 302, client navigation X-RSC-Redirect probe: 302 /auth/login?next=%2Fapp; partial → x-rsc-redirect: http://localhost/auth/login?next=%2Fapp
Route middleware re-runs on an in-group navigation probe: signed-out tab switch /app → /app/projects redirects
router.use() wraps actions and loader fetches; route middleware wraps neither src/rsc/handler.ts:706-727 (global chain around the core handler), :1194 (loader fetch inside it), src/rsc/loader-fetch.ts:18 (executeLoaderMiddleware); skills/middleware/SKILL.md request flow
Fetchable loaders: createLoader(fn, true) / createLoader(fn, { middleware }) src/loader.ts:25-39; skills/hooks/data.md (403 for plain loaders)
useLoader() returns { data, isLoading, error, load, refetch }; refreshGroup; useRefreshLoaders(); load memoized src/use-loader.tsx:129-158, :427-562, :735-747
useRouter().refresh() refetches with no held segments src/browser/navigation-bridge.ts:582-601
invalidateClientCache, keepClientCache, cacheTag, updateTag, revalidateTag, rewrite, TRACKING_SEARCH_PARAMS exports src/index.rsc.ts:122-127, 209, 242-254; signatures src/cache/cache-tag.ts:310, src/cache/tag-invalidation.ts:241,291, src/server/cookie-store.ts:335
throw rewrite("maintenance") renders at the visitor's URL probe: 200, x-rango-rewrite: /maintenance, "Back soon" in the payload
A navigation between groups is not optimistic src/client-urls/navigation.ts:97-104, 138-142 (only the mounted group matches)
The shell layout and its loader are held across groups probe: /app → /settings keeps M0L0L0 and its loader segment in matched; viewer data absent from the payload
Action revalidation per loader probe: renameProject re-ran ProjectLoader + ActivityLoader (predicate), not ViewerLoader; updateProfile re-ran ViewerLoader
prefetch: false defers the loader in a prefetch probe: readDeferred() = 1 segment, ActivityLoader not called
prefetch:false measurement (843-918 ms vs 77-157 ms) dc-t3, comment dc-c79 (2026-10-11), devcorner's app on 0.23.0, dev build, loaded machine; loader latency not recorded
G2: a group layout() loader re-runs on cross-route navs; a server layout loader is held probe on f579921: group loader revalidate(() => false) ran 1 → 2 on /g/a → /g/b; server layout loader 1 → 1; dc-t38
notFound() from a group loader renders the shell's notFoundBoundary() with a 404 probe: 404, AppNotFound UI and the shell nav in the payload
A read walks up to an ancestor layout's loader src/use-loader.tsx:166-213
Marketing ppr pages capture and HIT probe: /pricing MISS → HIT
Other-origin links are external to <Link> and the interceptor; reloadDocument skips the click handler but not prefetch; data-no-intercept="true" src/browser/react/Link.tsx:179-191, 290-293, 355-384; src/browser/link-interceptor.ts:183-218
Same-origin cross-app navigation answers X-RSC-Reload src/rsc/handler.ts:995-1000; dc-d21 (measured request sequence, e2e multi-router.test.ts cross-app cases)
Host patterns and the cookie override probe: example.com/www. → site, app./admin./docs. → their apps, localhost → site, cookie x-rango-host=admin.example.com → admin
Theme cookie is host-only src/theme/ThemeProvider.tsx:66-72 (no Domain)
cookies().set(..., { domain }) src/router/middleware-types.ts:27-35
One TS program per app examples/cloudflare-multi-router/tsconfig.json; root + 4 app programs typecheck
Prerender + ppr docs page with a Static nav slot probe: MISS → HIT, prelude holds the page body and the nav
onDemand + Passthrough: baked, live fallback, refresh probe: baked 200, unbaked live 200, router.prerender() → rendered
Draft via notFound() → removed; Skip in a refresh → render-failed probe: both statuses observed
onDemand and ppr cannot combine probe: the route definition throws "sets both ppr and onDemand"
Tracking params excluded from shell keys; distinct search = new shell probe: ?sort=price&utm_source=x HIT on ?sort=price's shell; ?sort=name MISS
Product shell HIT runs no handler; live loaders run; updateTag("product:mug") drops the shell probe: get 1 call over 2 requests, price 2; MISS after updateTag
add-to-cart outcomes probe: CartLoader + StockLoader re-ran; price, reviews and the handler held with notOnActions; without the route revalidate() the handler re-ran
ctx.set() in an action is visible to the revalidation render probe: CartLoader read the new cart id; skills/server-actions/SKILL.md
requireCart redirect probe: 302 /cart
A route module importing the router crashes when loaded first probe: Cannot read properties of undefined (reading 'handler') at router.ts:946
rango generate resolves () => import() (default export); a .then() provider fails without --runtime CLI probe: exit 1, "Unresolvable includes … dynamic-expression"
Nested unnamed includes 404 through 0.24.2 #1104; fix PR #1128 is open (not on main f579921). Not reproducible in a unit probe (it needs build discovery's trie); stated from the issue and #1128's regression tests
Async include first-hit numbers skills/composability/SKILL.md (measured 2026-07)
A clientUrls() async include has no startup win skills/client-urls/SKILL.md "Defining and mounting"
Vite plugin writes router.named-routes.gen.ts; per-module files come from rango generate src/vite/discovery/route-types-writer.ts:83-140, src/build/route-types/per-module-writer.ts:121; #1127 commit message
Hidden include names stay out of generated files src/vite/discovery/route-types-writer.ts:28,124, src/route-name.ts

Test plan

  • node tools/check-skills.mjs: OK, 48 skills.
  • node tools/generate-skills-catalog.mjs --check: OK.
  • node tools/check-docs-api.mjs: 404 identifiers verified (the five new files included).
  • node tools/check-doc-links.mjs: OK.
  • node tools/generate-route-types.mjs --check: current (no app sources changed).
  • oxfmt --check ., oxlint --deny-warnings: clean.
  • Snippet typecheck: every full-file block of the five files, extracted over the scratch apps: tsc clean in 9 programs.
  • Probes: 25 serveShellRequest / router.prerender() tests over the extracted snippets pass.
  • pnpm run typecheck, pnpm run test:unit:all: not run; no TypeScript source changed (markdown, JSON, two .mjs check scripts).
  • E2e: none; docs-only change.

Notes

  • ux-architecture links are plain text. The sibling skill (dc-t40) is not on this branch, and the skills guard (pre-commit and CI) rejects a /ux-architecture cross-reference to a missing directory. The four mentions read "the ux-architecture skill"; once dc-t40 lands, switch them to /ux-architecture so the guard checks them (SKILL.md ×3, large-app.md ×1).
  • Tension with /composability. That skill says "Prefer the async form for any route group that is a natural, independently-loadable unit". This skill defaults to sync and names when async pays. One of the two should move; follow-up.
  • rango generate and an inline root urls(). rango generate src/router.tsx on createRouter({}).routes(urls(({ path }) => [...])) writes a per-module router.gen.ts and no router.named-routes.gen.ts, with no warning (the .routes(({ path }) => [...]) and urls: urlpatterns forms both work). The recipes keep the root tree in urls.tsx, so they are unaffected; worth a ticket.
  • G2 and Nested unnamed include() never matches: its routes answer 404 with no warning #1104 are dated ("through 0.24.2"). When 0.24.3 ships the G2 fix and fix(router): nested unnamed include() routes match; dispatch matches through the route trie #1128 merges, trim the two sections.
  • Scratch apps and probes: /private/tmp/claude-501/-Users-ivotodorov-Development-vite-rsc/b29807e8-f2f4-4afb-bc0b-4452071e8f6f/scratchpad/skill-structure/ (large-app, multi-app, content-site, shop, small, extract.py, doc-* extractions).

Update 2026-10-13

  • G2 (fix(router): a clientUrls() layout() loader is held across its routes and follows its revalidate(), as a server layout's does #1137): a clientUrls() layout() loader is now held on navigation, as on a server layout. The defaults tables and recipes say so, the known gap is removed, and the revalidate(true|false) shorthand (feat(router): revalidate(true) and revalidate(false) shorthand in urls() and clientUrls() #1130) is used.
  • Links by name, never by string.
    • Inside a module, link by local name (ctx.reverse(".x"), useReverse(routes) with the module's generated .gen.ts). The module never knows its mount, and mount params (/:accountId/:projectId, /:locale, /:locale?) are filled automatically.
    • Links that cross modules are built on the server and passed down.
    • Hardcoded paths in the recipes are replaced.
    • The rules are verified in research dc-d58.
  • Held layouts. A server layout stays held across navigations, its own param included, by design. Param-dependent nav goes in a client component with useReverse(routes), or the layout gets a revalidate() that compares the param.
  • Rewrite or redirect: when to use throw rewrite() from middleware (same URL, no round trip), and its limits.

@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1135

commit: 856698a

ivogt added 2 commits October 13, 2026 09:52
…-app, content and e-commerce apps

A new skill, packages/rangojs-router/skills/app-structure/, puts the
primitives together into whole apps. SKILL.md holds the rules every recipe
shares (route tree in its own module, a named sync include() per section,
identity in router.use() and gates in route middleware, shared chrome in a
server layout, generated files), sync vs async include(), include naming
(name nested includes through 0.24.2, #1104), where shared chrome lives, a
growing-an-app path, and the minimal one-module app. Four companion recipes:

- large-app.md: a server shell over auth, onboarding, app, settings and
  admin clientUrls() groups; session in global middleware, sign-in,
  onboarding and role guards around the includes; prefetch: false with its
  measured trade-off; actions + revalidate(), refetch()/refreshGroup and
  cache tags for updates; fetchable loaders guard themselves; the group
  layout() loader gap through 0.24.2 and its workaround.
- multi-app.md: createHostRouter() with apex, app., admin. and docs. apps,
  what is shared and how, one TypeScript program per app, the host-override
  cookie in dev, cross-app links as document navigations.
- content-site.md: docs with Prerender + ppr and a Static nav, a blog with
  Prerender onDemand + Passthrough, the publish webhook beside the entry,
  machine routes.
- e-commerce.md: ppr category and product pages with tagged "use cache"
  reads and live loader holes, the cart as a layout loader, a guarded
  server-rendered checkout, the clientUrls() shop variant.

Wiring: the rango index and README tables list the skill, catalog.json is
regenerated, check-docs-api guards the five files, and check-skills
allowlists the /app and /settings route examples.
… in 0.24.3

A clientUrls() layout() loader is held across the group's routes since
#1137, so the e-commerce recipe no longer steers the cart loader to a
server layout for that reason, and large-app loses its known-gap section.
The nested unnamed include note now says 0.24.x: #1128 ships after 0.24.3.
@ivogt
ivogt force-pushed the docs/skill-app-structure branch 2 times, most recently from 9363c95 to 9b7c2b1 Compare October 13, 2026 10:22
@ivogt
ivogt force-pushed the docs/skill-app-structure branch from 9b7c2b1 to 856698a Compare October 13, 2026 10:26
@ivogt
ivogt marked this pull request as ready for review October 13, 2026 11:24
@ivogt
ivogt merged commit 7297dd0 into main Oct 13, 2026
78 checks passed
@ivogt
ivogt deleted the docs/skill-app-structure branch October 13, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant