SSR & Hydration
Elur Kit supports three output modes: static (SSG), server (SSR), and hybrid.
# Output modes
| Mode | Description |
|---|---|
"static" |
Pre-render all pages at build time (default) |
"server" |
Render on demand on each request |
"hybrid" |
Static by default, per-route server rendering |
// elur.config.ts
export default defineConfig({
output: "static", // "static" | "server" | "hybrid"
});# Build
The build() function scans src/app/, generates routes, and outputs to
dist/:
import { build } from "@elurjs/kit";
await build({
appDir: "./src/app",
outDir: "./dist",
islandsDir: "./src/islands",
generatedEntry: "./.elur/entry-client.ts",
});# renderToString(factory, options?)
Renders a Elur template to an HTML string on the server. Accepts a factory
(thunk) because html\`` evaluates at call time:
import { renderToString } from "@elurjs/kit";
const html = await renderToString(() => Page({ data }));
// options: { markers?: "none" | "hydration" } — default "hydration"# documentShell(options)
Wraps page HTML with the document shell (<html>, <head>, <body>):
import { documentShell } from "@elurjs/kit";
const fullHtml = documentShell({
body: pageHtml,
title: "My Page",
lang: "en",
htmlAttributes: { "data-theme": "dark" },
headScripts: ["/scripts/analytics.js"],
headLinks: ['<link rel="stylesheet" href="/styles/tokens.css">'],
data: { user: { name: "Ada" } },
actions: { "/contact": ["submitContact"] },
clientEntry: "/_elur/entry-client.js",
metadata: pageMetadata,
renderEndpoint: true,
});# ShellOptions
| Field | Type | Description |
|---|---|---|
body |
string |
Page HTML body (required) |
title |
string? |
Page title |
lang |
string? |
HTML lang attribute |
htmlAttributes |
Record<string, string>? |
Additional <html> attributes |
headScripts |
string[]? |
Scripts to inject in <head> |
headLinks |
string[]? |
<link> tags for <head> |
data |
unknown? |
Serialized loader data for client hydration |
actions |
Record<string, string[]>? |
Action names per page (for client) |
clientEntry |
string? |
Client entry URL path |
metadata |
PageMetadata? |
SEO metadata (title, description, OG, Twitter) |
renderEndpoint |
boolean? |
Whether /__elur-js/render exists (default true; false for static) |
# buildHeadTags(metadata, fallbackTitle)
Builds <head> tag strings from PageMetadata:
import { buildHeadTags } from "@elurjs/kit";
const head = buildHeadTags(metadata, "My Site");
// <title>...</title><meta name="description">...<meta property="og:...">...Tags include: <title>, <meta name="description">, <link rel="canonical">,
<meta name="robots">, Open Graph tags, and Twitter Card tags. All tags carry
data-elur-head for the SPA router to merge on navigation.
# collectShellExtras(pageData, layoutDataList)
Collects htmlAttributes and headScripts/headLinks declared by data
loaders (page and layouts) via top-level fields:
import { collectShellExtras } from "@elurjs/kit";
const extras = collectShellExtras(pageData, layoutDataList);
// { htmlAttributes: { "data-theme": "dark" }, headScripts: [...], headLinks: [...] }Loaders can declare these fields in their returned data:
// src/app/blog/[slug]/page.data.ts
export const load = async ({ params }) => {
const post = await getPost(params.slug);
return {
title: post.title,
htmlAttributes: { "data-page": "blog" },
headScripts: ["/scripts/highlight.js"],
headLinks: ['<link rel="stylesheet" href="/styles/code.css">'],
post,
};
};# Streaming
Use streamBoundary for per-request Suspense-style streaming. During SSR,
the fallback is emitted immediately and the resolved content is streamed as
a <template> chunk that swaps in-place. During SSG, boundaries are
resolved synchronously:
import { streamBoundary } from "@elurjs/kit";
const template = streamBoundary({
fallback: html`<p>Loading…</p>`,
promise: fetchUserData(),
children: (user) => html`<p>Hello, ${user.name}</p>`,
});# StreamBoundaryOptions<T>
| Field | Type | Description |
|---|---|---|
fallback |
ElurTemplate |
Content shown while the promise resolves |
promise |
Promise<T> |
Promise that resolves to the real data |
children |
(value: T) => ElurTemplate |
Renders the resolved value |
# createSsrServer(options)
Create an SSR server for on-demand rendering:
import { createSsrServer } from "@elurjs/kit";
const server = await createSsrServer({
appDir: "./src/app",
outDir: "./dist",
});
await server.listen(); // default port 3000
await server.close(); // shutdown# SsrServer
interface SsrServer {
server: Server; // Node.js http.Server
listen(): Promise<void>;
close(): Promise<void>;
}# Hydration
The client entry hydrates islands and starts the client router:
// src/entry-client.ts
import { hydrateIslands } from "@elurjs/kit/island";
import LikeButton from "./islands/LikeButton";
hydrateIslands({ LikeButton });Or let build() auto-generate it from src/islands/:
await build({
appDir: "./src/app",
outDir: "./dist",
islandsDir: "./src/islands",
generatedEntry: "./.elur/entry-client.ts",
});# ISR (Incremental Static Regeneration)
For hybrid mode, pages can be regenerated on-demand. Export a cache policy
from the loader:
// src/app/blog/[slug]/page.data.ts
export const load = async ({ params }) => {
const post = await db.posts.findBySlug(params.slug);
return { post };
};
export const cache = {
mode: "public", // "public" | "private" | "dynamic"
revalidate: 60, // seconds (0 = always revalidate)
tags: ["posts"], // for tag-based invalidation
};Default policy is dynamic (no caching).
# renderPage(options) — single page SSR
Renders a matched route to HTML. Used internally by the SSR server and adapters:
import { renderPage } from "@elurjs/kit";
const result = await renderPage({
route: matchedRoute, // PageRoute from scanRoutes
params: { slug: "hello" },
searchParams: new URLSearchParams(),
config: { lang: "es", clientEntry: "/_elur/entry-client.js" },
request, // for loaders that need cookies/headers
});
// result.html, result.revalidate, result.head, result.resolvedTitle
// result.clearActionErrorCookie (if action error was consumed)RenderPageResult also includes response when a loader throws a
first-class Response (redirect, 404, etc.).
# RenderPageOptions
| Field | Type | Description |
|---|---|---|
route |
PageRoute |
Matched route from scanRoutes (required) |
params |
RouteParams? |
Route parameters |
searchParams |
URLSearchParams? |
Query string |
config |
Pick<BuildConfig, "lang" | "clientEntry" | "renderEndpoint"> |
Render config (required) |
importer |
(path) => Promise<unknown>? |
Custom module loader |
actions |
Record<string, string[]>? |
Action registry |
request |
Request? |
Original request (for loaders) |
# renderStreamingPage(options) — streaming SSR
Streaming renders pages incrementally — sending static parts immediately and resolving async boundaries as they complete:
import { renderStreamingPage } from "@elurjs/kit";
const stream = await renderStreamingPage({
route: matchedRoute,
params: { slug: "hello-world" },
searchParams: new URLSearchParams(),
config: { lang: "es", clientEntry: "/_elur/entry-client.js" },
request,
});
// Returns a ReadableStream# StreamingPageOptions
| Field | Type | Description |
|---|---|---|
route |
PageRoute |
Matched route (required) |
params |
Record<string, string | string[]> |
Route parameters (required) |
searchParams |
URLSearchParams |
Query string (required) |
config |
Pick<BuildConfig, "lang" | "clientEntry"> |
Render config (required) |
importer |
(path) => Promise<unknown>? |
Custom module loader |
actions |
Record<string, string[]>? |
Action registry |
request |
Request? |
Original request |
Streaming is experimental. Some adapters may buffer the response.
# renderPageBody(options) — SPA render endpoint
Renders only the inner HTML body for a page (without the document shell).
Used by the client router's /__elur-js/render endpoint to inject real
content during SPA navigation:
import { renderPageBody } from "@elurjs/kit";
const result = await renderPageBody({
routes: scannedRoutes,
pathname: "/blog/hello-world",
searchParams: new URLSearchParams(),
config: { lang: "es", clientEntry: "/_elur/entry-client.js" },
request,
});
// result.body — inner HTML
// result.title — page title
// result.head — <head> tags for SPA merge
// result.fullHtml — full document (for ISR caching)
// result.clearActionErrorCookie — cookie cleanup if action error was consumed
// result.response — first-class Response if a loader threw oneThrows RouteNotFoundError if no route matches the pathname.
# RenderPageBodyOptions
| Field | Type | Description |
|---|---|---|
routes |
ScannedRoutes |
All scanned routes |
pathname |
string |
Path to render |
searchParams |
URLSearchParams |
Query string |
config |
Pick<BuildConfig, "lang" | "clientEntry"> |
Render config |
actions |
Record<string, string[]>? |
Action registry |
importer |
(path) => Promise<unknown>? |
Custom module loader |
request |
Request? |
Original request |
# renderErrorPage(options) — error pages
Renders a 404 or 500 error page using the scanned 404.page.ts / 500.page.ts
routes. Returns undefined if no error page exists:
import { renderErrorPage } from "@elurjs/kit";
const result = await renderErrorPage({
routes: scannedRoutes,
status: 404,
config: { lang: "es", clientEntry: "/_elur/entry-client.js", renderEndpoint: true },
});
// result: { html: string, status: 404 } | undefined# RenderErrorPageOptions
| Field | Type | Description |
|---|---|---|
routes |
ScannedRoutes |
All scanned routes |
status |
404 | 500 |
Error status code |
error |
unknown? |
The original error (for 500 pages) |
config |
Pick<BuildConfig, "lang" | "clientEntry" | "renderEndpoint"> |
Render config |
actions |
Record<string, string[]>? |
Action registry |
importer |
(path) => Promise<unknown>? |
Custom module loader |
# BuildConfig
| Field | Type | Description |
|---|---|---|
appDir |
string |
Absolute path to the app directory |
outDir |
string |
Absolute path to the output directory |
root |
string? |
Project root (for relative action paths in HTML shell) |
clientEntry |
string? |
Client entry URL path, e.g. /_elur/entry-client.js |
lang |
string? |
Default HTML lang attribute |
islandsDir |
string? |
Islands directory (enables auto-generated entry) |
generatedEntry |
string? |
Path for generated client entry (required with islandsDir) |
hydrateImport |
string? |
Import specifier for hydrateIslands (default @elurjs/kit/island) |
routerImport |
string? |
Import specifier for startClientRouter (default @elurjs/kit/router) |
publicDir |
string? |
Public directory for static assets |
imageFormats |
ImageFormat[]? |
Image formats (default ["webp", "avif"]) |
renderEndpoint |
boolean? |
Whether /__elur-js/render exists (default true; false for static) |
integrations |
ElurKitIntegration[]? |
Integrations to invoke during build |
# BuildResult
| Field | Type | Description |
|---|---|---|
pages |
number |
Number of static HTML pages generated |
skipped |
string[] |
Paths skipped (dynamic without static params) |
files |
string[] |
Absolute paths to generated HTML files |
islands |
IslandModule[] |
Discovered islands |
generatedEntry |
string? |
Path to generated client entry |
imagesProcessed |
number |
Image variants generated (0 if no sharp) |
outDir |
string |
Output directory (atomic staging dir when via CLI) |
# ShellOptions
| Field | Type | Description |
|---|---|---|
body |
string |
Rendered inner HTML for #app |
title |
string? |
<title> text |
lang |
string? |
<html lang> attribute |
htmlAttributes |
Record<string, string>? |
Additional <html> attributes |
headScripts |
string[]? |
Inline scripts in <head> (no-flash bootstrapping) |
headLinks |
string[]? |
Raw HTML in <head> (icons, manifest, theme-color) |
data |
unknown? |
Loader data serialized in <script id="elur-data"> |
actions |
Record<string, string[]>? |
Per-page action names in <script id="elur-actions"> |
clientEntry |
string? |
Client entry URL path |
metadata |
PageMetadata? |
Page metadata (meta, link, OG/Twitter tags) |
renderEndpoint |
boolean? |
Whether /__elur-js/render exists (default true) |
# SsrServerOptions
| Field | Type | Description |
|---|---|---|
appDir |
string |
Absolute path to the app directory |
root |
string? |
Project root (for relative action paths) |
publicDir |
string? |
Absolute path to static files directory |
clientEntry |
string? |
Client entry URL path |
lang |
string? |
Default HTML lang attribute |
port |
number? |
Server port |
host |
string? |
Server host |
cacheDir |
string? |
ISR cache directory |
defaultRevalidate |
number? |
Default revalidate seconds |
streaming |
boolean? |
Render with loading.ts streaming boundaries |
actionSecurity |
ActionSecurityOptions? |
CSRF/origin policy for actions |
# Build internals
# buildClientBundle(options) — Vite client build
Builds the client bundle using the Vite JS API (no npx subprocess):
import { buildClientBundle } from "@elurjs/kit";
const result = await buildClientBundle({
root: process.cwd(),
entry: "./.elur/entry-client.ts",
outDir: "dist/_elur",
clientEntry: "/_elur/entry-client.js",
});# beginAtomicStage(options) — atomic staging
Stages build output outside dist/ and swaps only on success:
import { beginAtomicStage } from "@elurjs/kit";
const stage = await beginAtomicStage({ outDir: "dist", stageDir: ".elur-stage" });
// ... write files to stage.path ...
await stage.commit(); // atomic swap to dist/# copyPublicAssets(options) — copy static files
import { copyPublicAssets } from "@elurjs/kit";
await copyPublicAssets({
publicDir: "public",
outDir: "dist",
});# Gotchas
page.data.tsloader runs on server. Don't accesswindowordocumentin loaders.- Dynamic routes need
generateStaticParams. Without it,[slug]routes are skipped during SSG. - SSR errors are never silenced. If an island throws during SSR, the
error propagates with remediation hints. Use
directive: "only"oroptions: { ssr: false }to skip SSR.