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:

ℹ Note

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/:id

Dynamic 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/b

On /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:

  1. Exact static segments (/blog/new beats /blog/[slug])
  2. Dynamic params (/blog/[slug] beats /blog/[...slug])
  3. 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>
  `;
}
ℹ Note

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, and pointerdown (tap), plus opt-in viewport prefetch via data-prefetch="viewport"
  • Prefetched pages cache for 30 seconds in a bounded LRU (32 entries)
  • Prefetching is network-aware — it skips on Save-Data and effectiveType 2g/slow-2g unless you pass data-prefetch="always"
  • Add data-no-prefetch to any link to opt out
  • View Transitions API used when supported (disabled with prefers-reduced-motion)
  • Add data-no-router to 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 Location response. 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-Timing and X-Request-ID always win.
  • Patterns support literal segments, :param, * wildcards, and :param* catch-alls, with :param interpolation 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;
}
ℹ Note

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.