So you want URLs that actually do things. Wild concept.
Alepha's router (@alepha/react/router) gives you SSR, type-safe navigation, nested routes, data loading, and error handling. All without a single pages/ folder or magic filename convention.
You define routes as class properties. TypeScript knows about them. Your IDE autocompletes them. You sleep better at night.
Note: The router depends on
@alepha/react(core). If you only need hooks likeuseClientoruseStorewithout routing, see React Integration.
$page PrimitiveIn Next.js, you create files. In Remix, you create more files. In Alepha, you define pages as class properties with $page.
1import { $page } from "@alepha/react/router"; 2import { t } from "alepha"; 3 4class AppRouter { 5 home = $page({ 6 path: "/", 7 component: () => <div>Welcome!</div> 8 }); 9 10 about = $page({11 path: "/about",12 lazy: () => import("./pages/About.tsx")13 });14}
That's it. Two routes. Type-safe. Ready for SSR.
Because then TypeScript knows your routes exist. You can router.go("home") and get autocomplete. You can generate paths with router.path("userProfile", { params: { id: "123" } }) and the compiler yells if you misspell it.
File-based routing gives you magic strings. Class-based routing gives you types.
The loader function runs before your component renders. On the server during SSR, on the client during navigation. Same code, both places.
1class AppRouter { 2 dashboard = $page({ 3 path: "/dashboard", 4 loader: async () => { 5 const stats = await api.getStats(); 6 return { stats }; 7 }, 8 component: ({ stats }) => <Dashboard stats={stats} /> 9 });10}
The return value becomes your component props. Automatically typed. No useEffect + useState dance on the client. No "loading..." flash.
Got dynamic routes? Declare them in the schema:
1userProfile = $page({ 2 path: "/users/:id", 3 schema: { 4 params: t.object({ id: t.integer() }) 5 }, 6 loader: async ({ params }) => { 7 // params.id is number, validated 8 const user = await api.getUser(params.id); 9 return { user };10 },11 component: ({ user }) => <UserCard user={user} />12});
Without the schema, params is unknown. With it, you get full type safety and validation. The framework validates the parameter before your loader even runs.
Same deal for query strings:
1search = $page({ 2 path: "/search", 3 schema: { 4 query: t.object({ 5 q: t.text(), 6 page: t.optional(t.integer()), 7 sort: t.optional(t.enum(["date", "relevance"])) 8 }) 9 },10 loader: async ({ query }) => {11 // query.q is string12 // query.page is number | undefined13 // query.sort is "date" | "relevance" | undefined14 return await api.search(query);15 },16 component: SearchResults17});
Nested routes can access parent props:
1class AppRouter { 2 project = $page({ 3 path: "/p/:projectId", 4 schema: { params: t.object({ projectId: t.integer() }) }, 5 children: () => [this.projectBoard, this.projectSettings], 6 loader: async ({ params }) => { 7 const project = await api.getProject(params.projectId); 8 return { project }; 9 },10 lazy: () => import("./ProjectLayout.tsx")11 });12 13 projectBoard = $page({14 path: "/", // Relative to parent: /p/:projectId/15 loader: async ({ project }) => {16 // project comes from parent loader17 const tasks = await api.getTasks(project.id);18 return { tasks };19 },20 component: ({ project, tasks }) => <Board project={project} tasks={tasks} />21 });22 23 projectSettings = $page({24 path: "/settings", // /p/:projectId/settings25 component: ({ project }) => <Settings project={project} />26 });27}
Child loaders receive parent props. No prop drilling. No context gymnastics.
Use lazy for code splitting. It's 2026 (or later). Your users don't need every component on first load:
1projectCreate = $page({2 path: "/p-new",3 lazy: () => import("./components/ProjectCreate.tsx")4});
The component loads when the route matches. Vite handles the bundling. You handle the coffee.
You can also use component for small, always-needed pages:
1notFound = $page({2 path: "/*",3 component: NotFound // Always bundled4});
Real apps have layouts. A sidebar that persists across pages. A header that knows which project you're in.
1class AppRouter { 2 api = $client<ProjectController>(); 3 alepha = $inject(Alepha); 4 5 layout = $page({ 6 children: () => [ 7 this.home, 8 this.project, 9 this.settings,10 this.notFound11 ],12 lazy: () => import("./Layout.tsx"),13 loader: async ({ user }) => {14 if (user) {15 // Load user's projects for sidebar16 this.alepha.set(userProjectsAtom, await api.getMyProjects());17 }18 }19 });20 21 home = $page({22 path: "/",23 lazy: () => import("./Home.tsx")24 });25 26 project = $page({27 path: "/p/:projectId",28 children: () => [29 this.projectBoard,30 this.projectSettings31 ],32 // ...33 });34}
The Layout component renders, then the matched child renders inside it. Layouts persist during navigation between children. No re-mount, no flicker.
In your layout, use NestedView to render children:
1// Layout.tsx 2import { NestedView } from "@alepha/react/router"; 3 4export default function Layout({ children }) { 5 return ( 6 <div className="app"> 7 <Sidebar /> 8 <main> 9 <NestedView /> {/* Child page renders here */}10 </main>11 </div>12 );13}
useRouter HookNavigate programmatically with full type safety:
1import { useRouter } from "@alepha/react/router"; 2 3const Navigation = () => { 4 const router = useRouter<AppRouter>(); 5 6 return ( 7 <div> 8 {/* By page name */} 9 <button onClick={() => router.go("home")}>Home</button>10 11 {/* With params */}12 <button onClick={() => router.go("userProfile", { params: { id: 123 } })}>13 View User14 </button>15 16 {/* With query */}17 <button onClick={() => router.go("search", { query: { q: "test" } })}>18 Search19 </button>20 21 {/* History */}22 <button onClick={() => router.back()}>Back</button>23 <button onClick={() => router.forward()}>Forward</button>24 25 {/* Reload current page */}26 <button onClick={() => router.reload()}>Refresh</button>27 </div>28 );29};
Need a URL string? Use router.path():
1const UserNav = () => { 2 const router = useRouter<AppRouter>(); 3 4 const profileUrl = router.path("userProfile", { params: { id: 123 } }); 5 // "/users/123" 6 7 const searchUrl = router.path("search", { query: { q: "test", page: 2 } }); 8 // "/search?q=test&page=2" 9 10 return (11 <nav>12 <a href={profileUrl}>Profile</a>13 <a href={searchUrl}>Search</a>14 </nav>15 );16};
Get both href and onClick for client-side navigation:
1const NavLink = ({ page, children }) => { 2 const router = useRouter<AppRouter>(); 3 4 return ( 5 <a {...router.anchor(page)}> 6 {children} 7 </a> 8 ); 9};10 11// Usage12<NavLink page="home">Home</NavLink>13<NavLink page="dashboard">Dashboard</NavLink>
The onClick handler prevents default, navigates client-side. The href is there for SEO, right-click "open in new tab", and accessibility.
Build navigation that knows where you are:
1import { useActive } from "@alepha/react/router"; 2 3const NavLink = ({ href, children }) => { 4 const { isActive, isPending, anchorProps } = useActive(href); 5 6 return ( 7 <a 8 {...anchorProps} 9 className={isActive ? "active" : isPending ? "loading" : ""}10 >11 {children}12 </a>13 );14};
For nested routes, use startWith:
1const SidebarLink = ({ href, children }) => { 2 const { isActive, anchorProps } = useActive({ href, startWith: true }); 3 4 // isActive is true for /users, /users/123, /users/settings... 5 return ( 6 <a {...anchorProps} className={isActive ? "active" : ""}> 7 {children} 8 </a> 9 );10};
Things go wrong. APIs fail. Users bookmark pages that no longer exist. Handle it gracefully:
1projectTask = $page({ 2 path: "/q/:taskId", 3 schema: { params: t.object({ taskId: t.integer() }) }, 4 loader: async ({ params }) => { 5 const task = await api.getTask(params.taskId); 6 return { task }; 7 }, 8 errorHandler: (error) => { 9 if (HttpError.is(error, 404)) {10 return <NotFound style={{ height: "100%" }} />;11 }12 // Return undefined to let error propagate to parent13 },14 lazy: () => import("./TaskView.tsx")15});
Handle authentication failures:
1layout = $page({2 children: () => [/* ... */],3 errorHandler: (error, state) => {4 if (HttpError.is(error, 401) && state.url.pathname !== "/login") {5 return new Redirection(`/login?r=${state.url.pathname}`);6 }7 },8 lazy: () => import("./Layout.tsx")9});
If a child page doesn't handle an error, it bubbles up to the parent. Define a catch-all error handler in your root layout:
1import { createElement } from "react"; 2import ErrorPage from "./ErrorPage.tsx"; 3 4layout = $page({ 5 children: () => [/* ... */], 6 errorHandler: (error, state) => { 7 // Only show generic error page in production 8 if (!this.alepha.isProduction()) { 9 return; // Let Alepha show the dev error overlay10 }11 12 return createElement(ErrorPage, { error });13 }14});
Clean up when users navigate away:
1project = $page({ 2 path: "/p/:projectId", 3 loader: async ({ params }) => { 4 const project = await api.getProject(params.projectId); 5 this.alepha.set(currentProjectAtom, project); 6 return { project }; 7 }, 8 onLeave: () => { 9 // Clear atoms when leaving10 this.alepha.set(currentProjectAtom, undefined);11 }12});
This runs in the browser only, when the user navigates to a different route.
Pre-render pages at build time:
1blogPost = $page({ 2 path: "/blog/:slug", 3 static: { 4 entries: posts.map(p => ({ params: { slug: p.slug } })) 5 }, 6 loader: async ({ params }) => { 7 const post = await loadPost(params.slug); 8 return { post }; 9 }10});
Or just mark a page as static (with server-side caching):
1termsOfService = $page({2 path: "/terms",3 static: true,4 component: TermsOfService5});
Access the current router state anywhere:
1import { useRouterState } from "@alepha/react/router"; 2 3const Debug = () => { 4 const state = useRouterState(); 5 6 return ( 7 <pre> 8 URL: {state.url.pathname} 9 Params: {JSON.stringify(state.params)}10 User: {state.user?.name}11 </pre>12 );13};
Or via the router:
1const router = useRouter<AppRouter>();2console.log(router.pathname); // "/users/123"3console.log(router.query); // { tab: "settings" }4console.log(router.state); // Full state object
Some pages need browser APIs. Force client-side rendering:
1canvasEditor = $page({ 2 path: "/editor", 3 ssr: false, // Renders loading state during SSR 4 lazy: () => import("./CanvasEditor.tsx") 5}); 6 7// Or with custom fallback 8client: { 9 fallback: <div>Loading editor...</div>10}
When you use $page in your module's primitives, the router module loads automatically:
1import { $module } from "alepha";2import { $page } from "@alepha/react/router";3 4export const MyAppModule = $module({5 name: "my-app",6 primitives: [$page], // Router auto-loads7});
No manual .with(AlephaReactRouter) needed.
1// Define a page 2$page({ 3 path: "/users/:id", 4 schema: { 5 params: t.object({ id: t.integer() }), 6 query: t.object({ tab: t.optional(t.text()) }) 7 }, 8 loader: async ({ params, query, user }) => ({ ... }), 9 component: MyComponent, // or...10 lazy: () => import("./Page"), // Code splitting11 children: () => [childPage], // Nested routes12 head: (props) => ({ title: props.user.name }),13 errorHandler: (error, state) => <Error />,14 onLeave: () => cleanup(),15 animation: "fadeIn",16 static: true,17 cache: { store: { ttl: [1, "hour"] } },18 client: true,19});20 21// Navigation22const router = useRouter<AppRouter>();23router.go("home");24router.go("userProfile", { params: { id: 123 } });25router.back();26router.forward();27router.reload();28 29// Paths30router.path("home"); // "/"31router.path("search", { query: { q: "test" }}); // "/search?q=test"32 33// Links34<a {...router.anchor("home")}>Home</a>35 36// Active state37const { isActive, isPending, anchorProps } = useActive("/users");38const { isActive } = useActive({ href: "/users", startWith: true });39 40// Query params41router.query; // { page: "2", sort: "name" }42router.setQueryParams({ page: 3 });43 44// State45router.pathname; // "/users/123"46router.state; // Full router state
Next up: State Management | Head & SEO