Server functions
Build Niral backends with server blocks, SSR loaders, RPC endpoints and progressive form actions. Explore middleware, sessions and streaming responses.
On this page 9 sections
The <server> block is your backend. Exports become RPC endpoints with compile-time client stubs; load() runs during SSR and feeds $props.
<server>
export async function load({ params }) {
return { post: await findPost(params.slug) }
}
export async function like(postId) {
session.set("liked", true)
return { count: 42 }
}
</server>
<script>
let { post } = $props
// like("id") — just call it; it's a POST under the hood
</script>
Ambient helpers
Every JS server block gets these without imports:
| Ambient | Purpose |
|---|---|
session | get/set/delete/clear — signed cookie (or DB store) |
user(), auth.* | the signed-in user, login/logout, passkeys, TOTP, OAuth |
v, validate, withSchema | input validation |
ai, rag | AI chat/stream/embed + retrieval |
mail() | send email over SMTP |
enqueue() | queue a background job |
publish() | push to a live channel |
log | structured logging |
env() | environment variables |
projectImport() | import project files (lib/db.js …) |
Streaming responses
An async function* streams — perfect for AI tokens and progress:
// server
export async function* ask(prompt) {
for await (const chunk of ai.stream(prompt)) yield chunk
}
// client
const stream = await ask("explain closures")
for await (const chunk of stream) text += chunk
Form actions — works without JS
<form method="post" action="?/save">
<input name="title" />
<button>Save</button>
</form>
save(fields) runs on the server. Ordinary enhanced forms rerun the page loader and replace the page component; without JS the browser does a classic POST and re-render. The action result becomes $props.form. Multipart uploads arrive as { filename, type, size, data } buffers, capped and sanitized.
Reactive form actions
Opt in to per-form state without replacing the page DOM:
<server>
export const save = withSchema({ title: v.string({ min: 3 }) }, async ({ title }) => {
session.set("title", title)
return { title }
})
</server>
<script lang="ts">
const saving = formAction("save")
</script>
<form method="post" action="?/save" on:submit={saving.submit}>
<label>Title <input name="title" /></label>
{#if saving.errors.title}<p role="alert">{saving.errors.title}</p>{/if}
<button disabled={saving.pending}>{saving.pending ? "Saving..." : "Save"}</button>
</form>
{#if saving.error}<p role="alert">{saving.error}</p>{/if}
{#if saving.result}<p>Saved: {saving.result.title}</p>{/if}
Create one controller per form in the component script. The action name must match the form's ?/name. Reactive getters update templates without another $state wrapper; read them from the controller, not a one-time destructure.
| Member | Meaning |
|---|---|
pending | True while this controller is submitting |
status | idle, pending, success, or error |
result | The successful action return value, otherwise undefined |
errors | Field messages from validation, initially an empty object |
error | General failure message, otherwise null |
statusCode | Response HTTP status, or 0 before a response/after transport failure |
refreshing | True during an opted-in loader refresh after success |
refreshError | Loader refresh failure, separate from the saved action result |
submit | Attach to on:submit; preserves submitter name/value |
reset() | Abort the active fetch and clear controller state, not input values |
Pending starts with cleared previous results/errors. On validation failure, inputs, focus, and selected files remain intact. Success also leaves inputs in place. By default, reactive submissions do not rerun load() or update $props.form; render the returned result or opt in to page refresh below. Successful actions returning { redirect: "/done" } navigate normally. Each controller is independent.
withSchema returns field errors with HTTP 400. For business validation, an action can return { errors: { title: "Already taken" } } or { error: "Unable to save" }; these reserved result fields mark failure. Nested object errors may contain nested maps; narrow them before rendering. Unexpected production server failures use a generic message.
For uploads add enctype="multipart/form-data" and a named file input. Enhanced forms preserve file bytes and let the browser set the multipart boundary; the encoding attribute also makes uploads work without JavaScript. Submit buttons can override the action, method, target and encoding using standard form attributes. Only same-origin POST actions targeting the current window are enhanced. A different action name falls back to ordinary enhancement.
JavaScript-disabled submissions keep the classic server-rendered behavior. Render $props.form as well when no-JS users need inline results or field messages; the controller itself starts idle and has no client state without JS.
Concurrent submissions of the same form are suppressed while its request is in flight. This is not a server-side idempotency guarantee. Network failures never trigger an automatic native resubmission: check whether the operation completed before retrying. Ordinary enhanced forms dispatch a bubbling niral:form-error event with event.detail.error when their response cannot be applied. Resetting or leaving a component aborts the client fetch and ignores late controller results, but cannot undo an action already executing on the server.
Form writes reject mismatched Origin hosts and cross-site Fetch Metadata when Origin is absent. Reverse-proxy deployments can set NIRAL_ORIGIN to their canonical public URL. Session authentication and server validation still apply.
State-preserving page refresh
Use pageRefresh() in a hydrated page or component to rerun the current page's loader and update page/layout props without remounting:
<server>
export async function load() {
return { title: session.get("title") ?? "Untitled" }
}
export async function save(fields) {
session.set("title", fields.title)
return { saved: true }
}
</server>
<script>
let { title } = $props
let draft = $state(title)
const data = pageRefresh()
const saving = formAction("save", { refresh: true })
</script>
<h1>{title}</h1>
<form method="post" action="?/save" on:submit={saving.submit}>
<label>Title <input name="title" bind:value={draft} /></label>
<button disabled={saving.pending || saving.refreshing}>Save</button>
</form>
<button on:click={data.refresh} disabled={data.pending}>Refresh</button>
{#if data.error}<p role="alert">{data.error}</p>{/if}
{#if saving.error}<p role="alert">{saving.error}</p>{/if}
{#if saving.refreshError}<p role="alert">Saved, but the page could not refresh.</p>{/if}
| Member | Meaning |
|---|---|
pending | True while this controller refreshes |
status | idle, pending, success, or error |
error | Read failure message, otherwise null |
refresh() | Returns true when fresh props were applied, false on failure/cancellation |
reset() | Abort this request and clear status/error; retain currently displayed data |
Refresh uses the current URL, including query parameters, bypasses prefetched navigation data, and keeps the existing DOM and component instances. Destructured $props bindings update reactively; local $state is not reinitialized. In the example, title updates but draft stays as the user typed it. Keyed lists keep surviving rows; removed rows or changed conditional branches are still removed normally. Focus, selected files and scroll remain untouched unless application rendering itself removes or changes their elements.
Create the controller during component setup and read its getters directly. Repeated calls on one pending controller are ignored. Across controllers, only the latest-started refresh may apply. Navigation, reset or component disposal prevents stale results from updating the page. Requests are not retried automatically. Read failures retain the previous props and can be retried with refresh().
The form option refreshes only after a successful, non-redirecting action. A failed refresh does not turn a saved action into a failed write: status remains success, with refreshError reported separately. Redirecting actions navigate normally; failed validation does not trigger a refresh. saving.reset() cancels its refresh too, but cannot undo server work.
This is page-level loader refresh, not selective queries, polling or optimistic updates. It does not reload component code, styles or document head. Non-client or redirected responses are reported as refresh errors rather than silently remounting the page. Static/no-JavaScript pages keep their existing behavior.
Python, Ruby, Go backends
<server lang="python">
import statistics
def stats(xs):
return { "mean": statistics.mean(xs) }
</server>
Same RPC, same sessions, same load() — the code runs in a persistent worker speaking a newline-JSON protocol. Go is compiled once at first use.
Middleware — hooks.js
// hooks.js at the project root
export async function handle(event) {
if (event.path.startsWith("/admin") && !event.session.get("user")) {
return event.redirect("/auth/login")
}
event.locals.tenant = lookupTenant(event.headers.host)
return null // continue
}
Sessions at scale
Sessions live in a signed HttpOnly cookie by default. Set NIRAL_SESSION_STORE=db and the data moves into data/sessions.db — the cookie shrinks to a signed id, and the 4KB limit disappears.
Databases
SQLite is built in and is the default — one file, zero setup, survives deploys. Outgrowing one box? Niral ships its own pure-Node Postgres driver (no pg, TLS included) — set NIRAL_DATABASE_URL and use the ambient sql in any <server> block:
<server>
export async function load() {
const { rows } = await sql.query("select * from posts where author = $1", [id])
return { posts: rows }
}
</server>
Full guide: Databases — SQLite & Postgres.