Docs

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:

AmbientPurpose
sessionget/set/delete/clear — signed cookie (or DB store)
user(), auth.*the signed-in user, login/logout, passkeys, TOTP, OAuth
v, validate, withSchemainput validation
ai, ragAI chat/stream/embed + retrieval
mail()send email over SMTP
enqueue()queue a background job
publish()push to a live channel
logstructured 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.

MemberMeaning
pendingTrue while this controller is submitting
statusidle, pending, success, or error
resultThe successful action return value, otherwise undefined
errorsField messages from validation, initially an empty object
errorGeneral failure message, otherwise null
statusCodeResponse HTTP status, or 0 before a response/after transport failure
refreshingTrue during an opted-in loader refresh after success
refreshErrorLoader refresh failure, separate from the saved action result
submitAttach 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}
MemberMeaning
pendingTrue while this controller refreshes
statusidle, pending, success, or error
errorRead 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.