JSPM

@rangojs/router

0.4.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 3911
  • Score
    100M100P100Q124423F
  • License MIT

Django-inspired RSC router with composable URL patterns

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 @experimental tag.

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 from node_modules/@rangojs/router/skills/<name> into .agents/skills/<name>. The files are plain markdown; cross-references like /loader name 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 checked

Rename /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 scan

Examples

Going deeper

License

MIT