Data & Backend
Elur Kit provides a complete server-side story: data loaders, API routes, server actions, middleware, error handling, and metadata — all file-based and type-safe. This page covers the backend features that live alongside your routes. For route file conventions and path patterns, see Routing.
# Data loading
Data loading lives in a separate page.data.ts file. This keeps server-only
data fetching code out of the client bundle:
// src/app/blog/[slug]/page.data.ts
import type { PageDataLoad } from "@elurjs/kit";
export const load: PageDataLoad = async ({ params, searchParams, request }) => {
const post = await getPost(params.slug as string);
return { post };
};The page component receives the data via the data prop:
// src/app/blog/[slug]/page.ts
import { html } from "@elurjs/core";
import type { PageProps } from "@elurjs/kit";
export default function BlogPost({ data }: PageProps<{ post: Post }>) {
return html`<article><h1>${data.post.title}</h1>${data.post.body}</article>`;
}# LoadContext
The loader receives a LoadContext with route information:
| Field | Type | Description |
|---|---|---|
params |
RouteParams |
Route parameters (e.g. { slug: "hello-world" }) |
searchParams |
URLSearchParams |
Query string parameters |
request |
Request? |
The original request (server mode only) |
# Layout data
Layouts can also have data loaders via layout.data.ts. Layout data is
available to the layout component via layoutData on child pages:
// src/app/blog/layout.data.ts
import type { PageDataLoad } from "@elurjs/kit";
export const load: PageDataLoad = async () => {
const categories = await getCategories();
return { categories };
};// src/app/blog/layout.ts
import { html } from "@elurjs/core";
import type { LayoutProps } from "@elurjs/kit";
export default function BlogLayout({ children, data }: LayoutProps<{ categories: string[] }>) {
return html`
<nav>${data?.categories.map(c => html`<a href="/blog/${c}">${c}</a>`)}</nav>
${children}
`;
}# PageProps
| Field | Type | Description |
|---|---|---|
data |
T |
Loader data (typed from load return) |
layoutData |
T? |
Parent layout loader data |
params |
RouteParams |
Route parameters |
searchParams |
URLSearchParams |
Query string |
form |
unknown? |
Action failure relayed via the error cookie: { __elur_js_action_error: true, status, data } when a plain-form action failed |
# LayoutProps
| Field | Type | Description |
|---|---|---|
children |
ElurChildren |
Page content to render |
data |
T? |
Layout loader data |
slots |
Record<string, ElurTemplate>? |
Rendered *.slot.ts templates keyed by name — passed at runtime but not declared on the type yet (see Routing — Layout slots) |
# Static params
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" }];
};The framework calls generateStaticParams at build time and generates one
HTML file per returned param combination.
# API routes
API routes use route.ts instead of page.ts. They handle requests
directly and return Response objects:
// src/app/api/posts/route.ts
export async function GET(request: Request): Promise<Response> {
const posts = await listPosts();
return Response.json({ posts });
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const post = await createPost(body);
return Response.json({ created: post }, { status: 201 });
}
export async function DELETE(request: Request): Promise<Response> {
const url = new URL(request.url);
const id = url.searchParams.get("id");
await deletePost(id);
return new Response(null, { status: 204 });
}The handler lookup is dynamic — mod[request.method] — so any exported
method name works (GET, POST, PUT, PATCH, DELETE, HEAD,
OPTIONS, …). Unmatched methods return 405 Method Not Allowed.
# Programmatic route matching
import { matchRoute, matchApiRoute } from "@elurjs/kit";
// Match a page route
const pageMatch = matchRoute("/blog/hello-world", routes.pages);
// { route: PageRoute, params: { slug: "hello-world" }, searchParams: URLSearchParams }
// Match an API route
const apiMatch = matchApiRoute("/api/posts/123", routes.api);
// { route: ApiRoute, params: { id: "123" } }Routes are sorted by specificity (static > dynamic > catch-all) before
matching, so /about wins over /:slug even if the catch-all appears first.
# MatchResult
interface MatchResult {
route: PageRoute;
params: Record<string, string | string[]>;
// Always empty — matchRoute strips the query; callers parse it from the
// request URL themselves.
searchParams: URLSearchParams;
}# ApiMatchResult
interface ApiMatchResult<T = ApiRoute> {
route: T;
params: Record<string, string | string[]>;
}# Dynamic API routes
src/app/api/posts/[id]/route.ts → /api/posts/:idinterface ApiContext {
params: Record<string, string | string[]>;
locals: Record<string, unknown>; // published by middleware via next({ locals })
}
export async function GET(request: Request, { params, locals }: ApiContext) {
const post = await getPost(params.id);
if (!post) return new Response("Not Found", { status: 404 });
return Response.json(post);
}The second argument is the API context — params from the matched route and
locals populated by middleware (see
Middleware).
# Server actions
Server actions are mutations defined in page.action.ts. They run on the
server and can be called from the client with progressive enhancement:
// src/app/contact/page.action.ts
export async function submitContact(data: { name: string; email: string }) {
await sendEmail(data);
return { ok: true };
}import { elurJsAction } from "@elurjs/kit/action";
const contact = elurJsAction("submitContact", { page: "/contact" });
html`
<form @submit=${(e: Event) => { e.preventDefault(); contact.submit({ name: "Ada" }); }}>
<input name="name" />
<button disabled=${() => contact.pending.value}>
${() => contact.pending.value ? "Sending..." : "Send"}
</button>
</form>
`See the Server Actions page for the full API:
defineAction, validators, concurrency modes, fail(), redirect(),
progressive enhancement, and CSRF security.
# loading.ts — streaming boundary
A loading.ts file wraps children in a streaming boundary, showing fallback
content while the page's data loads:
src/app/blog/loading.ts → wraps /blog/* during loading// src/app/blog/loading.ts
import { html } from "@elurjs/core";
export default function BlogLoading() {
return html`<div class="skeleton">Loading…</div>`;
}During SSR, the fallback renders immediately and the real content streams in when the loader resolves. During SSG, the build waits for all loaders.
# Error pages
404.page.ts— custom not found page500.page.ts— custom server error page
Error pages render during:
elur-kit build→dist/404.html,dist/500.htmlelur-kit startand Vite plugin → unmatched routes and render errors- All deployment adapters → unmatched routes and SSR failures
Error pages can export their own 404.page.data.ts / 500.page.data.ts
loaders.
# generateMetadata
Pages can export generateMetadata for dynamic SEO. It receives
MetadataContext, which extends LoadContext with the resolved loader
data:
import type { GenerateMetadata, PageMetadata } from "@elurjs/kit";
export const generateMetadata: GenerateMetadata = async ({ params, data }) => {
return {
title: data?.title ?? "Blog",
description: "A blog post",
canonical: `https://example.com/blog/${params.slug}`,
openGraph: { type: "article", image: "/og/blog.jpg" },
twitter: { card: "summary_large_image" },
};
};# PageMetadata
| Field | Type | Description |
|---|---|---|
title |
string? |
Page title |
description |
string? |
Meta description |
canonical |
string? |
Canonical URL |
robots |
string? |
Robots meta (e.g. "index, follow" or "noindex") |
openGraph |
OpenGraphMetadata? |
Open Graph tags |
twitter |
TwitterMetadata? |
Twitter Card tags |
other |
Record<string, string>? |
Additional <meta> tags as key/value pairs |
# OpenGraphMetadata
| Field | Type |
|---|---|
type |
string? |
title |
string? |
description |
string? |
url |
string? |
image |
string? |
imageAlt |
string? |
imageWidth |
number? |
imageHeight |
number? |
imageType |
string? |
siteName |
string? |
locale |
string? |
# TwitterMetadata
| Field | Type | Description |
|---|---|---|
card |
"summary" | "summary_large_image" | "player" | "app"? |
Card type |
title |
string? |
Title (falls back to page title) |
description |
string? |
Description (falls back to page description) |
image |
string? |
Image URL |
imageAlt |
string? |
Image alt text (twitter:image:alt) |
Tags are marked with data-elur-head so the SPA router swaps them on
navigation. See also Image & SEO for
generateSitemap, generateRobots, and jsonLd.
Metadata resolution falls back when generateMetadata is absent or returns
nothing: the renderer looks for a metadata field in the page loader
data first, then in each layout.data.ts result — so a loader can return
{ post, metadata: { title: post.title } } without a separate export.
# throw new Response() — first-class HTTP control flow
Loaders and layout loaders can throw Response objects for redirects, 404s,
or any HTTP status. The framework catches these and returns them directly
instead of rendering a 500:
export const load = async ({ params }) => {
const post = await getEntry("blog", params.slug);
if (!post) throw new Response("Not Found", { status: 404 });
return { post };
};// Redirect from a loader
export const load = async ({ params }) => {
const post = await getEntry("blog", params.slug);
if (post && post.data.oldSlug) {
throw new Response(null, {
status: 301,
headers: { Location: `/blog/${post.data.newSlug}` },
});
}
return { post };
};When catching errors around framework calls, re-throw Response objects
(error instanceof Response) so redirects and 404s keep flowing as
control flow instead of being handled as real errors:
try {
// ...
} catch (error) {
if (error instanceof Response) throw error; // re-throw as control flow
// handle real errors
}# Middleware
Middleware runs before route matching and rendering. Define it at the root:
src/middleware.ts or middleware.tsThe signature is (request: Request, context: MiddlewareContext) — return a
Response to short-circuit, or call next({ headers, locals }) and return
nothing to continue:
import type { Middleware } from "@elurjs/kit";
const middleware: Middleware = async (request, { next }) => {
if (!request.headers.get("Cookie")?.includes("session=")) {
return Response.redirect(new URL("/login", request.url), 307);
}
// Continue — attach locals for API routes and extra request headers
next({
locals: { user: await getUser(request) },
headers: { "x-user-id": "123" },
});
};
export default middleware;
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};Middleware runs in the unified Web handler — elur-kit dev, preview,
and start — after redirects/rewrites and the internal endpoints, before
routing. It does not run in the generated Node/Bun adapter servers yet.
See the Middleware & Cache page for
the full API: MiddlewareContext, next(), tagged invalidation, cache
adapters, and streamBoundary.
# Cache control
Route modules can export a cache object (from page.data.ts, the data
module) to control ISR behavior:
// src/app/blog/[slug]/page.data.ts
export const load = async ({ params }) => {
const post = await getEntry("blog", params.slug);
return { post };
};
export const cache = {
mode: "public", // "public" | "private" | "dynamic"
revalidate: 60, // seconds
tags: ["posts"], // for tag-based invalidation
};| Mode | Behavior |
|---|---|
"public" |
Cached globally; served from cache until revalidate |
"private" |
Never publicly cached (reserved for a future per-user adapter) |
"dynamic" |
Always rendered fresh (default, revalidate: 0) |
Public cache is automatically disabled for requests with Cookie or
Authorization headers. See
Middleware & Cache for cache
adapters (filesystem, Redis, Cloudflare KV) and invalidation.
# Server-side data access patterns
# Reading request headers
export const load: PageDataLoad = async ({ request }) => {
const auth = request?.headers.get("Authorization");
const user = await verifyToken(auth);
return { user };
};# Cookies
export const load: PageDataLoad = async ({ request }) => {
const cookies = request?.headers.get("Cookie");
// Parse cookies manually or use a cookie library
return { theme: parseTheme(cookies) };
};# Streaming data with streamBoundary
For slow data sources, stream partial HTML while loading:
import { streamBoundary } from "@elurjs/kit";
import { html } from "@elurjs/core";
export default function Page({ data }: PageProps<{ comments: Promise<Comment[]> }>) {
return html`
<h1>Post</h1>
${streamBoundary({
fallback: html`<p>Loading comments…</p>`,
promise: data.comments,
children: (comments) => html`
<ul>${comments.map(c => html`<li>${c.text}</li>`)}</ul>
`,
})}
`;
}# Summary of file conventions
| File | Purpose |
|---|---|
page.ts |
Page component (default export) |
page.data.ts |
Data loader (server-only) |
page.action.ts |
Server actions |
layout.ts |
Layout component wrapping children |
layout.data.ts |
Layout data loader |
loading.ts |
Streaming fallback during load |
route.ts |
API endpoint (GET, POST, etc.) |
404.page.ts |
Custom not found page |
500.page.ts |
Custom server error page |
middleware.ts |
Root middleware |
*.slot.ts |
Named layout slot |