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().
URL: /dashboard/users/42
Host tier Remote tier
───────── ───────────
/dashboard/* → /users/:id (matched inside the remote)
/billing/* → /invoices/:id
/ → / (root delegate)Host routes
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)
apps/dashboard/src/pages/
├── index.tsx // -> /
├── settings.tsx // -> /settings
└── users/[id].tsx // -> /users/:idPage 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:
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.
| Hook | Returns | Re-renders when |
|---|---|---|
useRouter() | Singleton Router instance | Never (stable identity) |
usePathname() | string — pathname + search + hash | Any 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 provider | Match changes |
useNavigate() | (to, opts?: { replace?, state? }) => void | Never |
useNavigationEvents(fn) | void | Fires start + complete events |
useRemoteData({ key, fetcher, ttl? }) | { data, error, loading, refresh } | Resolution + revalidation |
useNavigate() — imperative
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
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
useNavigationEvents((event) => {
if (event.phase === 'start') perf.mark('nav-start');
if (event.phase === 'complete') perf.measure('nav', 'nav-start');
});Dynamic segments
| File | Route | Notes |
|---|---|---|
pages/users/[id].tsx | /users/:id | Single segment |
pages/docs/[...rest].tsx | /docs/* | Splat — matches the remainder |
pages/(marketing)/about.tsx | /about | Parentheses are a route group — no URL segment is emitted; useful for shared layouts |
pages/(auth)/login.tsx | /login | Same — groups never appear in the URL |
Read params from inside the page component:
import { useParams } from '@jorvel/runtime';
export default function UserPage() {
const { id } = useParams<{ id: string }>(); // matched against [id]
return <h1>User {id}</h1>;
}Route guards
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.
import { dispatchJorvelNavigate } from '@jorvel/runtime';
dispatchJorvelNavigate({ to: '/dashboard/settings' }); // push
dispatchJorvelNavigate({ to: '/login', mode: 'replace' }); // replace
dispatchJorvelNavigate({ to: '/cart', state: { from: 'product' } }); // with history stateWhy a DOM event instead of an import?
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.
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:
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.Typed search params
Validate + serialize the query string through any { parse(input) }-shaped validator (Zod, Valibot, or hand-written — no dependency required).
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.
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.
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.
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 segmentsRoute 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.
