Docs

Server functions

Write a function on the server, import it in a component, call it. The build replaces the module with a typed stub, so the body and everything it imports stay on the server - and because the mounted function is an ordinary route, everything you already have for routes applies to it unchanged.

Every server function is a public endpoint

This is the part to internalise first, because the API deliberately reads like a local call. A mounted function is an HTTP route anyone can POST to, with arguments entirely under the caller's control, and its id is in the client bundle because the browser needs it. There is no obscurity to lean on. Treat one exactly as you would a hand-written app.post.

Four things follow from that, and the API enforces all of them:

  • Input is validated, always. input is not decoration - without a schema the function takes no argument at all, because unvalidated arguments on a public endpoint are mass assignment.
  • application/json only. A cross-origin HTML form can send urlencoded, multipart or text/plain and nothing else, so requiring JSON forces a preflight the browser blocks.
  • Same-origin only. A present Origin must match the request's own host - defence in depth behind the JSON requirement, at the cost of one comparison.
  • No closures. A function is a module-level export taking explicit arguments. Serialising closed-over variables to the browser and back is a class of problem worth not having rather than encrypting.

Both content-type rules were measured rather than assumed. A body schema alone still accepts a cross-origin urlencoded form - 200, attacker-controlled fields - and a bounded JSON read alone accepts the text/plain trick where a form's name=value is crafted to parse as valid JSON. Neither is sufficient by itself.

Declaring one

TS
// todos.fn.ts - the suffix is what tells the build to strip this module from the client.
import { serverFn } from "@nifrajs/web/fn"
import { t } from "@nifrajs/schema"
import { createTodo } from "./db/write.ts"

export const addTodo = serverFn(
  { input: t.object({ text: t.string({ minLength: 1 }) }), capabilities: ["db.write"] },
  async ({ text }) => createTodo({ text }),
)

The second argument receives the validated input and the ordinary Nifra Context: c.env, c.clientIp, c.budget, cookies and the capability guard are all there, because this is a route.

Mounting

TS
import { server } from "@nifrajs/core/server"
import { serverFunctions } from "@nifrajs/web/fn"
import * as todos from "./todos.fn.ts"

// "todos" is the namespace segment; each export mounts at /_nifra/fn/todos/<exportName>.
export const app = server().use(serverFunctions("todos", todos))

The namespace becomes a URL segment, so it is constrained to lowercase dot/dash parts. Each branded export mounts at /_nifra/fn/<namespace>/<exportName>; anything in the module that is not a server function is ignored rather than mounted.

One thing worth knowing: the namespace you pass here and the *.fn.ts filename are not statically checked against each other. If they disagree the call 404s, and the generated stub names the mismatch - but the compiler will not catch it for you.

Calling it

TS
// doc-check: skip - a component fragment; the import resolves to the build's generated stub.
import { addTodo } from "./todos.fn.ts"

// On the client this import is a stub: (input) => Promise<Output>. Calling it POSTs.
await addTodo({ text: "write the docs" })

No binding is needed to call one: the client stub is just (input) => Promise<Output>, and a click handler can await it. The types come from the server declaration, so a changed input schema is a compile error at the call site.

Pending, data and error state

What a component usually wants around the call is state, and state is the part every framework spells differently. useServerFn adds exactly that.

TSX
// doc-check: skip - JSX fragment, shown for the shape rather than compiled here.
import { useServerFn } from "@nifrajs/web-react/fn"
import { addTodo } from "./todos.fn.ts"

function AddTodo() {
  const add = useServerFn(addTodo)
  return (
    <button disabled={add.pending} onClick={() => add.call({ text }).catch(() => {})}>
      {add.pending ? "saving…" : "add"}
    </button>
  )
}

The same hook ships for every adapter, each contributing only its subscription primitive:

ImportSubscribes with
@nifrajs/web-react/fnuseSyncExternalStore
@nifrajs/web-preact/fnuseSyncExternalStore
@nifrajs/web-solid/fna signal
@nifrajs/web-vue/fna shallowRef
@nifrajs/web-svelte/fna readable store

The state machine lives once in @nifrajs/web and each binding contributes only its subscription primitive, so "is it pending" has one answer rather than five that drift. Two behaviours are worth knowing:

  • The last call wins. A response that is no longer the newest is discarded rather than written, so a slow first call landing after a fast second cannot overwrite fresh data with stale.
  • call still rejects. The error is recorded for rendering AND the promise rejects, so await behaves normally. A caller that only renders from state should attach .catch(() => ).

data is kept while the next call is in flight, so a rendered list does not blank on every refetch.

The client never sees the module

A *.fn.ts module is not bundled for the browser. The client build replaces it with one stub per export, so the bodies - and every module they import, including your database - stay on the server. Both pipelines do this identically, and the Bun and Vite transforms are held to byte-identical output by a parity test.

nifra dev --bun applies it too. Bun's dev-server bundler accepts plugins only through bunfig's [serve.static] channel, so that command generates a config under .nifra/dev-bun/ carrying the same stub plugin and relaunches itself with --config= pointing at it - verified per launch, refusing to serve if the boundary cannot be proven active. Identical stubs across all three pipelines.

It is a route, so assurance applies

Mounting goes through the ordinary public register(), which is what makes a server function inherit the body cap, schema validation, capability declarations, the effect ledger and nifra assure for free. The starter policy's authenticated-write rule matches any route declaring a domain write, so a function that writes and skips auth fails the check rather than shipping:

BASH
# A server function is a public POST endpoint, so the starter policy already covers it.
nifra assure

✖ POST /_nifra/fn/todos/addTodo (authenticated-write) is missing nifra.authenticated

See effect provenance for what the declaration is checked against, and the verification ladder for where this sits.

Cost

Nothing here touches the kernel or the request path. An app that mounts no server functions pays exactly nothing for the feature - which is the reason it is built as a plugin over the public registration API rather than as a bespoke dispatcher.