Server Actions
Server actions are typed, validated mutations that run on the server and are called from the client via reactive handles.
# File-based actions
Create a page.action.ts next to page.ts and export async functions:
// src/app/contact/page.action.ts
export async function submitContact(data: { name: string; email: string }) {
// validate, write to DB, send email, etc.
return { ok: true };
}Actions run on the server and are called from the client via
POST /__elur-js/actions.
# defineAction(options, handler)
Typed action with validation, concurrency control, and cache invalidation:
import { defineAction } from "@elurjs/kit";
import { z } from "zod";
export const submitContact = defineAction(
{
input: z.object({
name: z.string(),
email: z.string().email(),
}),
concurrency: "latest",
idempotent: false,
invalidateTags: ["contacts"],
invalidatePaths: ["/contact"],
},
async (input, ctx) => {
// input is validated and typed
// ctx.request, ctx.signal, ctx.params, ctx.locals
await db.contacts.create(input);
return { ok: true };
}
);# DefineActionOptions<TInput>
| Field | Type | Default | Description |
|---|---|---|---|
input |
ActionInputValidator<TInput> |
— | Zod schema, plain function, or any object with .parse() |
concurrency |
"latest" | "queue" | "parallel" |
"latest" |
How concurrent calls are handled |
idempotent |
boolean |
false |
Safe to retry |
invalidateTags |
string[] |
[] |
Cache tags to invalidate after success |
invalidatePaths |
string[] |
[] |
Cache paths to invalidate after success |
# ActionContext
| Field | Type | Description |
|---|---|---|
request |
Request |
Original Web Request |
signal |
AbortSignal |
Aborts if client disconnects |
idempotencyKey |
string? |
From request header |
params |
Record<string, string | string[]> |
Route params |
locals |
Record<string, unknown> |
Per-request locals from middleware |
# Concurrency modes
"latest"— abort previous in-flight call, keep only the latest"queue"— serialize calls, run in order"parallel"— run all calls concurrently
# elurJsAction(name, options?) — reactive client handle
Returns a reactive handle with signals for pending, error, and data:
import { elurJsAction } from "@elurjs/kit/action";
import { html } from "@elurjs/core";
const contact = elurJsAction("submitContact", { page: "/contact" });
html`
<form @submit=${(e: Event) => {
e.preventDefault();
contact.submit({ name: "Ada", email: "ada@example.com" });
}}>
<input name="name" />
<input name="email" />
<button type="submit" disabled=${() => contact.pending.value}>
${() => contact.pending.value ? "Sending..." : "Send"}
</button>
</form>
${() => contact.error.value ? html`<p>${contact.error.value.message}</p>` : null}
${() => contact.data.value ? html`<p>Sent!</p>` : null}
`# ElurAction<TInput, TOutput>
| Member | Type | Description |
|---|---|---|
submit(input) |
(input: TInput) => Promise<TOutput | ActionFailure | RedirectResponse> |
Calls the action, updates signals |
pending |
Signal<boolean> |
true while action is running |
error |
Signal<Error | null> |
Last error, or null |
data |
Signal<TOutput | ActionFailure | RedirectResponse | null> |
Last result (success, failure, redirect, or null) |
# CallActionOptions
| Field | Type | Description |
|---|---|---|
page |
string |
Route path to scope the action (avoids name collisions) |
# callAction(name, args, options?) — low-level
import { callAction } from "@elurjs/kit/action";
const result = await callAction(
"submitContact",
{ name: "Ada", email: "ada@example.com" },
{ page: "/contact" }
);
// args can be a single value or an array# fail(data, status?) — return a failure
import { fail } from "@elurjs/kit/action";
export async function submitContact(data: { name: string }) {
if (!data.name) return fail({ message: "Name required" }, 400);
return { ok: true };
}fail() returns an ActionFailure that the client receives in
contact.data.value (not error.value).
# redirect(location, status?) — return a redirect
import { redirect } from "@elurjs/kit/action";
export async function login(data: { email: string; password: string }) {
const user = await auth(data);
if (user) return redirect("/dashboard", 302);
return fail({ message: "Invalid credentials" }, 401);
}redirect() returns a RedirectResponse that the client follows
automatically. Default status is 303.
# Progressive enhancement
Actions work without JavaScript. Add hidden fields to a plain HTML form:
<form action="/__elur-js/actions" method="POST">
<input type="hidden" name="__elur_js_action_name" value="submitContact" />
<input type="hidden" name="__elur_js_action_page" value="/contact" />
<input name="name" />
<input name="email" />
<button type="submit">Send</button>
</form>The server runs the action and redirects back to the referring page. If the
client sends Accept: application/json, the result is returned as JSON
instead.
# Security
# Origin verification
import { verifyOrigin } from "@elurjs/kit/action";
if (!verifyOrigin(request, { allowedOrigins: ["https://myapp.com"] })) {
return new Response("Forbidden", { status: 403 });
}# Body limits
Configured in elur.config.ts:
defineConfig({
security: { bodyLimit: 1_000_000 }, // 1MB
});# HMAC-signed error cookies
Action errors are stored in HMAC-signed cookies to survive redirects, preventing tampering.
# Types
# ActionConcurrencyMode
type ActionConcurrencyMode = "latest" | "queue" | "parallel";"latest"— only the most recent call runs; previous in-flight calls are cancelled"queue"— calls run sequentially in order"parallel"— all calls run concurrently
# ActionContext
| Field | Type | Description |
|---|---|---|
request |
Request |
The original Web Request |
signal |
AbortSignal |
Aborts if the client disconnects |
idempotencyKey |
string? |
From request header, for safe retries |
params |
Record<string, string | string[]> |
Route params (page-scoped actions) |
locals |
Record<string, unknown> |
Per-request data from middleware |
# DefineActionOptions<TInput>
| Field | Type | Description |
|---|---|---|
input |
ActionInputValidator<TInput>? |
Validator with .parse() (Zod-compatible) |
concurrency |
ActionConcurrencyMode? |
Concurrency mode |
idempotent |
boolean? |
Safe to retry |
invalidateTags |
string[]? |
Cache tags to invalidate after success |
invalidatePaths |
string[]? |
Cache paths to invalidate after success |
# DefinedAction<TInput, TOutput>
The return type of defineAction() — a callable with metadata:
interface DefinedAction<TInput, TOutput> {
(input: TInput, ctx: ActionContext): Promise<TOutput | ActionFailure<TOutput>>;
__elurAction: {
name: string;
concurrency: ActionConcurrencyMode;
idempotent: boolean;
invalidateTags: readonly string[];
invalidatePaths: readonly string[];
};
}# DefinedActionFn<TInput, TOutput>
The function signature inside defineAction:
type DefinedActionFn<TInput, TOutput> = (
input: TInput,
ctx: ActionContext,
) => Promise<TOutput | ActionFailure<TOutput>>;# OriginCheckOptions
| Field | Type | Default | Description |
|---|---|---|---|
allowedOrigins |
string[]? |
— | Extra origins allowed to call actions |
strictOrigin |
boolean? |
false |
Reject requests missing both Origin and Referer |
# Type guards
import {
isActionFailure,
isRedirectResponse,
isFirstClassResponse,
} from "@elurjs/kit/action";
if (isActionFailure(result)) {
console.log(result.data, result.status);
}
if (isRedirectResponse(result)) {
console.log(result.location, result.status);
}
if (isFirstClassResponse(error)) {
// thrown Response object (redirect, 404, etc.)
}# handleActionRequest(request, resolveAction, security?)
Low-level server handler with CSRF verification, body parsing, and error handling:
import { handleActionRequest } from "@elurjs/kit/action";
const response = await handleActionRequest(
request,
async (name, page) => {
// Return the action function for the given name/page
return actions[page]?.[name];
},
{ allowedOrigins: ["https://myapp.com"], bodyLimit: 1_000_000 },
);ActionResolver is (name: string, page?: string) => Promise<((...args: unknown[]) => unknown) | undefined>.
# scanActions(appDir) — build-time discovery
Returns a per-page registry keyed by page URL path:
import { scanActions } from "@elurjs/kit";
const registry = await scanActions("./src/app");
// {
// "/contact": { "submitContact": "/abs/path/to/page.action.ts" },
// "/blog": { "createPost": "/abs/path/to/page.action.ts" },
// }# relativeActions(actions, root)
Returns a copy with file paths made relative to root:
import { relativeActions } from "@elurjs/kit";
const safe = relativeActions(registry, process.cwd());# actionNames(actions)
Returns only action names per page (safe for HTML shell serialization):
import { actionNames } from "@elurjs/kit";
const names = actionNames(registry);
// { "/contact": ["submitContact"], "/blog": ["createPost"] }# originForbidden(message)
Builds a 403 text response for a rejected origin check:
import { verifyOrigin, originForbidden } from "@elurjs/kit/action";
const reason = verifyOrigin(request, { strictOrigin: true });
if (reason) return originForbidden(reason);# ActionRequest
The JSON body sent to /__elur-js/actions:
interface ActionRequest {
name: string;
page?: string;
args: unknown[];
}# Action error cookies
When an action fails via a plain HTML form submission (progressive
enhancement), the failure data is relayed back via a short-lived signed
cookie (__elur_js_action_error, Max-Age=15s, SameSite=Lax, HttpOnly).
The cookie is HMAC-signed with ELUR_JS_ACTION_SECRET (env var) or a
per-process key in dev. Small payloads are embedded directly in the cookie;
large payloads overflow to an in-memory store keyed by a signed id.
import {
encodeActionErrorCookie,
decodeActionErrorCookie,
clearActionErrorCookieHeader,
setActionErrorCookieHeader,
ACTION_ERROR_COOKIE,
} from "@elurjs/kit/action";
// Encode a failure for the redirect cookie
const { value, storeId } = encodeActionErrorCookie(
{ email: "Invalid" },
400,
);
// Set on the redirect response
headers.set("Set-Cookie", setActionErrorCookieHeader(value));
// On the next render, read and consume
const error = decodeActionErrorCookie(request.headers.get("Cookie"));
// { data: { email: "Invalid" }, status: 400 } | undefined
// Clear after consuming
headers.set("Set-Cookie", clearActionErrorCookieHeader());The form prop on PageProps is populated from this cookie during SSR.
# Public error helpers
import {
toPublicErrorInfo,
publicErrorResponse,
isFirstClassResponse,
} from "@elurjs/kit";
// Production-safe error info (no stacks or internal paths)
const info = toPublicErrorInfo(error, { includeDetail: false });
// { code: "INTERNAL_SERVER_ERROR", message: "Internal Server Error", status: 500 }
// Build a JSON error Response with safe body
const response = publicErrorResponse(error, {
includeDetail: process.env.NODE_ENV !== "production",
requestId: ctx.requestId,
});isFirstClassResponse(error) returns true when a loader or layout threw
a Response object (redirect, 404, etc.) — these are re-thrown as control
flow, not treated as 500 errors.