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:
Pages and layouts must be function components — the renderer invokes the
default export as Page(props) / Layout({ children, data, slots }), so class
components (ElurComponent) are not supported for routes.
// src/app/about/page.ts
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/*The param arrives as string[] — /docs/a/b gives
params.slug === ["a", "b"]. A plain [slug] gives string.
# Optional catch-all
Optional catch-all uses [[...slug]] — matches the parent path too:
src/app/blog/[[...slug]]/page.ts → /blog, /blog/a, /blog/a/bOn /blog the param is an empty array (params.slug === []).
# Matching priority
When several routes could match the same URL, the matcher scores each
segment — static +2, :param +1, catch-all +0 — and tries the most
specific route first:
- Exact static segments (
/blog/newbeats/blog/[slug]) - Dynamic params (
/blog/[slug]beats/blog/[...slug]) - Catch-all / optional catch-all
# 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"Each slot module exports a component that receives the same PageProps as
the page. Rendered slot templates reach the layout through the slots
prop (Record<string, ElurTemplate> keyed by file name):
// src/app/blog/layout.ts
import type { LayoutProps } from "@elurjs/kit";
import { html } from "@elurjs/core";
export default function BlogLayout(
props: LayoutProps & { slots?: Record<string, unknown> },
) {
return html`
<div class="layout">
<main>${props.children}</main>
<aside>${props.slots?.sidebar}</aside>
</div>
`;
}slots is passed at runtime but is not yet declared on LayoutProps —
extend the type locally as shown above.
# 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, rewrites, and route headers
Since v2.5 these rules are configured in elur.config.ts and evaluated by
the unified Web handler — they work in dev, preview, start, and the
generated Node/Bun servers (the Vercel/Netlify bundled handlers do not
pick them up yet):
// elur.config.ts
import { defineConfig } from "@elurjs/kit";
export default defineConfig({
redirects: [
{ from: "/old/:slug", to: "/blog/:slug", status: 301 },
{ from: "/home", to: "/" }, // 308 by default
],
rewrites: [
{ from: "/docs/:rest*", to: "/pages/docs/:rest" }, // URL stays /docs/*
],
headers: [
{ path: "/api/*", headers: { "Cache-Control": "no-store" } },
],
});- Redirects are evaluated before all routing and return a
Locationresponse. First match wins. - Rewrites transparently change the pathname used for routing — the browser URL does not change. The rewritten path drives static serving, SSR, API routes, and the ISR cache key.
- Route headers merge into responses matching the public (pre-rewrite)
path. They may override security headers;
Server-TimingandX-Request-IDalways win. - Patterns support literal segments,
:param,*wildcards, and:param*catch-alls, with:paraminterpolation in destinations. A bare*matches the rest but captures nothing — use a named:param*catch-all when the destination needs the matched tail.
The rule types are exported from the package root so you can annotate
rule arrays outside defineConfig:
import type { RedirectRule, RewriteRule, RouteHeadersRule } from "@elurjs/kit";# 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 |
# 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;
}The types above (PageRoute, ApiRoute, ScannedRoutes) are the Kit
file-system route objects. For the SPA navigation layer see
Client Router; the imperative
createRouter API (RouteRecord, NavigationGuard, RouteLocation,
Router) belongs to @elurjs/core and is documented under
Router — Kit apps don't use it directly.