Routing
Elur Kit uses file-based routing. Files in src/app/ automatically become
routes based on their path. For data loading, API routes, actions,
middleware, and server-side patterns, see
Data & Backend.
# File conventions
| File | Purpose |
|---|---|
page.ts |
Page component for the route |
page.data.ts |
Data loader (runs on server) |
page.action.ts |
Server actions |
layout.ts |
Layout wrapping child pages |
layout.data.ts |
Layout data loader |
loading.ts |
Streaming fallback during load |
route.ts |
API endpoint (no HTML) |
404.page.ts |
Custom 404 page |
500.page.ts |
Custom 500 page |
middleware.ts |
Root middleware |
*.slot.ts |
Named layout slot |
See Data & Backend for details on loaders, actions, API routes, middleware, and error pages.
# Pages
Each page.ts exports a default component:
import { html } from "@elurjs/core";
import type { PageProps } from "@elurjs/kit";
export default function AboutPage({ data }: PageProps<unknown>) {
return html`<h1>About</h1>`;
}# Layouts
Layouts wrap pages and receive children:
import { html } from "@elurjs/core";
import type { LayoutProps } from "@elurjs/kit";
export default function RootLayout({ children }: LayoutProps) {
return html`
<html>
<body>
<nav>...</nav>
${children}
</body>
</html>
`;
}Nested layouts stack — a blog/layout.ts wraps all pages under /blog/.
# Dynamic routes
Use [param] for dynamic segments:
src/app/blog/[slug]/page.ts → /blog/:slug
src/app/users/[id]/page.ts → /users/:idDynamic routes require generateStaticParams for SSG:
// src/app/blog/[slug]/page.ts
import type { GenerateStaticParams } from "@elurjs/kit";
export const generateStaticParams: GenerateStaticParams = async () => {
return [{ slug: "hello-world" }, { slug: "elur-kit" }];
};# Catch-all routes
Use [...slug] for catch-all segments:
src/app/docs/[...slug]/page.ts → /docs/*# Optional catch-all
Optional catch-all uses [[...slug]] — matches the parent path too:
src/app/blog/[[...slug]]/page.ts → /blog, /blog/a, /blog/a/b# Route groups
Route groups (name) are URL-invisible. They only affect layout nesting,
not the path:
src/app/(marketing)/pricing/page.ts → /pricing
src/app/(marketing)/layout.ts → wraps /pricing but not in URL# Layout slots
Named slot files (*.slot.ts) in the same directory as a page are discovered
by the route scanner and made available to the layout:
src/app/blog/
├── layout.ts # wraps /blog/*
├── [slug]/page.ts # → /blog/:slug
├── [slug]/sidebar.slot.ts # named slot "sidebar"
└── [slug]/comments.slot.ts # named slot "comments"# SPA router
The client router intercepts internal navigation and swaps the rendered content without a full page load:
- Links are prefetched on
pointerenter,focus, andpointerdown(tap), plus opt-in viewport prefetch viadata-prefetch="viewport" - Prefetched pages cache for 30 seconds in a bounded LRU (32 entries)
- Prefetching is network-aware — it skips on
Save-DataandeffectiveType2g/slow-2gunless you passdata-prefetch="always" - Add
data-no-prefetchto any link to opt out - View Transitions API used when supported (disabled with
prefers-reduced-motion) - Add
data-no-routerto any link to opt out of client-side navigation data-elur-persist="key"keeps live DOM nodes (islands, media, canvas) across navigations- Lifecycle events:
elur:navigate-start,elur:navigate-end,elur:navigate-error,elur:before-render,elur:rendered
See Client router for the full reference — options, events, persistence, morphing, and per-page JavaScript.
# Programmatic navigation
import { navigateTo, prefetch } from "@elurjs/kit/router";
// Navigate to a new page (pushes to history by default)
await navigateTo("/blog/hello-world");
// Back/forward style navigation (replaces, marks as popstate)
await navigateTo("/login", "", false);
// Prefetch a page without navigating
await prefetch("/blog/hello-world");
// Force prefetch even on constrained networks
await prefetch("/blog/hello-world", "", { force: true });navigateTo(pathname, search?, push?) returns Promise<boolean> — true if
navigation succeeded, false if it was cancelled by a newer navigation.
prefetch(pathname, search?, opts?) fetches the page payload and caches it
for 30 seconds. Subsequent navigations to the same path use the cache
instantly (fromCache: true in the lifecycle event detail).
# startClientRouter()
Initializes the client router automatically. This is called by the generated router module — you normally don't call it directly:
import { startClientRouter } from "@elurjs/kit/router";
startClientRouter({ prefetch: true, morph: false, loadingIndicator: false });# Redirects and rewrites
Redirect and rewrite rules are internal utilities that match path patterns
with :param and * wildcards. They are used by the framework during build
and request handling:
// Internal API (not exported via @elurjs/kit/router)
// RedirectRule: { from, to, status? } — default status 308
// RewriteRule: { from, to }
// RouteHeadersRule: { path, headers }# RedirectRule
| Field | Type | Description |
|---|---|---|
from |
string |
Source path pattern (:param, * wildcards) |
to |
string |
Destination path (supports :param interpolation) |
status |
301 | 302 | 307 | 308 |
Default: 308 |
# RewriteRule
| Field | Type | Description |
|---|---|---|
from |
string |
Source path pattern |
to |
string |
Destination path (internal rewrite, URL doesn't change) |
# RouteHeadersRule
| Field | Type | Description |
|---|---|---|
path |
string |
Path pattern to match |
headers |
Record<string, string> |
Headers to apply |
const headers = [
{ path: "/api/*", headers: { "Cache-Control": "no-store" } },
];# Types
# PageRoute
The route object returned by scanRoutes for each page:
| Field | Type | Description |
|---|---|---|
path |
string |
URL path, e.g. "/blog/:slug" |
pagePath |
string |
Filesystem path to page.ts |
dataPath |
string? |
Filesystem path to page.data.ts |
actionPath |
string? |
Filesystem path to page.action.ts |
layouts |
string[] |
Ordered layout.ts paths (root → leaf) |
loadingPath |
string? |
Filesystem path to loading.ts |
params |
string[] |
Dynamic parameter names |
optionalCatchAll |
boolean? |
Has [[...slug]] segment |
slots |
Record<string, string>? |
Named slot modules (name → path) |
# ApiRoute
| Field | Type | Description |
|---|---|---|
path |
string |
URL path, e.g. "/api/posts" |
routePath |
string |
Filesystem path to route.ts |
params |
string[] |
Dynamic parameter names |
# ScannedRoutes
interface ScannedRoutes {
pages: PageRoute[];
api: ApiRoute[];
error404?: PageRoute;
error500?: PageRoute;
}# RouteRecord
interface RouteRecord {
path: string;
component: () => ElurTemplate | ElurComponent;
name?: string;
children?: RouteRecord[];
meta?: Record<string, unknown>;
beforeEnter?: NavigationGuard;
}# NavigateOptions
interface NavigateOptions {
replace?: boolean; // replace history entry instead of pushing
}# NavigationGuard
type NavigationGuard = (
to: string,
from: string,
) => boolean | string | void | { redirect: string };
// Return false to cancel, a path string to redirect, or nothing to continue# NavigationGuardResult
type NavigationGuardResult = boolean | string | void | { redirect: string };# AfterEachHook
type AfterEachHook = (to: string, from: string) => void;# RouterMode
type RouterMode = "history" | "hash";# ScrollBehavior
type ScrollBehavior = (
to: string,
from: string,
savedPosition: ScrollPosition | null,
) => ScrollPosition | false;# RouteLocation
type RouteLocation = string | NamedRouteLocation;
interface NamedRouteLocation {
name: string;
params?: Record<string, string>;
}# Router interface
interface Router {
readonly current: Signal<string>;
readonly params: Signal<Record<string, string>>;
readonly query: Signal<Record<string, string>>;
readonly base: string;
readonly intent: Signal<NavigationIntent>;
readonly canGoBack: Signal<boolean>;
navigate(location: RouteLocation, options?: NavigateOptions): void;
replace(location: RouteLocation, options?: NavigateOptions): void;
back(animation?: unknown): void;
forward(animation?: unknown): void;
go(delta: number): void;
isActive(path: string, exact?: boolean): boolean;
resolve(path: string): ResolvedRoute;
readonly routes: RouteRecord[];
beforeEach(guard: NavigationGuard): () => void;
afterEach(hook: AfterEachHook): () => void;
}