Defining Routes
Routes are the building blocks of an Effex application’s URL structure. A route connects a URL pattern to a component, optionally with typed parameters, data loading, and error handling.
Basic Routes
Create a route with Route.make and add a render function with Route.render:
import { Route } from "@effex/router";
import { $, collect } from "@effex/dom";
const HomeRoute = Route.make("/").pipe(
Route.render(() => $.h1({}, $.of("Welcome home"))),
);
Routes are built with a pipe-based combinator API. Route.make creates a bare route (with no render function), and you compose behavior onto it.
Typed Params
URL parameters like /users/:id are strings by default. Use Route.params with an Effect Schema to validate and transform them:
import { Schema } from "effect";
import { Route } from "@effex/router";
const UserRoute = Route.make("/users/:id").pipe(
Route.params(Schema.Struct({ id: Schema.NumberFromString })),
Route.render(() => UserPage()),
);
Now id is a number, not a string. If the URL contains a non-numeric ID, the schema validation fails with a ParseError.
Accessing Params in Components
Each route creates a unique context tag for its params. Access them with yield*:
const UserPage = () =>
Effect.gen(function* () {
const { id } = yield* UserRoute.params; // number
return yield* $.div({}, $.of(`User ${id}`));
});
Search Params
Query string parameters work the same way:
const SearchRoute = Route.make("/search").pipe(
Route.searchParams(
Schema.Struct({
q: Schema.String,
page: Schema.optional(Schema.NumberFromString).pipe(
Schema.withDefault(() => 1),
),
}),
),
Route.render(() => SearchPage()),
);
// In SearchPage:
const { q, page } = yield* SearchRoute.searchParams;
// q: string, page: number (defaults to 1)
Raw Params
If you don’t need schema validation, use Route.rawParams to keep the raw string dictionary:
const ProfileRoute = Route.make("/profile/:username").pipe(
Route.rawParams,
Route.render(() => ProfilePage()),
);
// In ProfilePage:
const { username } = yield* ProfileRoute.params; // string
Data Loading
Route.get adds a server-side loader and a render function that receives the loaded data:
const UserRoute = Route.make("/users/:id").pipe(
Route.params(Schema.Struct({ id: Schema.NumberFromString })),
Route.get(
({ params: { id } }) =>
Effect.gen(function* () {
const db = yield* DatabaseService;
return yield* db.getUser(id);
}),
(user) => UserPage({ user }),
),
);
The first argument is the loader — it receives { params, searchParams } and returns data. The second argument is the render function — it receives the loader’s return value directly.
The loader’s error and requirement types (E and R) flow to the platform’s HTTP router, not into the route’s component types. This keeps client-side code clean of server dependencies.
Guards
Protect routes with a reactive condition:
const DashboardRoute = Route.make("/dashboard").pipe(
Route.withGuard(isAuthenticated, { redirect: "/login" }),
Route.render(() => Dashboard()),
);
If isAuthenticated is a Readable that returns false, the user is redirected to /login. You can also provide a fallback component instead of a redirect:
Route.withGuard(isAuthenticated, {
fallback: () => $.div({}, $.of("Please log in")),
})
Animations
Add enter/exit animations to route transitions:
const ModalRoute = Route.make("/modal/:id").pipe(
Route.withAnimation({
enter: "slide-up",
exit: "slide-down",
}),
Route.render(() => ModalContent()),
);
These animations are applied by the Outlet when transitioning between routes.
Lazy Loading
Split routes into separate bundles that load on demand:
const AdminRoute = Route.lazy(
"/admin",
() => import("./admin/AdminPage.js"),
);
The dynamic import runs when the route is first matched. The imported module must have a default export that is a Route.
Error Handling
Catch errors from a route’s render function:
const UserRoute = Route.make("/users/:id").pipe(
Route.get(loader, renderUser),
Route.catchTag("NotFound", () => NotFoundPage()),
Route.catchTag("Unauthorized", () => UnauthorizedPage()),
);
// Or catch everything
const SafeRoute = Route.make("/risky").pipe(
Route.render(() => RiskyComponent()),
Route.catchAll((error) => ErrorPage({ error })),
);