Routes & Layouts
Anchor decouples the structure of your application from its UI implementation. Routes are defined mathematically as pure TypeScript objects, forming an abstract tree independent of the view framework.
What it solves:
- Massive Central Routers: Huge global component files filled with hundreds of
<Route>markup tags that cause team merge conflicts. - Node Incompatibility: The inability to parse, test, or evaluate application routing paths outside of a browser environment.
Defining Routes
Start with a router instance, then chain .route() calls to create the tree structure.
// lib/router.ts
import { createRouter } from '@anchorlib/react';
import type { ReactNode } from 'react';
export const router = createRouter<ReactNode>();// lib/router.ts
import { createRouter } from '@anchorlib/solid';
import type { JSX } from 'solid-js';
export const router = createRouter<JSX.Element>();The routing structure itself is completely framework-agnostic:
// routes/route.ts
import { router } from '../lib/router.js';
export const rootRoute = router.route(); // Getting the root route object.// routes/users/route.ts
import { rootRoute } from '../route.js';
export const usersRoute = rootRoute.route('/users');// routes/users/[user_id]/route.ts
import { usersRoute } from '../route.js';
export const profileRoute = usersRoute.route('/:user_id');This structural chaining produces a strict tree:
/ → rootRoute
├── /users → usersRoute
│ ├── / → usersRoute.route('/') (index)
│ └── /:user_id → profileRouteBy defining routes as objects rather than framework components, Anchor forces a scalable architecture. The route files express what your application is, without importing a single kilobyte of view logic.
What it solves:
- String Typos: Manually typing broken string paths across large applications, instead of utilizing programmatic tree navigation.
Subdirectory Hosting
Because Anchor avoids string-based navigation in your components, changing your app's base URL is trivial. If you want to host your app in a subdirectory (like https://example.com/app), you don't need to change a single <Link> or navigate() call across your entire codebase.
You simply change the path of your root route:
// routes/route.ts
import { router } from '../lib/router.js';
export const rootRoute = router.route('/app');Because all other routes chain off rootRoute, the entire routing tree instantly rebases itself to /app!
Decoupled Rendering
Once a route exists in the abstract tree, you attach the actual UI using .render() and wrap the result back to the framework using page().
// routes/users/layout.tsx
import { page } from '@anchorlib/react';
import { usersRoute } from './route.js';
export const UsersLayout = page(usersRoute).render(({ children }) => (
<div>
<header>Users</header>
{children}
</div>
));
export default UsersLayout;// routes/users/layout.tsx
import { page } from '@anchorlib/solid';
import { usersRoute } from './route.js';
export const UsersLayout = page(usersRoute).render(({ children }) => (
<div>
<header>Users</header>
{children}
</div>
));
export default UsersLayout;Render Callback Arguments
| Argument | Type | Description |
|---|---|---|
state | Route state | The reactive state for this specific route segment. |
context | Context | The shared RouterContext across the entire active route tree. |
children | Node | The child route's rendered output (layout routes only). |
What it solves:
- Circular Dependencies: UI components attempting to import the router, while the router simultaneously tries to import the UI components.
- Bundle Bloat: Forcing an application to eagerly evaluate or import every single View module just to construct the initial routing paths.
Fine-Grained Routing
Anchor routing is decentralized. In traditional applications, navigating triggers a global context change at the top-level <Router>, forcing an expensive view diff across the entire app.
Anchor bypasses this cascade. By driving route states with native observables, Anchor achieves fine-grained routing.
When navigation occurs, the router computes the exact structural node that changed. Instead of re-rendering from the top down, only the specific component observing that precise state evaluates.
- Parameter Mutations: If a user remains on a route but the URL parameter mutates (
/users/1to/users/2), the view does not unmount. The route mutates the observablestate, and only the specific DOM elements bound to that state update in place. - Node Swapping: If a user swaps branches (
/profileto/settings), unchanged parent nodes are ignored by the render cycle. Anchor replaces only the exact leaf component at the point of intersection.
What it solves:
- Global Re-renders: Wasting CPU cycles re-evaluating the entire component tree when only a single parameter or leaf node actually changed.
Surgical Layouts
In Anchor, every route inherently acts as a layout boundary. The .render() callback provides a children slot, which serves as the dedicated projection area for any nested descendant routes.
This allows you to construct UI structures through the route topology without having to pass component props down a monolithic tree.
Layout routes (with children)
When building a parent route, use the children parameter to compose a persistent UI shell around your nested views:
.render(({ state, context, children }) => (
<div>
<nav>Sidebar</nav>
<main>{children}</main>
</div>
))Leaf routes (no children)
Conversely, leaf routes sit at the absolute end of a path. Because they have no further descendants to project, they operate on their own state without needing to render the slot:
.render(({ state, context }) => (
<div>
<h1>User Profile</h1>
<p>{context.params.user_id}</p>
</div>
))What it solves:
- Heavy DOM Destruction: Accidentally unmounting expensive parent shells (WebSockets, Video Players, interactive maps) during deep child navigation.
Index Routes
Calling .route('/') on a parent creates an index route—the default view injected into the children slot when the parent path matches without traversing deeper.
// routes/users/route.ts
export const usersIndexRoute = usersRoute
.route('/')
.provide('users', () => [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);// routes/users/page.tsx
import { page, Link, For } from '@anchorlib/react';
import { usersIndexRoute } from './route.js';
import { ProfilePage } from './[user_id]/page.js';
export const UsersPage = page(usersIndexRoute).render(({ state }) => (
<ul>
<For each={() => state.data?.users}>
{(user) => (
<li>
<Link to={ProfilePage} params={{ user_id: user.id }}>
{user.name}
</Link>
</li>
)}
</For>
</ul>
));// routes/users/page.tsx
import { page, Link, For } from '@anchorlib/solid';
import { usersIndexRoute } from './route.js';
import { ProfilePage } from './[user_id]/page.js';
export const UsersPage = page(usersIndexRoute).render(({ state }) => (
<ul>
<For each={state.data?.users}>
{(user) => (
<li>
<Link to={ProfilePage} params={{ user_id: user.id }}>
{user.name}
</Link>
</li>
)}
</For>
</ul>
));When the URL is /users, the layout renders with UsersPage sitting in its {children} slot. When the user navigates to /users/42, the parent layout stays where it is, UsersPage unmounts, and the Profile leaf route replaces it.
Modal Routes
Sometimes you want a route to render as an overlay floating on top of the current page, rather than swapping out the page content. Anchor provides a modal() factory for this.
// routes/users/invite/route.ts
import { usersRoute } from '../route.js';
export const userInviteRoute = usersRoute.route('/invite');// routes/users/invite/page.tsx
import { modal, render } from '@anchorlib/react';
import { userInviteRoute } from './route.js';
export const UserInvitePage = modal(userInviteRoute).render(() => (
<dialog open>
<h1>Invite User</h1>
</dialog>
));// routes/users/invite/page.tsx
import { modal } from '@anchorlib/solid';
import { userInviteRoute } from './route.js';
export const UserInvitePage = modal(userInviteRoute).render(() => (
<dialog open>
<h1>Invite User</h1>
</dialog>
));When navigating to /users/invite, the main page remains mounted as it was. The UserInvitePage renders globally in a separate, top-level reactive stack managed by <UIRouter>, placing it above the rest of the application tree. Everything else (guards, providers, reactivity) functions like a standard page() route. Shareable links and browser back navigation are handled out of the box.
Route State
The state object passed to .render() is reactive. It triggers surgical updates to the bound component if its internal values change.
| Property | Type | Description |
|---|---|---|
state.active | boolean | Whether this route is currently matched |
state.status | 'idle' | 'pending' | 'success' | 'error' | Current lifecycle status |
state.data | object | Resolved provider data |
state.error | RouteError | undefined | Error from guard or provider failure |
state.resolving | boolean | Whether providers are currently running |
state.authenticating | boolean | Whether guards are currently running |
state vs context
When calling .render(({ state, context }) => ...), it is crucial to understand the difference between the two reactive objects:
state(Route-Scoped): Represents the specific state of this exact route segment.state.dataonly contains data resolved by providers attached directly to this specific route.context(Tree-Scoped): Represents the globalRouterContextshared across the entire active route tree.context.datacontains the merged result of all providers across all active parent and child routes, andcontext.paramscontains all merged URL parameters.
Use state when you only care about the lifecycle and data of the current component. Use context when you need to read URL parameters or access data provided by a parent layout.
You can leverage state.status to render loading layouts while preserving the layout shell:
.render(({ state, context }) => {
return render(() => {
if (state.status === 'pending') return <div>Loading Profile...</div>;
return <h1>{context?.data?.profile?.name}</h1>;
});
}).render(({ state, context }) => {
return (
<Show
when={state.status !== 'pending'}
fallback={<div>Loading Profile...</div>}
>
<h1>{context?.data?.profile?.name}</h1>
</Show>
);
})Router Options
Because RouterOptions extends RouteOptions, you can set global defaults for all routes and providers when creating the router instance.
// lib/router.ts
import { createRouter } from '@anchorlib/react';
export const router = createRouter({
// Router-specific options
renderMode: 'immediate',
baseUrl: 'https://example.com', // Base URL for resolving relative paths
cacheSize: 100,
// Global route defaults
keepAlive: true,
preloadMode: 'hover',
maxAge: 60000,
});Render Modes
The renderMode option controls how the router handles the UI transition while data is being fetched.
'deferred'(Default): The route blocks the navigation and waits for the entire provider tree to resolve before rendering. This prevents UI flashes and avoids showing partial layouts. The previous page remains visible until the new route is fully ready.'immediate': The route activates immediately, unmounting the previous page and rendering the new layout shell instantly. Providers execute in the background, allowing you to show specific loading indicators and stream data in as it resolves using<Show when={() => state.status === 'pending'}>.
WARNING
Server-Side Rendering (SSR) Compatibility
The 'immediate' render mode is generally incompatible with SSR. During SSR, the server must wait for the entire provider tree to resolve before rendering to ensure a complete HTML response:
// Server always blocks
await router.activate();
const body = renderToString();To ensure hydration matches the server's fully-resolved output, the client must also block:
// Client hydration blocks to match server
await router.activate();
hydrate();If you want true immediate behavior (where the client immediately mounts a loading state and fetches data asynchronously), you must opt out of SSR and use Client-Side Rendering (CSR) via createRoot() instead of hydrate().
Mixed SSR and CSR
If you need to use SSR but still want deferred data loading (CSR) for specific routes, move the fetch logic out of the route's .provide() and into the component itself using an observable .with() binding. This allows the server to instantly render the pending state, which the client hydrates and then fetches:
const ProfilePage = page(profileRoute).render(({ context }) => {
// Data loading is bound to the component, not the route!
const state = getProfile.with(() => [context.params.user_id]);
return (
<Show when={() => state.status === 'pending'}>
<div>Loading profile...</div>
</Show>
);
});Route Options
You can enforce specific behaviors by passing options directly into .route(). Route options override the global router defaults for that specific route block.
const profile = usersRoute.route('/:user_id', {
keepAlive: true, // Preserve state when navigating away
preloadMode: 'hover', // Preload when a Link to this route is hovered
maxAge: 60000, // Cache provider data for 60 seconds
maxRetries: 3, // Retry failed providers up to 3 times
retryDelay: 1000, // Wait 1s between retries
});Scalable File Structure
By treating route declarations and view injections as two separate stages, you ensure architectural scalability and eliminate circular dependencies.
src/
├── lib/
│ └── router.ts ← createRouter()
├── routes/
│ ├── route.ts ← rootRoute = router.route('/')
│ ├── layout.tsx ← RootLayout (layout shell)
│ ├── page.tsx ← HomePage (leaf)
│ ├── users/
│ │ ├── route.ts ← usersRoute = rootRoute.route('/users')
│ │ ├── layout.tsx ← UsersLayout (layout)
│ │ ├── page.tsx ← UsersPage (exact match at /users)
│ │ └── [user_id]/
│ │ ├── route.ts ← profileRoute = usersRoute.route('/:user_id')
│ │ └── page.tsx ← ProfilePage (leaf)
│ └── settings/
│ ├── route.ts ← settingsRoute = rootRoute.route('/settings')
│ └── page.tsx ← SettingsPage (leaf)The route definition file (route.ts) acts as the pure schema. The component file (layout.tsx or page.tsx) imports that schema, chains the UI via .render(), and exports the sealed page().
Independent Top-Level Routes
Use router.append() to create top-level routes that exist outside the root route tree. This is useful for routes that need their own layout (e.g., authentication pages that don't share the main application shell):
// routes/auth/route.ts
import { router } from '../../lib/router.js';
export const authRoute = router.append('/auth');Routes created with .append() are matched independently. They do not inherit the root route's layout, guards, or providers — they are a separate route tree.
// routes/auth/signin/route.ts
import { authRoute } from '../route.js';
export const signinRoute = authRoute.route('/signin');Index Redirect
An index route on an independent tree can redirect to a specific child:
import { redirect } from '@anchorlib/react';
import { SignInPage } from './signin/index.js';
authRoute.route('/').guard(() => {
throw redirect(SignInPage);
});import { redirect } from '@anchorlib/solid';
import { SignInPage } from './signin/index.js';
authRoute.route('/').guard(() => {
throw redirect(SignInPage);
});Error Boundaries
Global Error Boundary
Use router.catch() to define a fallback renderer for unmatched routes or unhandled errors:
import { router } from '../lib/router.js';
router.catch(() => {
return (
<div className="error-page">
<h1>404</h1>
<p>Page not found</p>
</div>
);
});import { router } from '../lib/router.js';
router.catch(() => {
return (
<div class="error-page">
<h1>404</h1>
<p>Page not found</p>
</div>
);
});Per-Route Error Boundary
Use .catch() on individual routes to define route-specific error renderers:
usersRoute.catch((error) => {
return (
<div className="error">
<h2>Something went wrong</h2>
<p>{error.message}</p>
</div>
);
});usersRoute.catch((error) => {
return (
<div class="error">
<h2>Something went wrong</h2>
<p>{error.message}</p>
</div>
);
});If a route defines a .catch() handler, errors during that route's activation or rendering are caught and rendered by the handler instead of propagating to the global boundary.