cross-router-core
v1.0.4
Published
Agnostic router engine for cross-router.
Maintainers
Readme
cross-router-core
The framework-agnostic engine behind cross-router. Contains the router, history, matcher, middleware chain, loaders, actions, and runtime route registry. Has no dependency on any UI framework.
Framework adapters (cross-router-svelte, etc.) import exclusively from this package. Plugin authors who only need to define routes and patch the route tree can also import directly from here.
Installation
npm i cross-router-core
# or with pnpm
pnpm add cross-router-coreCreating a router
In practice you will use a factory from a framework adapter (e.g. createBrowserRouter from cross-router-svelte) rather than calling createRouter directly. The adapter factory handles history creation and wires the router into the framework's reactivity model.
If you are building a new adapter, use createRouter directly:
import { createRouter, createBrowserHistory } from 'cross-router-core'
const router = createRouter(createBrowserHistory(), {
routes: [...],
middleware: [...],
})
await router.initialize()Route definition
Routes are plain objects. The RouteDefinition type is fully generic — params, search, loader data, and middleware context are all inferred from the definition.
import type { RouteDefinition } from 'cross-router-core'
const routes: RouteDefinition[] = [
{
id: 'root',
path: '/',
children: [
{
id: 'home',
index: true, // matches the parent path exactly
loader: async () => {
return { message: 'Hello' }
},
},
{
id: 'user',
path: 'users/:id', // :id is extracted and typed in loader/action
loader: async ({ params }) => {
return fetchUser(params.id)
},
},
{
id: 'not-found',
path: '*', // catch-all — always matched last
},
],
},
]Route fields
| Field | Type | Description |
|---|---|---|
| id | string | Required. Must be unique across the entire tree. |
| path | string | URL segment(s) this route matches. Omit for pathless layout routes. |
| index | true | Matches the parent path exactly. Cannot be combined with path. |
| middleware | Middleware[] | Route-level middleware. Runs after global middleware, before the loader. |
| search | valibot schema | Validates and types the URL search params for this route. |
| loader | function | Runs before the route renders. Return value becomes loaderData. |
| action | function | Handles form submissions (POST). |
| shouldRevalidate | function | Return false to skip re-running the loader after a navigation or action. |
| component | unknown | The component to render. Type is narrowed by the framework adapter. |
| errorComponent | unknown | Rendered when this route or any descendant throws. |
| lazy | function | Returns a promise resolving loader, action, component, or errorComponent. Used for code splitting. |
| children | RouteDefinition[] | Nested routes. |
Middleware
Middleware runs before every navigation, in order: global middleware first, then each matched route's own middleware from outermost to innermost.
Each middleware receives the accumulated context from the previous middleware and passes an extended context to the next one. This is the primary way to propagate things like authenticated user objects, feature flags, or API clients down to loaders.
import { createContext, defineMiddleware, redirect } from 'cross-router-core'
const USER_CONTEXT = createContext<User>()
const authMiddleware = defineMiddleware(async ({ context, request }, next) => {
const user = await getSession()
if (!user)
throw redirect('/login')
// Pass the user into the context — loaders will receive it typed
context.set(USER_CONTEXT, user)
await next()
})Pass global middleware to createRouter:
createRouter(history, {
middleware: [authMiddleware],
routes: [...],
})Or attach middleware to a specific route:
{
id: 'admin',
path: 'admin',
middleware: [requireAdminMiddleware],
loader: async ({ context }) => {
const user = context.get(USER_CONTEXT) // `user` is properly typed here
},
}defineMiddleware
Helper for authoring middleware with full type inference. Without it you would need to annotate TIn and TOut manually.
import { createContext, defineMiddleware } from 'cross-router-core'
const ORG_CONTEXT = createContext<Organization>()
const myMiddleware = defineMiddleware<
{ user: User }, // TIn — what this middleware expects
{ user: User, org: Org }
>(async ({ context }, next) => {
const org = await fetchOrg(context.user.orgId)
context.set(ORG_CONTEXT, org)
await next()
})Redirect
Throw redirect() inside any middleware, loader, or action to short-circuit navigation and send the user elsewhere.
import { redirect } from 'cross-router-core'
// In a middleware:
if (!user)
throw redirect('/login')
// With a specific HTTP status:
throw redirect('/new-path', 301)Supported status codes: 301, 302 (default), 307, 308.
Search params
Pass a valibot schema as the search field to validate and type URL search params for a route. The inferred type flows into the loader automatically.
import type { RouteDefinition } from 'cross-router-core'
import { coerce, number, object, optional, string } from 'valibot'
const searchSchema = object({
q: optional(string(), ''),
page: optional(coerce(number(), Number), 1),
})
const route: RouteDefinition<'/search', [], typeof searchSchema> = {
id: 'search',
path: 'search',
search: searchSchema,
loader: async ({ search }) => {
// search.q and search.page are fully typed
return fetchResults(search.q, search.page)
},
}Lazy routes
Use lazy to defer loading a route's component and/or loader until first navigation. This enables automatic code splitting.
{
id: 'settings',
path: 'settings',
lazy: () => import('./pages/SettingsPage').then(m => ({
component: m.default,
loader: m.loader,
})),
}The returned object can include any combination of loader, action, component, and errorComponent. Fields defined statically on the route take precedence over lazy-resolved ones.
Runtime route patching
Routes can be added and removed from a live router instance at any time. This is useful for plugin architectures where features are loaded on demand.
// Add routes under an existing parent
router.patch([
{
id: 'plugin-settings',
path: 'plugin/settings',
component: PluginSettingsPage,
}
], 'root') // 'root' is the id of the parent route
// Remove a route subtree by id
router.unpatch('plugin-settings')Route specificity is resolved automatically — a newly patched route with a specific path will always be tried before a catch-all * route, regardless of registration order.
RouterInstance API
The object returned by createRouter.
| Method | Description |
|---|---|
| state | Current NavigationState. |
| subscribe(fn) | Subscribe to state changes. Returns an unsubscribe function. |
| initialize() | Must be called once before first render. Performs the initial navigation. |
| navigate(to, options?) | Navigate to a path, adding a history entry. |
| replace(to, options?) | Navigate without adding a history entry. |
| back() | Go back one entry in the history stack. |
| forward() | Go forward one entry in the history stack. |
| submit(formData, options) | Submit a form to a route action. |
| patch(routes, parentId?) | Add routes at runtime. |
| unpatch(routeId) | Remove a route subtree at runtime. |
| destroy() | Clean up history listeners. Call when tearing down the app. |
NavigationState
interface NavigationState {
location: Location // current URL broken into parts
status: NavigationStatus // 'idle' | 'loading' | 'submitting' | 'redirecting' | 'error'
matches: RouteMatch[] // matched routes from root to leaf
loaderData: Record<string, unknown> // keyed by route id
actionData: unknown // result of the last action
error: unknown // set when status === 'error'
isTransitioning: boolean
viewTransition: boolean
}Writing a framework adapter
An adapter needs to:
- Create a router via
createRouterwith the appropriate history - Call
router.initialize()before first render - Subscribe to
router.subscribe()and pipe state into the framework's reactivity model - Render the matched route tree by reading
state.matches - Expose hooks (
useLoaderData,useParams, etc.) that read from the current state - Export a
routes()helper that tags route definitions with aRouteRendererfor cross-framework rendering
See cross-router-svelte as the reference implementation.
License
This project is under MIT license.
