JORVEL

Routing

JORVEL routing has two tiers: the host owns top-level routes, each remote owns its sub-routes. Both layers are built on the browser History API — no react-router required, no extra context, no wrapper providers needed in remotes.

Why two tiers?

A single router would force the host to import every remote's page table at build time, defeating the purpose of federation. JORVEL splits the table: the host decides which remote handles which prefix, the remote decides how to render the sub-path. The two tables meet at runtime via usePathname().

text
URL: /dashboard/users/42

Host tier         Remote tier
─────────         ───────────
/dashboard/*  →   /users/:id        (matched inside the remote)
/billing/*    →   /invoices/:id
/             →   /                  (root delegate)

Host routes

apps/shell/src/bootstrap.tsx
tsx
import { NavLink, RemoteOutlet, getRouter } from '@jorvel/runtime';
import type { RouteTarget } from '@jorvel/runtime';

const HOST_ROUTES: RouteTarget[] = [
  { path: '/dashboard/*', remote: 'dashboard', module: './App' },
  { path: '/',            remote: 'dashboard', module: './App' },
];

const REMOTES = {
  dashboard: () => import('dashboard/App'),
};

getRouter(); // singleton, safe under StrictMode

export default function App() {
  return (
    <>
      <header>
        <NavLink to="/" label="Home" />
        <NavLink to="/dashboard/settings" label="Settings" />
      </header>
      <main>
        <RemoteOutlet routes={HOST_ROUTES} remotes={REMOTES} />
      </main>
    </>
  );
}

NavLink respects modified clicks

NavLink only intercepts plain primary-button clicks. A click with ctrl/cmd/shift/altheld, or a middle/non-primary button, falls through to the browser — so "open in new tab" and "open in new window" work as expected.

Remote pages (file-based)

text
apps/dashboard/src/pages/
├── index.tsx         // -> /
├── settings.tsx      // -> /settings
└── users/[id].tsx    // -> /users/:id

Page files can be authored as .tsx, .ts, .jsx, .js, .mjs, or .cjs — the scanner treats them the same and the route path never carries the extension (index.mjs/).

Run jorvel routes to compile this tree into src/jorvel.routes.ts and pass it to RemoteApp:

apps/dashboard/src/remote.tsx
tsx
import { RemoteApp } from '@jorvel/runtime';
import { pages } from './jorvel.routes.js';

export default function RemoteRoot({ subpath = '/' }: { subpath?: string }) {
  return <RemoteApp subpath={subpath} pages={pages} />;
}

Hooks

All hooks subscribe to the same singleton router, so a state update in any remote re-renders subscribers in any other remote — no manual wiring.

HookReturnsRe-renders when
useRouter()Singleton Router instanceNever (stable identity)
usePathname()string — pathname + search + hashAny navigation
useSearchParams()[URLSearchParams, (next, mode?: 'push' | 'replace') => void] Query-string changes
useQueryParam(key)[string | null, (next) => void]That key changes
useParams<T>()Typed params from the nearest providerMatch changes
useNavigate()(to, opts?: { replace?, state? }) => voidNever
useNavigationEvents(fn)voidFires start + complete events
useRemoteData({ key, fetcher, ttl? }){ data, error, loading, refresh }Resolution + revalidation

useNavigate() — imperative

tsx
import { useNavigate } from '@jorvel/runtime';

function LogoutButton() {
  const navigate = useNavigate();
  return (
    <button onClick={async () => {
      await fetch('/api/logout', { method: 'POST' });
      navigate('/login', { replace: true, state: { reason: 'logout' } });
    }}>
      Sign out
    </button>
  );
}

useSearchParams() — reactive query string

tsx
import { useSearchParams } from '@jorvel/runtime';

function Filters() {
  const [params, setParams] = useSearchParams();
  const tab = params.get('tab') ?? 'overview';

  return (
    <select value={tab} onChange={(e) => {
      const next = new URLSearchParams(params);
      next.set('tab', e.target.value);
      setParams(next, 'replace');   // 'replace' keeps history clean for filter changes
    }}>
      <option>overview</option>
      <option>activity</option>
    </select>
  );
}

useNavigationEvents() — instrumentation

ts
useNavigationEvents((event) => {
  if (event.phase === 'start')    perf.mark('nav-start');
  if (event.phase === 'complete') perf.measure('nav', 'nav-start');
});

Dynamic segments

FileRouteNotes
pages/users/[id].tsx/users/:idSingle segment
pages/docs/[...rest].tsx/docs/*Splat — matches the remainder
pages/(marketing)/about.tsx/aboutParentheses are a route group — no URL segment is emitted; useful for shared layouts
pages/(auth)/login.tsx/loginSame — groups never appear in the URL

Read params from inside the page component:

pages/users/[id].tsx
tsx
import { useParams } from '@jorvel/runtime';

export default function UserPage() {
  const { id } = useParams<{ id: string }>();   // matched against [id]
  return <h1>User {id}</h1>;
}

Route guards

ts
import { createAuthGuard, runGuards } from '@jorvel/runtime';

const authGuard = createAuthGuard({
  isAuthenticated: () => !!localStorage.getItem('token'),
  loginPath: '/login',
});

const routes = [
  {
    path: '/dashboard/*',
    remote: 'dashboard',
    module: './App',
    guards: [authGuard],
  },
];

Guards run in order; a falsy result blocks the route, and { redirect } redirects instead. runGuards() lets you integrate the chain into a custom outlet.

Cross-app navigation

Remote code can navigate without importing the router by dispatching a DOM event. The host listens via attachJorvelNavigateListener() (auto-installed by getRouter()) and turns the event into a history.pushState.

ts
import { dispatchJorvelNavigate } from '@jorvel/runtime';

dispatchJorvelNavigate({ to: '/dashboard/settings' });               // push
dispatchJorvelNavigate({ to: '/login', mode: 'replace' });           // replace
dispatchJorvelNavigate({ to: '/cart', state: { from: 'product' } }); // with history state

Why a DOM event instead of an import?

The DOM event decouples remotes from the host's router instance. A remote can be loaded standalone (storybook, tests, a different host) and still call the same API — the event just no-ops when no host is listening.

Error boundaries

Wrap each RemoteOutlet in an ErrorBoundary so a single remote crash never blanks the host. The bundled boundary calls reportError() from @jorvel/observability automatically.

tsx
import { ErrorBoundary, RemoteOutlet } from '@jorvel/runtime';

<ErrorBoundary fallback={(err, reset) => (
  <div role="alert">
    <p>{err.message}</p>
    <button onClick={reset}>Retry</button>
  </div>
)}>
  <RemoteOutlet routes={HOST_ROUTES} remotes={REMOTES} />
</ErrorBoundary>

Routing during SSR

On the server, the History API is not available. Use createServerRouter(pathname) instead — a synchronous read-only router that feeds renderRouteToString:

ts
import { createServerRouter } from '@jorvel/runtime';
import { renderRouteToString } from '@jorvel/ssr';

const ctx = createServerRouter(request.url);
const result = await renderRouteToString(App, { path: ctx.pathname });

The hooks are SSR-safe

usePathname() and getRouter() do not touch window / history during SSR — they return the request-scoped server router, so a tree rendered with renderRouteToString never crashes on a missing History API.

Validate + serialize the query string through any { parse(input) }-shaped validator (Zod, Valibot, or hand-written — no dependency required).

tsx
import { parseSearchParams, buildSearchString, useTypedSearchParams } from '@jorvel/runtime';

const schema = { parse: (o: Record<string,string>) => ({ tab: o.tab ?? 'home', page: Number(o.page ?? '1') }) };

parseSearchParams('tab=settings&page=3', schema);   // { tab: 'settings', page: 3 }
buildSearchString({ tab: 'x', page: 2 });            // 'tab=x&page=2'

function Toolbar() {
  const [q, setQ] = useTypedSearchParams(schema);    // reactive, SSR-safe
  return <button onClick={() => setQ({ ...q, page: q.page + 1 })}>Next</button>;
}

Parallel routes & slots

Render independent subtrees keyed by slot name (Next's @modal/@sidebar), including intercepting routes that open over the current page.

tsx
import { defineSlots, SlotOutlet, ParallelRoutes } from '@jorvel/runtime';

const slots = defineSlots({
  modal: [{ path: '/photos/:id', element: <PhotoModal />, intercept: true }],
  sidebar: [{ path: '/dashboard/*', element: <Sidebar /> }],
});

function Layout() {
  return (
    <ParallelRoutes slots={slots}>
      <main><Outlet /></main>
      <SlotOutlet name="sidebar" />
      <SlotOutlet name="modal" />   {/* intercepts /photos/:id over the current page */}
    </ParallelRoutes>
  );
}

