Server Functions
Server Functions are async functions that can be called from the client-side. You don't have to implement API endpoints for these functions to be callable as the client can call these functions like a direct reference to the function itself. Server Functions are usable on forms, buttons, submit inputs, and as props to client components. React and the runtime will manage calling these functions on the server-side.
You can expose any "use server"; marked function as a Server Function. Server Functions can be called from the client using the action prop on a <form> element, formAction on a <button> or <input> element, or from a client component passing down the Server Function as a prop to the client component.
If you use TypeScript, all of your Server Functions are type-safe and you will get type errors if you pass the wrong parameters to a Server Function or if you try to access a property on the response object that doesn't exist.
You can define Server Functions inline in your components as any other event handler in a React component.
export default function App() {
async function action() {
"use server";
console.log("Server Function called!");
}
return (
<form action={action}>
<button type="submit">Submit</button>
</form>
);
}
Progressive enhancement: if you have JavaScript enabled, the Server Function will be called using the
fetchAPI and the response will be used to update the DOM in a React transition. If you don't have JavaScript enabled, the Server Function will be called using a regular HTTP request.
You can even define your Server Functions inline your JSX.
export default function App() {
return (
<form
action={async () => {
"use server";
console.log("Server Function called!");
}}
>
<button type="submit">Submit</button>
</form>
);
}
Server Functions are able to access all variables in scope, including references to props and any variables available in the scope of the Server Function from the last render of the component as Server Functions are mapped on each render of the server component.
If you want to keep your Server Functions in separate modules, you can do so by using the "use server"; pragma at the top of your module. All exported functions from a Server Function module will be usable as Server Functions.
"use server";
export async function action() {
console.log("Server Function called!");
}
You will get all form data as an object as the first parameter to your Server Function.
export default function App() {
async function action(formData) {
"use server";
console.log(`Server Function called by ${formData.get("name")}!`);
}
return (
<form action={action}>
Your name: <input name="name" />
<button type="submit">Submit</button>
</form>
);
}
import { useActionState } from "@lazarv/react-server/router";
export default function App() {
async function action(formData) {
"use server";
console.log(`Server Function called by ${formData.get("name")}!`);
}
const { error } = useActionState(action);
return (
<form action={action}>
Your name: <input name="name" />
<button type="submit">Submit</button>
{error && <p>{error.message}</p>}
</form>
);
}
To access the action state, you can use the useActionState hook. The useActionState hook takes the Server Function as the first parameter and returns an object with the following properties:
formData: the form data objectdata: the data object returned by the Server Functionerror: the error object if the action failedactionId: the action ID of the current action
You can also pass Server Function references to client components as props and call them from the client component as any other async function.
"use client";
export default function MyClientComponent({ action }) {
const handleClick = () => {
action({ name: "John" });
};
return <button onClick={handleClick}>Click me!</button>;
}
import MyClientComponent from "./MyClientComponent";
export default function App() {
async function action({ name }) {
"use server";
console.log(`Server Function called by ${name}!`);
}
return (
<div>
<MyClientComponent action={action} />
</div>
);
}
Server Functions called from client components can return data to the client component directly. This can be useful if you want to display a message to the user after the Server Function has been completed or if you want to display an error message if the Server Function failed.
"use client";
export default function MyClientComponent({ action }) {
const [response, setResponse] = useState(null);
const handleClick = async () => {
const response = await action({ name: "John" });
setResponse(response);
};
return (
<>
<button onClick={handleClick}>Click me!</button>
{response && <p>{response.message}</p>}
</>
);
}
import MyClientComponent from "./MyClientComponent";
export default function App() {
async function action({ name }) {
"use server";
console.log(`Server Function called by ${name}!`);
return { message: `Hello ${name}!` };
}
return (
<div>
<MyClientComponent action={action} />
</div>
);
}
You can combine inline "use server" functions and inline "use client" components in the same file. This is useful when a Server Function and the client UI that consumes it are closely related.
import { useState, useTransition } from "react";
const greeting = "Hello";
async function greet(name) {
"use server";
return `${greeting}, ${name}!`;
}
function Greeter() {
"use client";
const [message, setMessage] = useState("");
const [, startTransition] = useTransition();
return (
<div>
<button
onClick={() =>
startTransition(async () => {
setMessage(await greet("World"));
})
}
>
Greet
</button>
{message && <p>{message}</p>}
</div>
);
}
export default function App() {
return <Greeter />;
}
Both the Server Function and the client component are extracted into separate modules automatically. The Server Function can capture variables from the module scope, and the client component can call the Server Function directly.
A Server Function can define an inline "use client" component and return it as rendered JSX. The client component will be serialized through the RSC protocol and hydrated on the client, making it fully interactive.
import { useState, useTransition } from "react";
async function createCounter(initialCount) {
"use server";
function Counter({ start }) {
"use client";
const [count, setCount] = useState(start);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
return <Counter start={initialCount} />;
}
function Shell() {
"use client";
const [content, setContent] = useState(null);
const [, startTransition] = useTransition();
return (
<div>
<button onClick={() =>
startTransition(async () => setContent(await createCounter(0)))
}>
Create Counter
</button>
{content}
</div>
);
}
export default function App() {
return <Shell />;
}
The createCounter Server Function defines a Counter client component, renders it with the given props, and returns the element. The framework extracts the nested directives into separate modules through a chain of virtual modules, so everything works from a single file.
You can define inline "use server" functions inside a file that already has a top-level "use client" directive. The Server Functions are automatically extracted from the client module, so you can keep related server logic and client UI together in a single file.
"use client";
import { useState, useTransition } from "react";
export default function TodoApp() {
const [items, setItems] = useState([]);
const [, startTransition] = useTransition();
async function addItem(text) {
"use server";
return { id: Date.now(), text };
}
return (
<div>
<button
onClick={() =>
startTransition(async () => {
const item = await addItem(`item-${items.length}`);
setItems((prev) => [...prev, item]);
})
}
>
Add
</button>
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</div>
);
}
The file is treated as a client component because of the top-level "use client" directive, but the addItem function is extracted into a separate server module. This works the same way as defining the Server Function in a standalone "use server" file — the runtime handles the extraction automatically.
Server Functions accept arguments from the client. Any payload that flows from a browser into a server handler is, by definition, untrusted. The runtime offers an opt-in API — createFunction — that lets you declare a parse/validate contract for each runtime argument slot. The contract is enforced at the protocol layer: the decoder runs your parse and validate slot-by-slot during the args walk, and rejects the request on the first failure with HTTP 400 — before the handler runs, before any business logic touches the value, before any allocation that the handler might assume is bounded.
Bare "use server" functions without createFunction keep working unchanged. The validation contract is opt-in and additive.
Wrap an action handler with createFunction(spec)(handler). Pass the per-slot validate specs as an array — the most common shape:
import {
createFunction,
formData,
file,
} from "@lazarv/react-server/function";
import { z } from "zod";
export const updateProfile = createFunction([
z.string().email(),
z.string().min(2).max(80),
])(async function updateProfile(email, name) {
"use server";
await db.users.update({ email, name });
});
Index i in the array describes the runtime arg slot i — what the client puts on the wire at position i. It is not the handler's signature parameter i. Bound captures from .bind(...) or inline closures are not part of this contract — they're hidden values, integrity-protected by the AEAD action token, and never validated as user input.
The runtime accepts any Standard Schema — Zod, Valibot, ArkType — duck-typed via safeParse / assert / parse. You don't import a particular library from @lazarv/react-server; bring whichever schema library your project already uses.
When you also need pre-validate parsing, switch to the object form: createFunction({ validate, parse }). Both fields are arrays indexed by the same slot. parse[i] runs after the value tree is materialized but before validate[i] — the right place for type coercion that schemas can't easily express:
export const setLimit = createFunction({
parse: [(v) => Number(v)],
validate: [z.number().int().min(1).max(1000)],
})(async function setLimit(limit) {
"use server";
await db.config.set({ limit });
});
A throwing parse surfaces as DecodeValidationError(reason: "parse_failed"); a failing validate surfaces as reason: "validate_failed". The decoder aborts on the first failure — the next slot is never touched, the handler is never called.
When only some slots need validation or parsing, use the noop export as a placeholder rather than reaching for sparse-array syntax ([, , schema]) or explicit undefined:
import { createFunction, noop } from "@lazarv/react-server/function";
export const update = createFunction([noop, noop, z.number().int()])(
async function update(a, b, count) {
// a: unknown, b: unknown, count: number — slots 0/1 are accepted
// unvalidated; slot 2 is the only one constrained.
"use server";
}
);
Same idea in the object form when parse and validate need different gap patterns:
createFunction({
parse: [noop, noop, (v) => Number(v)],
validate: [z.string(), noop, z.number()],
})(handler);
// handler: (a: string, b: unknown, c: number)
noop is the identity transform — at runtime it's the same as omitting validation/parsing for that slot. Slots typed noop resolve to unknown in the inferred handler signature.
FormData arguments are common when uploading files or submitting progressive-enhancement forms. Schema libraries can describe object shapes, but they describe materialized values — by the time a schema runs, the file is already buffered. formData is wire-aware: the decoder enforces declared keys and per-entry constraints during the FormData walk, so size and MIME limits are checked synchronously against Blob.size / Blob.type before the entry is added to the result.
import {
createFunction,
formData,
file,
} from "@lazarv/react-server/function";
export const upload = createFunction([
formData({
title: z.string().min(1).max(120),
photo: file({
maxBytes: 5 * 1024 * 1024,
mime: ["image/png", "image/jpeg"],
}),
}),
])(async function upload(form) {
"use server";
const title = form.get("title");
const photo = form.get("photo");
// photo is already size- and MIME-checked.
});
formData(shape, options?) takes the entry shape as its first argument and an optional config bag as its second. The shape is required; the only option today is unknown, but the slot is reserved for future per-form constraints.
The decoder looks up declared entries by exact key. There is no prefix scan, so an attacker-injected entry like 5_role=admin cannot land in the FormData your handler reads.
The unknown policy — passed as formData(shape, { unknown: "drop" }) — controls what happens to entries that are not declared:
"reject"(default, recommended): an unknown entry fails the decode withDecodeValidationError(reason: "unknown_entry"). Defends against attacker-injected fields."drop": silently skip undeclared entries. Useful when the form includes React-managed hidden fields the schema doesn't enumerate."allow": copy undeclared entries through unvalidated. Escape hatch — documented as unsafe.
file({...}) and blob({...}) accept maxBytes (per-entry size cap), mime (allowlist of acceptable MIME types), an optional sync validate(value) callback for custom checks (e.g. magic-byte detection), and optional: true to allow a missing entry. File.type is browser-supplied and trivially spoofable — combine MIME with magic-byte detection in validate for hard guarantees.
The Flight protocol carries more than just primitives, objects, and FormData. For each remaining wire type where a Standard Schema isn't enough on its own — usually because validation needs to bound resource consumption, type-check against a platform constructor, or wrap an async source — there's a dedicated wire-aware helper:
import {
createFunction,
arrayBuffer,
typedArray,
map,
set,
stream,
asyncIterable,
iterable,
promise,
} from "@lazarv/react-server/function";
| Helper | Wire tag | Handler-side type | Why it's wire-aware |
|---|---|---|---|
arrayBuffer({ maxBytes }) | $AB | ArrayBuffer | Byte-length cap before the handler observes the buffer |
typedArray({ ctor, maxBytes }) | $AT | InstanceType<C> (e.g. Float32Array) | Constructor allowlist via instanceof; type narrowed in inference |
map({ maxSize, key, value }) | $Q | Map<K, V> | Size cap; inner key/value schemas |
set({ maxSize, value }) | $W | Set<T> | Size cap; per-item schema |
stream({ maxChunks, maxBytes }) | $r / $b | ReadableStream | Bounds enforced as the handler consumes the stream |
asyncIterable({ maxYields, value }) | $x | AsyncIterable<T> | Yield ceiling + per-yield validation |
iterable({ maxYields, value }) | $X | Iterable<T> | Same, sync flavor |
promise(value) | $@ | Promise<T> | Resolved-value validation through the schema |
A few examples in context:
// Binary upload arriving as a typed array
export const upload = createFunction([
typedArray({ ctor: Uint8Array, maxBytes: 5 * 1024 * 1024 }),
])(async function upload(bytes) {
"use server";
// bytes: Uint8Array, already size-checked
});
// Bounded Map argument
export const lookup = createFunction([
map({ maxSize: 100, key: z.string(), value: z.number() }),
])(async function lookup(table) {
"use server";
// table: Map<string, number>, capped at 100 entries
});
// Streaming upload with a per-stream byte cap
export const ingest = createFunction([
stream({ maxBytes: 50 * 1024 * 1024, maxChunks: 8192 }),
])(async function ingest(stream) {
"use server";
// stream: ReadableStream — wrapped to error if either ceiling is exceeded
for await (const chunk of stream) { … }
});
// Bounded async iterable with per-yield validation
export const events = createFunction([
asyncIterable({
maxYields: 1000,
value: z.object({ type: z.string(), payload: z.unknown() }),
}),
])(async function events(stream) {
"use server";
// stream: AsyncIterable<{type: string, payload: unknown}>
});
Each helper rejects at decode with a distinct reason code:
wire_shape_mismatch— slot's wire tag doesn't match the spec (e.g. expected$ATgot a primitive)max_bytes_exceeded/max_size_exceeded/max_chunks_exceeded/max_yields_exceeded— resource ceiling trippedvalidate_failed— inner Standard Schema rejected a key, value, or yielded item
For streams and iterables, the bound is enforced as the handler consumes them, not at decode time. The decoder wraps the materialized stream / iterable; once the handler reads past the ceiling the wrapper errors instead of yielding more data. That matters because Flight materializes stream chunks up-front at decode — the wrap is what lets per-slot bounds tighten beyond the global maxStreamChunks ceiling.
typedArray takes the actual constructor reference (not a string name): typedArray({ ctor: Float32Array }) infers as (samples: Float32Array) in the handler, and the runtime check is value instanceof Float32Array. Pass an array of constructors for a union: typedArray({ ctor: [Uint8Array, Uint8ClampedArray] }).
In development, every Server Function call without a createFunction contract logs a one-time warning to the server console:
Server function
src/actions/upload.mjs#uploadcalled without validation — wrap the export withcreateFunction({...})(handler)from@lazarv/react-server/function(setconfig.serverFunctions.strict = falseto silence) 🛡️
The action id format matches what the runtime uses internally (<modulePath>#<exportName>), so you can grep straight to the source. The warning is per-action and per-process: each unwrapped action is named once, no matter how many times it's called.
This is purely a dev guardrail — built/started servers never log it. If you're migrating an existing codebase incrementally and don't want the noise, set config.serverFunctions.strict = false:
// react-server.config.mjs
export default {
serverFunctions: { strict: false },
};
When validation fails, the runtime returns HTTP 400 and sets the x-react-server-action-error header to the failure reason (validate_failed, parse_failed, unknown_entry, max_bytes_exceeded, mime_not_allowed, missing_entry, wire_shape_mismatch, custom_validate_failed, duplicate_entry). The client receives a generic error — schema diagnostics are not forwarded to avoid leaking expected-shape details that aid attackers. Diagnostics are written to the server log via logger.warn for operator visibility.
If you need user-facing error messages tied to specific fields, validate the same payload again inside the handler and return a structured error object: that path is yours to design and is what useActionState is built around.
Server Function calls travel a round-trip across the network: the runtime emits a reference to the function, the client invokes it, and the runtime resolves and runs the function on the server. Two things need to be tamper-evident across that round-trip — the identity of the action being called, and any captured values that travel with it.
Every Server Function reference is encoded as a single AES-256-GCM token. The token's plaintext is the pair (actionId, bound), where bound is the array of values that were captured by .bind(...) or by an inline closure at render time, or null for unbound actions. The ciphertext is what the client sees on the wire, and what it sends back when the action is invoked.
function ProfilePage({ userId }) {
return (
<form
action={async (formData) => {
"use server";
await db.users.update(userId, formData.get("name"));
}}
>
…
</form>
);
}
In the example above, userId is captured by the inline Server Function. The runtime emits a token that bundles both the action's identity and the captured userId together. The client never sees userId in plaintext, never round-trips it as a separate value, and cannot edit it without invalidating the token's authentication tag — which causes the call to be rejected.
This applies to every form of Server Function:
- Module-scope
"use server"functions (no captures → bound isnull) - Inline closures with render-time captures
- Server-side
.bind(...)usage to partially apply a Server Function - Bound server references passed as arguments to other Server Functions
There is one configuration property to set: a stable encryption secret. Without it, the runtime generates an ephemeral key per process — fine for development, but tokens won't survive a restart or be valid across multiple instances of the server.
// react-server.config.mjs
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET, // 32-byte hex, or any string (hashed to 32 bytes)
},
};
The key resolves in this order:
REACT_SERVER_FUNCTIONS_SECRETenvironment variableREACT_SERVER_FUNCTIONS_SECRET_FILEenvironment variable (path to a file)serverFunctions.secretin the runtime configserverFunctions.secretFilein the runtime config (path to a file)- A random ephemeral key (development fallback only)
To rotate without invalidating in-flight tokens, list the prior keys under serverFunctions.previousSecrets (or serverFunctions.previousSecretFiles). Incoming tokens are tried against the primary key first and then each previous key in turn:
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET,
previousSecrets: [process.env.ACTION_SECRET_PREVIOUS],
},
};
The same rotation applies to both action identity and bound captures — they share one key.
When a bound Server Function is exposed to a client component and the client calls .bind(...) to add more arguments, those additional arguments are treated as runtime arguments, not as new captures. They travel as ordinary call args (alongside whatever the user passes at invocation), and they are not included inside the encrypted token. This is intentional: only the original server-emitted bound is integrity-protected. Client-added arguments are effectively just the arguments the client chooses to send at call time.
If your application has no Server Functions at all, set serverFunctions: false in the runtime config. The runtime then refuses to decode any action request: incoming POST/PUT/PATCH/DELETE traffic is not parsed for action calls, the manifest is not queried, and the request flows through to normal page rendering as if the action endpoints didn't exist. This eliminates the action-dispatch surface as an attack target and removes a small amount of per-request overhead.
// react-server.config.mjs
export default {
serverFunctions: false,
};
In production builds with no "use server" modules and no inline Server Functions, the runtime auto-detects an empty manifest and applies the same gate without explicit configuration. The explicit false is for cases where you want the gate even before a build (development), or as a deliberate posture choice regardless of what the build sees.
Remote Components rendering can be force-disabled in the same defense-in-depth spirit via remoteComponents: false — see Disabling Remote Components rendering on the Micro-frontends page.
The token covers the values in the bound array. If a captured value is a File or Blob, the token covers the slot reference that points to the binary content, but not the binary content itself. Servers that bind server-constructed binary data into a closure should be aware that an attacker controlling the upload could substitute the binary content even with a valid token. In practice, captured File/Blob values are rare in Server Function closures.