Package Exports
- @rangojs/router
- @rangojs/router/__internal
- @rangojs/router/browser
- @rangojs/router/build
- @rangojs/router/build/shell-capture
- @rangojs/router/cache
- @rangojs/router/cache-runtime
- @rangojs/router/client
- @rangojs/router/cloudflare
- @rangojs/router/host
- @rangojs/router/host/testing
- @rangojs/router/internal/browser/dev-discovery
- @rangojs/router/internal/deps/browser
- @rangojs/router/internal/deps/html-stream-client
- @rangojs/router/internal/deps/html-stream-server
- @rangojs/router/internal/deps/rsc
- @rangojs/router/internal/deps/ssr
- @rangojs/router/internal/rsc-handler
- @rangojs/router/rsc
- @rangojs/router/server
- @rangojs/router/ssr
- @rangojs/router/testing
- @rangojs/router/testing/dom
- @rangojs/router/testing/e2e
- @rangojs/router/testing/flight
- @rangojs/router/testing/flight-matchers
- @rangojs/router/testing/vitest
- @rangojs/router/theme
- @rangojs/router/types
- @rangojs/router/vercel
- @rangojs/router/vite
Readme
Rango
A code-first, type-safe React Server Components router. Django-inspired: routes are expressed in one visible tree, URLs are built from names, and everything past the core is opt-in.
Experimental: This package is under active development. APIs may change between releases. Install with
@experimentaltag.
This page is a tour: it builds one small shop and meets the entire core API along the way — about six primitives. Everything else is opt-in and linked at the end. For the design rationale behind these APIs, read Why Rango; this page shows how it feels, that page argues why it's right.
Install
npm install @rangojs/router@experimental react @vitejs/plugin-rsc// vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { rango } from "@rangojs/router/vite";
export default defineConfig({
plugins: [react(), rango({ preset: "cloudflare" })],
});The cloudflare preset targets Cloudflare Workers (add
@cloudflare/vite-plugin); the vercel preset emits a ready-to-deploy
.vercel/output (Build Output API) from a plain vite build — see the
/vercel skill; omit preset for the default
Node setup.
Using the skills with your coding agent
This package ships agent skills in node_modules/@rangojs/router/skills/ —
task-focused guides written for LLM coding agents. Start at
skills/rango/SKILL.md (the mental model + catalog); a machine-readable index
is at skills/catalog.json.
- Claude Code: point it at the skills (e.g. "read
node_modules/@rangojs/router/skills/rango/SKILL.md before routing work"), or
copy/symlink the directories you use into your project's
.claude/skills/. - Other agents (Cursor, Codex CLI, Gemini CLI, ...): these harnesses
auto-discover skills from
.agents/skills/in your project (or~/.agents/skills/) — copy or symlink the skill directories you use fromnode_modules/@rangojs/router/skills/<name>into.agents/skills/<name>. The files are plain markdown; cross-references like/loadername sibling skill directories.
1. Pages
A router is a tree. path() places a page, layout() wraps children,
{ name } gives a route an identity:
// src/router.tsx
import { createRouter, urls } from "@rangojs/router";
import { Document } from "./document";
import { ShopLayout } from "./layouts/shop";
import { HomePage } from "./routes/home";
import { ProductPage } from "./routes/product";
const urlpatterns = urls(({ path, layout }) => [
layout(<ShopLayout />, () => [
path("/", HomePage, { name: "home" }),
path("/products/:slug", ProductPage, { name: "product" }),
]),
]);
export const router = createRouter({ document: Document }).routes(urlpatterns);// src/layouts/shop.tsx
import { Outlet } from "@rangojs/router/client";
export function ShopLayout() {
return (
<div>
<nav>Shop</nav>
<main>
<Outlet /> {/* child routes render here */}
</main>
</div>
);
}// src/document.tsx
"use client";
import type { ReactNode } from "react";
import { MetaTags, Scripts } from "@rangojs/router/client";
export function Document({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<MetaTags />
<Scripts />
</head>
<body>
<Scripts position="body" />
{children}
</body>
</html>
);
}(The built-in DefaultDocument already wires all of this — a custom document
is optional.)
A handler is a function of ctx. Typing it by route name gives typed params
— the Vite plugin generates the route map automatically, nothing to register:
// src/routes/product.tsx
import type { Handler } from "@rangojs/router";
export const ProductPage: Handler<"product"> = (ctx) => {
return <h1>{ctx.params.slug}</h1>; // slug: string, from the pattern
};And because routes have names, URLs are built, never hand-written:
const url = ctx.reverse("product", { slug: "espresso-cup" });
// "/products/espresso-cup" — name and params compile-time checkedRename /products/:slug to /shop/:slug in the one place it's defined and
every link, redirect, and prefetch follows. In client components, href()
validates static paths against the registered patterns:
<Link to={href("/")}>Home</Link>.
The tree is also lazy-first, which is the shape serverless cold starts want.
include() mounts a whole route module under a prefix — and with the async
form, include("/shop", () => import("./shop")), the group is code-split:
its module doesn't load or run until a request matches it, a group nobody
visits never evaluates at all, and warm requests run zero route handlers.
Boot cost stays flat as the app grows — one module body at startup, not one
per group — while matching stays an O(path length) prefix trie, identical
in dev and production. None of this is assumed: the trie is benchmarked
in-repo against multi-thousand-route manifests, and the lazy guarantees are
pinned by run-count tests (see
matching & lazy discovery).
Grow the tree without watching the boot time.
That's a working site. Everything below adds to this app.
2. Data
The product page needs data. A handler is an async server component — fetch where you render:
// src/routes/product.tsx
export const ProductPage: Handler<"product"> = async (ctx) => {
const product = await db.products.find(ctx.params.slug);
ctx.use(Meta)({ title: product.name }); // metadata where the data is
return <ProductView product={product} />;
};That's the default data path. React Router and Remix split data into a
loader beside the component because components couldn't fetch; RSC collapses
the split, and Rango doesn't reintroduce it. (That ctx.use(Meta) line is
also the whole metadata story: push tags where the data already is, layouts
set title templates, deeper segments override — no separate metadata export,
no second fetch.)
Loaders enter when data needs a life of its own. First case: a client component needs server data — the stock badge is interactive, but the stock lives in the database:
// src/loaders/stock.ts
import { createLoader } from "@rangojs/router";
export const StockLoader = createLoader(async (ctx) => {
"use server";
return db.stockFor(ctx.params.slug);
});path("/products/:slug", ProductPage, { name: "product" }, () => [
loader(StockLoader),
loading(<ProductSkeleton />),
]),// src/components/stock-badge.tsx
"use client";
import { useLoader } from "@rangojs/router/client";
import { StockLoader } from "../loaders/stock";
export function StockBadge() {
const { data } = useLoader(StockLoader);
return <span>{data.inStock ? "In stock" : "Sold out"}</span>;
}Loaders run in parallel with the handler and stream; loading() opts the
segment into skeleton-then-stream. Without it, document requests arrive
ready — the HTML ships with data in place; the skeleton is a per-segment
choice, not the first impression.
The rule of thumb: fetch in the handler when the data belongs to the rendered page — it will be frozen with the shell if you cache it (step 4). Put data in a loader when it must outlive the shell: shared with client components, fresh on every hit even when the segment is cached, refetchable from the client, or revalidated on its own after actions.
3. Mutations
Users add to cart. A server action is a plain "use server" function; the
form posts to it with standard React 19 hooks — and it works without
JavaScript:
// src/actions/cart.ts
"use server";
export async function addToCart(productId: string) {
await db.cart.insert({ productId });
}// src/components/add-to-cart.tsx
"use client";
import { useActionState } from "react";
import { addToCart } from "../actions/cart";
export function AddToCart({ productId }: { productId: string }) {
const [, action, pending] = useActionState(() => addToCart(productId), null);
return (
<form action={action}>
<button disabled={pending}>{pending ? "Adding…" : "Add to cart"}</button>
</form>
);
}After an action, route segments and loaders re-render by default so the UI
reflects the new state. revalidate() narrows that to the segments that
actually own the data — matched by action reference, so renames are
compile errors, not stale predicates:
import * as CartActions from "./actions/cart";
path("/cart", CartPage, { name: "cart" }, () => [
loader(CartLoader, () => [
revalidate((ctx) => ctx.isAction(CartActions) || undefined),
]),
]),Notice what you didn't write: no API endpoint, no fetch wrapper, and no
client-cache invalidation call. Actions invalidate the client-side caches
(history entries, prefetches, HTTP cache key) automatically — a no-op action
can opt out per invocation with keepClientCache().
4. Speed
Production traffic. Wrap a segment in cache() and the rendered shell —
including everything the handler fetched — is stored, while every loader on
it keeps running fresh on each hit. This is where the handler-vs-loader
choice from step 2 pays off: handler data freezes with the shell, the
StockLoader stays live. Cached shell, live data, one line:
const urlpatterns = urls(({ path, layout, loader, loading, cache }) => [
layout(<ShopLayout />, () => [
path("/", HomePage, { name: "home" }),
cache({ ttl: 600, swr: 3600, tags: ["products"] }, () => [
path("/products/:slug", ProductPage, { name: "product" }, () => [
loader(StockLoader), // never cached: re-runs on every hit
loading(<ProductSkeleton />),
]),
]),
]),
]);Wire a store once on the router (MemorySegmentCacheStore for dev,
CFCacheStore for Cloudflare — see the /caching skill),
and bust by tag from the mutation that changes the data:
// src/actions/products.ts
"use server";
import { updateTag } from "@rangojs/router";
export async function renameProduct(id: string, name: string) {
await db.products.rename(id, name);
await updateTag("products"); // awaitable, read-your-own-writes
}Navigation speed is a Link prop away:
<Link to={url} prefetch="viewport">
{product.name}
</Link>A fully-prefetched navigation commits a finished page — no skeleton, no loading flash — and staying correct is the router's job: every action invalidates the prefetch caches by default, so a prefetched page can't show pre-mutation data.
To move the shell's cost to build time entirely, Prerender() bakes it while
loaders stay live at runtime — same mental model, earlier cache write. See
the /prerender skill.
5. An API, when you need one
Response routes live in the same tree — path.json(), path.text(),
path.xml(), path.image(), path.stream():
path("/products/:slug", ProductPage, { name: "product" }),
path.json("/products/:slug", (ctx) => db.products.find(ctx.params.slug), {
name: "productJson",
}),Same URL: browsers get the page, API clients get JSON, negotiated by
Accept header in the route trie. Handlers return bare values; errors
serialize as RFC 9457 application/problem+json. The payload type is
inferred from the handler — no codegen:
type Product = RouteResponse<typeof urlpatterns, "productJson">;See the /api-client skill for a small typed
client over these endpoints.
Everything else, when you need it
That was the core: path/layout/include, names, loaders, actions +
revalidate, cache, response routes. The rest is opt-in — reach for it
when the requirement appears:
| I need to… | Skill |
|---|---|
| guard or shape requests (auth, headers) | /middleware |
| multi-column layouts, independent slots | /parallel |
| open a route as a modal on soft navigation | /intercept |
| compose route modules / sub-apps | /route, /composability |
| cache a single function or component | /use-cache, /cache-guide |
| feed live loaders from a cached shell | /shell-manifest |
| edge caching with Cache-Control | /document-cache |
| light/dark mode without FOUC | /theme |
| analytics / third-party scripts with CSP nonce | /scripts |
| locale routing | /i18n |
| SSE and WebSockets | /streams-and-websockets |
| multi-app routing by domain | /host-router |
| animate navigations | /view-transitions |
| test loaders, middleware, handlers, Flight | /testing |
| see where request time goes | /observability |
| deploy to Vercel (cache store, tracing, output) | /vercel |
| choose in-function vs CDN caching | /deployment-caching |
| compare Rango with Next.js / TanStack / Waku | /comparison |
The /rango skill is the full catalog and the
mental model that ties it together.
Reference
Imports and subpaths
| Export | Description |
|---|---|
@rangojs/router |
Server/RSC core and shared types: createRouter, urls, createLoader, Handler, Prerender, Meta |
@rangojs/router/client |
Client: Link, Outlet, href, useNavigation, useLoader, MetaTags |
@rangojs/router/cache |
Cache: CFCacheStore, VercelCacheStore, MemorySegmentCacheStore, createDocumentCacheMiddleware |
@rangojs/router/theme |
Theme: useTheme, ThemeProvider, ThemeScript |
@rangojs/router/host |
Host routing: createHostRouter, defineHosts, isNoRouteMatchError |
@rangojs/router/vercel |
Vercel: createVercelTracing (phase spans via @vercel/otel's global tracer) |
@rangojs/router/vite |
Vite plugin: rango() |
@rangojs/router/testing |
Consumer testing primitives: runLoader, runMiddleware, dispatch (plus /testing/dom, /testing/flight, /testing/e2e) |
@rangojs/router/rsc |
Advanced server pipeline APIs: createRSCHandler, request-context access |
@rangojs/router/ssr |
Advanced SSR bridge APIs: createSSRHandler |
Use only subpaths that are explicitly exported; avoid deep imports.
The root entry is conditionally resolved: server-only APIs (createRouter,
urls, redirect, Prerender, cookies) run under the react-server
condition and throw guidance errors elsewhere. If you hit a root-entrypoint
stub error: hooks and components (Link, Outlet, useLoader, MetaTags)
live in @rangojs/router/client; cache APIs in @rangojs/router/cache;
host APIs in @rangojs/router/host.
Type safety
The Vite plugin generates router.named-routes.gen.ts automatically (on dev
startup, HMR, and builds), registering route names, params, and search
schemas globally via Rango.GeneratedRouteMap. That powers Handler<"name">,
ctx.reverse(), and RouteParams<"name"> with no manual registration.
For response-aware and path-based utilities (href(), Rango.Path,
RouteResponse), augment Rango.RegisteredRoutes once:
// router.tsx
const router = createRouter<AppBindings>({}).routes(urlpatterns);
declare global {
namespace Rango {
interface Env extends AppEnv {}
interface RegisteredRoutes extends typeof router.routeMap {}
}
}See the /typesafety skill for the full
surface breakdown.
CLI
Route types are generated by the Vite plugin; the CLI is the manual fallback for CI or pre-first-run IDE support:
npx rango generate src/router.tsx # global named-route map
npx rango generate src/ # recursive scanExamples
e2e/mini— single-file demo appcloudflare-basic— Cloudflare Workers with caching, loaders, theme, and pre-renderingcloudflare-multi-router— multi-app host routingvercel-basic— Vercel deployment withpreset: "vercel",VercelCacheStore, and OTel tracingvercel-multi-router— multi-app host routing on Vercel (single function, routed by Host header)
Going deeper
- Why Rango — the design rationale, claim by claim
- Framework comparison — Rango vs Next.js App Router, TanStack Start, and Waku, capability by capability
- Docs index — architecture, caching, prerender, testing
- Execution model — the runtime contract
License
MIT