Routes and navigation
Register routes, navigate between them, and read route state.
Create a router, register each URL pattern, then mount it:
import { const router: (options?: RouterOptions) => RouterBuilderrouter } from "@ilha/router";
const const Home: () => ViewHome = () => <JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>Home</JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>;
const const User: () => ViewUser = () => <JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>User</JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>;
const const NotFound: () => ViewNotFound = () => <JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>Not found</JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>;
function router(options?: RouterOptions): RouterBuilderrouter({ RouterOptions.notFound?: Component | undefinednotFound: const NotFound: () => ViewNotFound })
.RouterBuilder.route: (pattern: string, page: Page, opts?: {
layouts?: LayoutHandler[];
}) => RouterBuilder
route("/", const Home: () => ViewHome)
.RouterBuilder.route: (pattern: string, page: Page, opts?: {
layouts?: LayoutHandler[];
}) => RouterBuilder
route("/user/:id", const User: () => ViewUser)
.RouterBuilder.mount: (target: string | Element, options?: MountOptions) => () => voidmount("#app");
Match route patterns
| Pattern | Example URL | Params |
|---|---|---|
/ |
/ |
{} |
/user/:id |
/user/42 |
{ id: "42" } |
/:org/:repo |
/ilha/router |
{ org: "ilha", repo: "router" } |
/docs/**:slug |
/docs/guide/intro |
{ slug: "guide/intro" } |
/** |
any unmatched path | {} |
Static segments win over parameters, then catch-alls.
Links and navigate
Use an ordinary <a href>. A mounted SPA router intercepts same-origin clicks:
const const Navigation: () => ViewNavigation = () => (
<JSX.IntrinsicElements.nav: HTMLAttributes<HTMLElement>nav>
<JSX.IntrinsicElements.a: AnchorHTMLAttributesa href?: Attrhref="/">Home</JSX.IntrinsicElements.a: AnchorHTMLAttributesa>
<JSX.IntrinsicElements.a: AnchorHTMLAttributesa href?: Attrhref="/about">About</JSX.IntrinsicElements.a: AnchorHTMLAttributesa>
</JSX.IntrinsicElements.nav: HTMLAttributes<HTMLElement>nav>
);
Use navigate() after application logic:
import { const navigate: (to: string, opts?: NavigateOptions) => voidnavigate } from "@ilha/router";
function navigate(to: string, opts?: NavigateOptions): voidnavigate("/about");
function navigate(to: string, opts?: NavigateOptions): voidnavigate("/account", { NavigateOptions.replace?: boolean | undefinedreplace: true });
function navigate(to: string, opts?: NavigateOptions): voidnavigate("/results?page=2", { NavigateOptions.scroll?: boolean | undefinedscroll: false });
Share layouts across routes
Pass shared layouts in route() options. Matching layouts stay mounted while you move between their pages, so layout state survives navigation:
import { const router: (options?: RouterOptions) => RouterBuilderrouter } from "@ilha/router";
import type { type View = VNode | UnsafeHtml | JsonText | AtomHandle<unknown> | Stream<View, unknown, unknown> | Component | View[] | Iterable<View>View } from "ilha";
const const AppLayout: ({ children }: {
children?: View;
}) => View
AppLayout = ({ children: Viewchildren }: { children?: Viewchildren?: type View = VNode | UnsafeHtml | JsonText | AtomHandle<unknown> | Stream<View, unknown, unknown> | Component | View[] | Iterable<View>View }) => <JSX.IntrinsicElements.div: HTMLAttributes<HTMLDivElement>div>{children: Viewchildren}</JSX.IntrinsicElements.div: HTMLAttributes<HTMLDivElement>div>;
const const PageA: () => ViewPageA = () => <JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>A</JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>;
const const PageB: () => ViewPageB = () => <JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>B</JSX.IntrinsicElements.h1: HTMLAttributes<HTMLHeadingElement>h1>;
function router(options?: RouterOptions): RouterBuilderrouter()
.RouterBuilder.route: (pattern: string, page: Page, opts?: {
layouts?: LayoutHandler[];
}) => RouterBuilder
route("/a", const PageA: () => ViewPageA, { layouts?: LayoutHandler[] | undefinedlayouts: [const AppLayout: ({ children }: {
children?: View;
}) => View
AppLayout] })
.RouterBuilder.route: (pattern: string, page: Page, opts?: {
layouts?: LayoutHandler[];
}) => RouterBuilder
route("/b", const PageB: () => ViewPageB, { layouts?: LayoutHandler[] | undefinedlayouts: [const AppLayout: ({ children }: {
children?: View;
}) => View
AppLayout] })
.RouterBuilder.mount: (target: string | Element, options?: MountOptions) => () => voidmount("#app");
Search- or hash-only changes re-render the same page without remounting it. Routes with different layouts remount as before.
Read the current route
import { const useRoute: () => {
hash: typeof routeHash;
navigating: typeof navigating;
params: typeof routeParams;
path: typeof routePath;
search: typeof routeSearch;
}
useRoute } from "@ilha/router";
const const UserPage: () => ViewUserPage = () => {
const { const path: () => stringpath, const params: () => Record<string, string>params, const search: () => stringsearch } = function useRoute(): {
hash: typeof routeHash;
navigating: typeof navigating;
params: typeof routeParams;
path: typeof routePath;
search: typeof routeSearch;
}
useRoute();
return (
<JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>
{const path: () => stringpath()} {const params: () => Record<string, string>params().stringid} {const search: () => stringsearch()}
</JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>
);
};
isActive(href) is true when the current path matches.
Keep UI state in the query string
You bind one query param with searchParam(). Writes replace the history entry, and the page re-renders in place:
import { const searchParam: SearchParamFnRead and write one query param through the router. Writes `replace` the
history entry without scrolling, so the mounted router re-renders the
current route in place instead of remounting it.searchParam } from "@ilha/router";
const const Tabs: () => ViewTabs = () => {
const const tab: SearchParam<string>tab = function searchParam(name: string, opts: SearchParamOptions<string>): SearchParam<string> (+1 overload)String param: `parse` is optional.searchParam("t", { SearchParamOptions<string>.default: stringValue when the param is absent. Writing it removes the param.default: "overview" });
return (
<JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton type?: string | undefinedtype="button" EventProps<HTMLButtonElement>.onclick?: ((event: Targeted<HTMLButtonElement, MouseEvent>) => void) | undefinedonclick={() => const tab: SearchParam<string>tab.SearchParam<string>.set: (next: string) => voidset("logs")}>
{const tab: SearchParam
() => string
tab()}
</JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton>
);
};
Writing the default removes the param. Pass parse and serialize for non-string values, such as { default: 1, parse: Number }.
Head
import { head } from "@ilha/router";
export default function About() {
head({ title: "About" });
return <h1>About</h1>;
}
Layouts can set titleTemplate. On the client, head() updates document after mount and on navigation. See the router reference.
Fetch first, then describe the page. The head window stays live for the whole mount, so a call after await still updates document — never assign document.title by hand:
import { head } from "@ilha/router";
export default async function Post() {
const post = await fetchPost();
head({ title: post.title });
return <h1>{post.title}</h1>;
}
Skip interception
The mounted router intercepts same-origin <a> clicks. You opt a link out with data-no-intercept:
const LegacyLink = () => (
<a href="/legacy/report" data-no-intercept>
Full-page report
</a>
);
Observe navigation
| Export | Meaning |
|---|---|
beforeNavigate(fn) |
Hook before every navigation; call cancel() to stop it. Returns an unsubscribe function |
afterNavigate(fn) |
Hook after every navigation. Returns an unsubscribe function |
navigating() |
True while a navigation is in flight |
routeHash() |
Current hash, including the leading # |
prime() |
Sync route state from the address bar without mounting |
useContext() |
{ request } for the current render, if the server scoped one |
There are no loaders and no route guards. Fetch inside an async page; confirm or cancel through beforeNavigate().