redirects & rewrites

Declare a redirects / rewrites block and evaluate it (in middleware or an adapter) with the matchers from @jorvel/types:param and * substitute into the destination.

ts
import { matchRedirect, matchRewrite } from '@jorvel/types';

const redirects = [{ source: '/old/:slug', destination: '/new/:slug', permanent: true }];
matchRedirect(redirects, '/old/pricing');  // { destination: '/new/pricing', status: 308 }

const rewrites = [{ source: '/api/*', destination: '/proxy/*' }];
matchRewrite(rewrites, '/api/users');      // '/proxy/users'

Catch-all & optional catch-all

A trailing * splat captures the rest of the path. File conventions [...slug] (catch-all) and [[...slug]] (optional catch-all — also matches the parent) compile to a * route; read the tail from the wildcard param.

ts
import { matchPath } from '@jorvel/runtime';

matchPath('/docs/*', '/docs/a/b/c');   // { params: { '*': 'a/b/c' } }
// file:  src/pages/docs/[...slug].tsx      → route '/docs/*'      (requires ≥1 segment)
// file:  src/pages/docs/[[...slug]].tsx    → routes '/docs' AND '/docs/*' (optional)
const { '*': rest } = params;          // 'a/b/c' → split('/') for segments

Route groups & scroll restoration

Group folders like (marketing) / (app)organize files without adding a URL segment — the compiler strips parenthesized segments. Scroll is restored on back/forward automatically (the router preserves the browser's history.scrollRestoration); for SPA navigations call window.scrollTo(0, 0) in a useNavigationEvents handler, or key a scroll container by pathname to retain per-route positions.