alepha@docs:~/docs/guides/frontend$
cat 2-routing.md
4 min read
Last commit:

#Routing

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 like useClient or useStore without routing, see React Integration.


#The $page Primitive

In Next.js, you create files. In Remix, you create more files. In Alepha, you define pages as class properties with $page.

tsx
 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.

#Why Class Properties?

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.


#Loading Data

The loader function runs before your component renders. On the server during SSR, on the client during navigation. Same code, both places.

tsx
 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.

#With URL Parameters

Got dynamic routes? Declare them in the schema:

tsx
 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.

#Query Parameters

Same deal for query strings:

tsx
 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});

#Accessing Parent Data

Nested routes can access parent props:

tsx
 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.


#Lazy Loading

Use lazy for code splitting. It's 2026 (or later). Your users don't need every component on first load:

tsx
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:

tsx
1notFound = $page({2  path: "/*",3  component: NotFound  // Always bundled4});

#Nested Routes

Real apps have layouts. A sidebar that persists across pages. A header that knows which project you're in.

tsx
 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.

#NestedView Component

In your layout, use NestedView to render children:

tsx
 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}

#Navigation

#The useRouter Hook

Navigate programmatically with full type safety:

tsx
 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};

#Generating Paths

Need a URL string? Use router.path():

tsx
 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};

#Anchor Props

Get both href and onClick for client-side navigation:

tsx
 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.


#Active States

Build navigation that knows where you are:

tsx
 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};

#Prefix Matching

For nested routes, use startWith:

tsx
 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};

#Error Handling

Things go wrong. APIs fail. Users bookmark pages that no longer exist. Handle it gracefully:

tsx
 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});

#Redirects on Error

Handle authentication failures:

tsx
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});

#Error Bubbling

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:

tsx
 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});

#Lifecycle

#onLeave

Clean up when users navigate away:

tsx
 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.

#Static Pages & Caching

Pre-render pages at build time:

tsx
 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):

tsx
1termsOfService = $page({2  path: "/terms",3  static: true,4  component: TermsOfService5});

#Router State

Access the current router state anywhere:

tsx
 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:

tsx
1const router = useRouter<AppRouter>();2console.log(router.pathname);  // "/users/123"3console.log(router.query);     // { tab: "settings" }4console.log(router.state);     // Full state object

#Client-Only Pages

Some pages need browser APIs. Force client-side rendering:

tsx
 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}

#Auto-Loading

When you use $page in your module's primitives, the router module loads automatically:

typescript
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.


#Quick Reference

typescript
 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

On This Page
No headings found...
ready
mainTypeScript
UTF-8guides_frontend_routing.md