#Routing
Alepha uses the $page primitive to define React routes, with support for data loading, code splitting, SSR, SSG, nested routing, and type-safe parameters.
#Setup
1import { $page } from "alepha/react/router";
Routes are defined as class properties. The class is registered with the Alepha instance in your entry files.
#Defining Pages
A complete example from a real Alepha application:
1import { z } from "alepha"; 2import { $page } from "alepha/react/router"; 3import { $client } from "alepha/server/links"; 4import type { CountApi } from "./CountApi.ts"; 5 6export class AppRouter { 7 countApi = $client<CountApi>(); 8 9 home = $page({10 head: { title: "Home" },11 schema: {12 query: z.object({13 name: z.text({ default: "Alepha" }),14 }),15 },16 loader: async ({ query }) => {17 return {18 greeting: `Hello, ${query.name} SSR!`,19 count: await this.countApi.inc().then((result) => result.count),20 };21 },22 lazy: () => import("./Home.tsx"),23 });24 25 about = $page({26 head: { title: "About" },27 path: "/about",28 lazy: () => import("./About.tsx"),29 });30}
#Page Options
#path
URL pattern with parameter support. If omitted, defaults to the root (/).
1path: "/users/:id";2path: "/blog/:slug";
#schema
Type-safe URL parameters and query strings using Zod schemas.
1schema: {2 params: z.object({ id: z.integer() }),3 query: z.object({ tab: z.text().optional() }),4}
Parameters and query values are validated and typed in the loader and component props.
#loader
Server-side data fetching function. Receives typed params, query, and parent props. The returned data is passed to the component as props. In SSR, data is serialized on the server and hydrated on the client.
1loader: async ({ params, query }) => {2 const user = await this.userApi.getUser(params.id);3 return { user };4};
#component and lazy
Provide the React component to render. Use lazy for code splitting (recommended):
1// Code splitting (recommended)2lazy: () => import("./UserProfile.tsx")3 4// Direct component5component: ({ user }) => <div>{user.name}</div>
Lazy-loaded modules must use a default export.
#head
Set document head tags (title, meta, etc.). Can be static or dynamic:
1// Static 2head: { 3 title: "About Us"; 4} 5 6// Dynamic, based on loader data 7head: (props) => ({ 8 title: props.user.name, 9 description: `Profile of ${props.user.name}`,10});
#Canonical URLs
Every page gets a <link rel="canonical">, plus og:url and twitter:url, without declaring anything. Set PUBLIC_URL and they are built from it and the page's matched route path:
PUBLIC_URL=https://example.com → <link rel="canonical" href="https://example.com/docs/routing">
It is built from the route path, not the request URL, so ?utm_source=newsletter and a trailing slash never reach the tag - collapsing those duplicates is the entire job of a canonical, and one built from location.href would certify them instead.
Nothing is emitted when there is no PUBLIC_URL to build on, for wildcard and /404 routes, or when a layer errored - in each case there is no single URL the page could honestly name, and a relative canonical resolves against whichever host served it, which is exactly the ambiguity being removed.
To point a page somewhere else - a duplicate that should defer to the original - set url yourself:
1head: {2 url: "https://example.com/docs/routing";3}
Set it on a page, never in the global $head(): there it names the same URL for the whole site, and search engines read that as every page being a duplicate of that one. Alepha logs a warning if you do.
#static
Pre-render the page at build time (SSG). On the server, acts as a cached page.
1// Simple static page 2static: true 3 4// With predefined entries 5static: { 6 entries: [ 7 { params: { slug: "hello-world" } }, 8 { params: { slug: "getting-started" } }, 9 ],10}
#ssr
Disable server-side rendering for the page component (@default true). With ssr: false the component renders client-side only (wrapped in <ClientOnly /> internally), but the loader still runs on the server - data fetching is unaffected. The value is decided at the leaf and inherited as a default by descendants: ssr: false on a parent acts as the default for its children, and a child can override with ssr: true.
1ssr: false;
#stream
Buffer the HTML instead of streaming it, so the page can choose its status code (@default true).
A page is streamed with an early <head> flush by default: the head leaves before the loader runs, which is what makes the first paint fast. The cost is that the HTTP status is committed by then, so a page whose existence depends on data cannot answer 404 - a missing product renders the error boundary with a 200, which a crawler indexes as a real page.
With stream: false the page renders to a string first and only then replies, so onServerResponse sees the finished render and can set reply.status.
1product = $page({ 2 path: "/product/:slug", 3 stream: false, 4 loader: async ({ params }) => { 5 const product = await this.api.find(params.slug); 6 if (!product) throw new NotFoundError("No such product"); 7 return { product }; 8 }, 9 onServerResponse: ({ reply }) => {10 if (/* the loader found nothing */) reply.status = 404;11 },12});
Use it for the handful of routes that can legitimately not exist - a product, an article, a profile. Leave it alone everywhere else: buffering delays the first byte by the whole render.
#use
Attach middlewares to the page - this is how you add server-side caching:
1use: [$cache({ ttl: [1, "hour"] })];
Note.
$secureon a page is a real guard. An anonymous visitor is refused at the router: with aloginroute declared, the result is a redirect to/login?redirect=<path>, and the loader's data never reaches the HTML; with no login route, the server answers 401. The two$securevariants refuse differently under the hood (the browser returns, the server throws), and the router normalises both into the redirect - which also means a page's ownerrorHandlercannot catch the refusal, because on the server the middleware chain wraps the render.Keep
$secureon the endpoints underneath as well. Defense in depth: the API answers 401 whatever the interface does.
When static: true is set, the framework automatically applies $cache({ provider: "memory", ttl: [1, "week"] }) to the page.
#can
UI-affordance predicate for the page's navigation entry - not security. Navigation surfaces (sidebar, breadcrumbs, command palette) consult it to hide or disable the entry; the router never does, and nothing returns a 403. For real access control, gate the page with use: [$secure({ permissions })], which is server-enforced.
1can: ({ has }) => has("admin"); // hide the nav entry2can: ({ has }) => has("admin") || "disabled"; // show it greyed out
#redirect
Redirect to another path when this page is matched - shorthand for throwing a Redirection in the loader. The redirect happens before any loader or component rendering.
1home = $page({2 path: "/",3 redirect: "/dashboard",4});
#nav
Navigation metadata - declares the page's presence in navigation surfaces (sidebar, breadcrumbs, command palette). A page without nav is route-only: reachable by URL but not listed. label, icon, description, and badge accept any ReactNode. Visibility is UI-only - an entry hides when nav.hidden is set, when nav.permission isn't fully granted, or when can() returns false.
1users = $page({2 path: "/users",3 nav: { label: "Users", icon: <Users /> },4 lazy: () => import("./pages/Users"),5});
#props
Default props passed to the component; props returned by the loader override them.
1props: () => ({ pageSize: 25 });
#Nested Routing
Define parent-child relationships between pages using parent on the child or children on the parent. Parent pages render child content using the <NestedView /> component.
#Which option to use
The choice is not stylistic - it depends on who owns the child page:
- You own the child (you wrote the
$pageand can edit it) → setparenton the child. The child declares its own place in the tree. - You don't own the child (it comes from another package or an injected router you can't modify) → add it to
childrenon your parent. The parent adopts pages it doesn't control.
The second case is the reason children exists. When you $inject a router from another package, its $page definitions are frozen - you can't reach in and set parent on them. children is how you mount those external pages under one of your own layouts:
1class AppRouter { 2 protected productRouter = $inject(ProductRouter); 3 4 layout = $page({ 5 path: "/app", 6 component: () => <Shell><NestedView /></Shell>, 7 children: () => [ 8 this.productRouter.catalogPage, 9 this.productRouter.checkoutPage,10 ],11 });12}
When you do own the child, prefer parent - it keeps parents free of forward references to their own descendants and reads top-down:
1import { $page } from "alepha/react/router"; 2import { NestedView } from "alepha/react/router"; 3 4class AppRouter { 5 layout = $page({ 6 path: "/app", 7 component: () => ( 8 <div> 9 <nav>Sidebar</nav>10 <main>11 <NestedView />12 </main>13 </div>14 ),15 });16 17 dashboard = $page({18 path: "/dashboard",19 parent: this.layout,20 lazy: () => import("./Dashboard.tsx"),21 });22 23 settings = $page({24 path: "/settings",25 parent: this.layout,26 lazy: () => import("./Settings.tsx"),27 });28}
⚠️ Declare each edge from one side only. If page B already has
parent: pageA, do not also list B inpageA.children. The link is already established; stating it on both sides creates a TypeScript circular dependency between the two class fields (each references the other before it is initialised).
<NestedView /> renders the matched child page. It supports an optional errorBoundary prop.
#Ready-made routers from @alepha/ui
Three routers ship whole surfaces you can mount instead of rebuilding:
| Router | Surface | Extend with |
|---|---|---|
AuthRouter |
/auth/{login,register,reset-password,verify-email} |
- (write your own to change the URLs) |
AdminRouter |
/admin - users, sessions, keys, jobs, audits, … |
$pageAdmin |
AccountRouter |
/account - profile, security, sessions, API keys, connected apps |
$pageAccount |
$pageAdmin and $pageAccount are $pageNav already parented to their shell,
so one call adds a page to the shared sidebar with no separate registration -
the shell reads each page's own nav metadata. Both follow the same rules:
take order: 100 or above (or your own nav.group) so you don't reshuffle the
built-in entries, and gate with can: () => this.someApi.someAction.can()
rather than permission alone, because a permission is self-declaring and
stays granted over an API that was never mounted.
AdminRouter stands alone at the root by design. AccountRouter goes either
way - mount it and /account is a root route, or adopt its layout into your
own shell with children, which is the children case above:
1class AppRouter {2 protected account = $inject(AccountRouter);3 4 layout = $page({5 children: () => [this.home, this.account.layout, this.notFound],6 lazy: () => import("./Layout.tsx"),7 });8}
#Error Handling
Use errorHandler to catch loader or rendering errors. Return a ReactNode for a custom error page, a Redirection to redirect, or undefined to let the error propagate to parent pages.
1import { Redirection } from "alepha/react/router"; 2 3errorHandler: (error) => { 4 if (HttpError.is(error, 404)) { 5 return <NotFound />; 6 } 7 if (HttpError.is(error, 401)) { 8 return new Redirection("/login"); 9 }10}
The same handler also covers failures thrown around the render - a use:
middleware, or a server hook such as the rate limiter or the not-ready guard
that answers while the app is still booting. Those never run a loader, so there
is no layer to fail; the router still resolves the nearest errorHandler and
renders its result as a full HTML page.
When no handler applies, the built-in error page answers: the stack overlay in development, a plain card carrying the request id in production.
#HTML or JSON
The switch is the request's Accept header, and nothing else:
Accept: text/html: what a browser sends on a hard navigation - gets a rendered document.- Anything else, including the
*/*thatfetch()defaults to, keeps the JSON error body. API clients are unaffected.
An error page is server-rendered and deliberately not hydrated: it ships no
entry script, so the client cannot boot and re-render the very URL the server
just refused. A custom error component is therefore static - hooks that read
context (useRouter, useI18n) work, event handlers do not.
#Lifecycle Callbacks
onEnter: called when the user enters the page (browser only)onLeave: called when the user leaves the page (browser only)
1onEnter: () => {2 analytics.trackPageView("/dashboard");3 window.scrollTo(0, 0);4};
onServerResponse: called before the server sends the response (server only)
#Page Animations
CSS-based enter/exit animations (experimental).
1// Simple animation name 2animation: "fadeIn" 3 4// Detailed enter/exit 5animation: { 6 enter: { name: "fadeIn", duration: 300 }, 7 exit: { name: "fadeOut", duration: 200, timing: "ease-in-out" }, 8} 9 10// Dynamic based on router state11animation: (state) => ({12 enter: "slideIn",13 exit: "slideOut",14})
Define the keyframes in your CSS:
1@keyframes fadeIn {2 from {3 opacity: 0;4 }5 to {6 opacity: 1;7 }8}
#Router Hooks
#useRouter
Access the router for navigation. Accepts a type parameter for type-safe page name references.
1import { useRouter } from "alepha/react/router"; 2 3const Nav = () => { 4 const router = useRouter<AppRouter>(); 5 6 return ( 7 <div> 8 <p>Current path: {router.pathname}</p> 9 <button onClick={() => router.push("/about")}>About</button>10 <button onClick={() => router.push("home")}>Home (by name)</button>11 <button onClick={() => router.back()}>Back</button>12 <button onClick={() => router.forward()}>Forward</button>13 <button onClick={() => router.reload()}>Reload</button>14 </div>15 );16}
Key methods and properties:
| Method/Property | Description |
|---|---|
push(path, opts) |
Navigate to a path or page name. Options: replace, params, query, force. |
back() |
Go back in history. |
forward() |
Go forward in history. |
reload() |
Reload the current page. |
isActive(href) |
Check if the given path is the current route. |
pathname |
Current pathname string. |
query |
Current query parameters as Record<string, string>. |
path(name, cfg) |
Resolve a page name to its URL path. |
anchor(path) |
Returns { href, onClick } props for anchor elements. |
setQueryParams(record) |
Update URL query parameters without navigation. |
#useActive
Determine if a route is active and get anchor props for navigation links.
1import { useActive } from "alepha/react/router"; 2 3interface NavLinkProps { 4 href: string; 5 label: string; 6} 7 8const NavLink = (props: NavLinkProps) => { 9 const { isActive, isPending, anchorProps } = useActive(props.href);10 11 return (12 <a {...anchorProps} className={isActive ? "active" : ""}>13 {isPending ? "Loading..." : props.label}14 </a>15 );16};
Accepts a string or an options object:
1const { isActive } = useActive({ href: "/docs", startWith: true });2// isActive is true for /docs, /docs/intro, /docs/api, etc.
#useQueryParams
Manage typed query parameters with a schema.
1import { useQueryParams } from "alepha/react/router"; 2import { z } from "alepha"; 3 4const SearchPage = () => { 5 const [params, setParams] = useQueryParams( 6 z.object({ 7 search: z.text().optional(), 8 page: z.integer().optional(), 9 }),10 );11 12 return (13 <input14 value={params.search ?? ""}15 onChange={(e) => setParams({ ...params, search: e.target.value })}16 />17 );18}
Options:
| Option | Type | Default | Description |
|---|---|---|---|
key |
string |
"q" |
Param name for base64 format. Ignored by querystring. |
format |
"base64" | "querystring" |
"base64" |
base64 packs the whole object into one opaque param (?q=…); querystring spreads each field as its own readable param (?search=…&page=…). |
push |
boolean |
false |
true adds a history entry (pushState) so back returns to the previous value; false replaces the current entry (replaceState). |
With format: "querystring", each schema field maps to its own URL param,
and values are coerced back to their declared types on read (e.g. a
z.integer() field reads ?page=2 as the number 2).
#Links and Anchor Interception
Plain <a href="/..."> anchors are intercepted automatically and routed
through the SPA router - no <Link> wrapper required. This works inside
React JSX as well as in raw HTML injected into the page (e.g. Markdown
content rendered from a CMS).
1<a href="/about">About</a>
The interceptor bails out (and lets the browser handle the click natively) when any of the following apply:
- the click uses a modifier key (
meta,ctrl,shift,alt) - the mouse button isn't the primary one (middle/right click)
- the anchor has
targetother than_self(e.g.target="_blank") - the anchor has a
downloadattribute - the anchor has a
data-no-routerattribute (explicit opt-out) - the
hrefuses a non-http(s) scheme (mailto:,tel:,data:, …) - the
hrefpoints to a different origin - the
hrefis hash-only (#section) - another listener already called
event.preventDefault()
To force a hard navigation on a same-origin link, opt out per-anchor:
1<a href="/legacy" data-no-router>Legacy page</a>
To disable the global interceptor, set interceptAnchorClicks: false on
the alepha.react.browser.options atom.
# component
<Link> is still available as a thin wrapper around <a> that wires the
router via onClick directly:
1import { Link } from "alepha/react/router";2 3<Link href="/about">About</Link>
With the global interceptor enabled, <Link> is mostly a stylistic
preference. Reach for it when you want explicit per-link control or
intend to extend it with prefetching/active-state logic later.
#Router Events
Route transitions emit events on the Alepha event system:
react:transition:begin: navigation started (includes previous and new state)react:transition:success: navigation completedreact:transition:error: navigation failedreact:transition:end: always emitted after transition completes