Readiness: Implemented
Build an app
Server actions
A "use server" export is a function the browser can invoke over the network,
and it is a public HTTP endpoint from the moment the build contains it. So this
page is as much about what uf refuses as about what you write: the grammar the
arguments have to fit, the three independent things standing between the
endpoint and a cross-site call, and the one guarantee uf cannot give you —
that whoever made the call was allowed to.
What you will be able to do: write and call a server action, submit a form to one, know which arguments may cross and what is refused, and make an action authorize itself.
What you need first: Server Components for where
"use server" sits in the module graph, and Async React
for the transitions an action runs in.
Writing one
A module whose first statement is "use server";. Every export is an action,
and every export has to be an async function.
"use server";
// @flow
// app/counter/_actions/tally.js
import { cookies } from "@uniflowed/server";
import { tallyFor } from "./ledger.js";
/** Record a count and answer with what the server made of it. */
export async function recordCount(count: number): Promise<{|
readonly total: number,
readonly visitor: string,
|}> {
return {
total: tallyFor(count),
visitor: cookies().get("visitor") ?? "anonymous",
};
}
Two things in that file are the point of it. cookies() comes from
@uniflowed/server, which imports node:async_hooks; ./ledger.js is an
ordinary module with no directive of its own, standing in for the database
handle a real action reaches for. Neither reaches the browser.
@uniflowed/vite answers the client build with a generated module in place of
this file — one reference per callable export — so the body, its imports, and
everything only they reached stay where they were written. The graph agrees with
the bundler about it: an import from the client graph into a "use server"
module is coloured server, so a database handle reached only through an action
is not in the client graph at all.
Calling one
An ordinary import and an ordinary call.
"use client";
// @flow
// app/counter/_components/Counter.js
import * as React from "@uniflowed/react";
import { useState } from "@uniflowed/react";
import { recordCount } from "../_actions/tally.js";
export default component Counter() {
const [count, setCount] = useState<number>(0);
const [recorded, setRecorded] = useState<string>("");
return (
<div>
<button type="button" onClick={() => setCount(count + 1)}>
add one
</button>
<button
type="button"
onClick={() => {
void recordCount(count).then((answer) => {
setRecorded(`${String(answer.total)} ${answer.visitor}`);
});
}}
>
record
</button>
<output>{recorded}</output>
</div>
);
}
On the server that import is the function. In the browser it is a reference:
createServerReference(id, "app/counter/_actions/tally.js#recordCount"), which
is an id and a fetch. You never write one — @uniflowed/vite emits one per
callable export, in the client graph only.
Flow reads the module, not the reference. The bundler's substitution is not
something the checker sees, so recordCount("nine") against
recordCount(count: number) is a uf check error rather than a 400 with
nothing in it. That half needs nothing from the runtime, and
tests/type-tests/server-actions.js exists so that a change which breaks it is
caught by a test rather than by a user.
Handed down by a Server Component
Under React Server Components a Server Component may also pass an action to a client component as a prop, bound to arguments or not. The client component then does not import the action module at all:
return (
<article>
<h1>{note.text}</h1>
<p>{`by ${note.author}`}</p>
<DeleteNote remove={deleteNote.bind(null, note.id)} />
</article>
);
export component DeleteNote(remove: () => Promise<void>) {
const [pending, startTransition] = useTransition();
return (
<button
type="button"
disabled={pending}
onClick={() => {
startTransition(async () => {
await remove();
});
}}>
Delete
</button>
);
}
In the rsc graph each callable export of a "use server" module is
registered with React as a server reference under its id. Flight sends the
reference and its bound arguments, and React's browser client turns it into a
function that makes the same call an imported reference makes: the same JSON
wire, grammar, Origin check and deployment header, with the bound arguments
first. A function that is not a callable export of a "use server" module is
not registered, and Flight still refuses to send it — except an inline
"use server" closure in a server module, which the build rewrites into a
hoisted export and registers through bindServerClosure. A closure written in
a "use client" module is not registered.
Bound arguments are the visitor's to change. They travel to the browser in
the payload and come back in the call, in plain text. deleteNote.bind(null, note.id) does not stop anyone from calling deleteNote with another id, so
the action checks the id it is given, as it would any argument
(An action authorizes itself).
Forms
A reference is an ordinary async function, which is all React 19's form APIs ask for. For pending indicators and reversible optimistic feedback around those actions, see Async React.
"use server";
// @flow
// app/counter/_actions/notes.js
import { maxLength, minLength, object, pipe, safeParse, string, trim } from "@uniflowed/validator";
import { put } from "@uniflowed/validator/plain-object";
const Note = object({ note: pipe(string(), trim(), minLength(1), maxLength(80)) });
/** What `useActionState` holds between submits. */
export type NoteState = {| readonly saved: string | null, readonly problem: string | null |};
export async function submitNote(previous: NoteState, form: FormData): Promise<NoteState> {
const fields: { [string]: string } = {};
for (const [name, value] of form.entries()) {
if (typeof value === "string") {
put(fields, name, value);
}
}
const parsed = safeParse(Note, fields);
if (!parsed.ok) {
// The previous `saved` survives a rejected submit, which is why
// `useActionState` hands an action the previous state at all.
return { saved: previous.saved, problem: "a note is 1 to 80 characters" };
}
return { saved: parsed.value.note, problem: null };
}
"use client";
// @flow
// app/counter/_components/NoteForm.js
import * as React from "@uniflowed/react";
import { useActionState } from "@uniflowed/react";
import { useFormStatus } from "react-dom";
import { type NoteState, submitNote } from "../_actions/notes.js";
const NO_NOTE: NoteState = { saved: null, problem: null };
component SaveNote() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
save note
</button>
);
}
export default component NoteForm() {
const [note, saveNote] = useActionState<NoteState, FormData>(submitNote, NO_NOTE);
return (
<form action={saveNote}>
<input name="note" maxLength={80} defaultValue="" />
<SaveNote />
<output>{note.saved ?? note.problem ?? ""}</output>
</form>
);
}
<form action={fn}> hands the action a FormData; useActionState hands it
the previous state and then the FormData. useFormStatus has to be read from
a component inside the form, which is React's rule rather than uf's — a button
that knows whether its own submit is in flight cannot be the component rendering
the <form>.
The schema is on the server because that is the only place it is a check.
<input required maxlength="80"> is a convenience for the person typing, and
the request that reaches the function was not necessarily made by that document.
A rejected submit is a value on the state rather than an exception: the endpoint
answers a thrown action with a 500 and nothing in it, and "your note is too
long" is not an internal error.
The same form works before its page has hydrated. See the next section.
Before the page hydrates
A form bound to a server action is a real HTML form in the document the server
sends. React asks the action for $$FORM_ACTION while it renders to HTML, and
uf's actions answer, so the markup above comes out as
<form action="" encType="application/x-www-form-urlencoded" method="POST">
<input type="hidden" name="$uf_ref_0"/>
<input type="hidden" name="$uf_id_0" value="…the action's id…"/>
<input type="hidden" name="$uf_bound_0" value="{"args":[…the state…]}"/>
<input type="hidden" name="$ACTION_KEY" value="k…"/>
<input name="note" maxLength="80" value=""/>
…
</form>
A person who submits it before the JavaScript has arrived — a slow network, a script blocked, a click in the first second — sends an ordinary browser post to the page's own URL. The server runs the action with the previous state and the form, and answers:
| The action | The answer |
|---|---|
was called by a useActionState | the page, rendered again with the action's result as that hook's state; the browser hydrates with the same state |
was a plain <form action={fn}> | 303 See Other back to the page, so a reload does not submit twice |
called redirect() | 303 See Other to where it pointed |
called notFound(), unauthorized() or forbidden() | 404, 401 or 403 with a single fixed line of text. No page is rendered around it |
| threw anything else | 500, with nothing of the exception in it |
Once the page has hydrated nothing about this runs: React calls the reference, and the call is the JSON request below.
The native post is a separate door, and a narrower one. It accepts only
application/x-www-form-urlencoded — never multipart/form-data, so a file
still cannot cross — and only a post carrying a $uf_ref_ field; anything else,
including an ordinary form posted to a route handler at the same URL, is left
alone with its body unread. It refuses with a 403 unless Origin equals
Host, and unless Sec-Fetch-Site is same-origin when the browser sends
it. The id, the bound state and every field go through the same lookup and the
same grammar as the JSON call, with the same limits. What it does not have is
the JSON call's custom header and content type, because a native post cannot
carry them — so it rests on Origin alone, which every current browser sends
on a POST. docs/security.md
has the row.
What the state has to be. useActionState's initial state travels in the
form, so it has to be something that can cross: useActionState(save, null)
or a plain object, not a Date or undefined. A state that cannot cross
costs the pre-hydration submit and nothing else — React logs "Failed to
serialize an action for progressive enhancement" on the server and writes the
form it writes for any client action.
What may cross
A server action's arguments are plain JSON data, plus at most one form. One
object, {"args": [...]}, of valid UTF-8. Each value is null, a boolean, a
finite number, a string, an array of those, or a plain object whose keys are
ordinary strings. The result comes back under the same grammar, plus undefined
for an action that returns nothing — and never a form, because a form is
something a browser submits and not something a server answers with.
| Bound | Value |
|---|---|
| Request body | 1 MiB |
| Positional arguments | 16 |
| Nesting depth | 24 |
| Values in one payload | 10,000 |
| Fields in one submitted form | 256 |
| Length of one form field name | 128 |
And what is refused, each for a reason:
- Functions, symbols, bigints,
undefinedas an argument. JSON has no spelling for them, and an action declared to take one would be taking something the caller cannot send. NaNand the infinities.JSON.stringifywritesnullfor each, so accepting one would mean the action was called with a different number from the one the caller passed.- Class instances,
Map,Set,Date,RegExp, typed arrays, React elements. Each would need a tag in the payload naming a constructor to call, and a tag naming a constructor is what every deserialisation CVE is made of. An action that wants a date takes an ISO string and parses it, where the parse is yours and is checked. __proto__,constructorandprototypeas keys.JSON.parsegives__proto__an own property rather than changing the prototype, so a payload carrying one is not itself pollution — it becomes pollution in the first line of application code that spreads or merges it. Refused in one place rather than left for every action to remember.- A value that refers to itself, or twice to the same object. The alternative is a marker meaning "this is the object you saw earlier", which is a reference format by another name.
- A file in a form. Bytes here would be base64 in a JSON string with no ceiling of their own, and an upload wants a content type, a streaming read and a size limit that are not this module's.
- A second form in one call. The envelope names the form's position once.
- Symbol keys.
JSON.stringifydrops them silently, so the payload would arrive missing a property nobody could see was missing.
The same grammar runs on both sides, so the browser refuses to send what the
server would refuse to receive. A bad argument is
argument 2.createdAt is a class instance … thrown at the call site with a
stack that reaches your component, rather than a 400 with nothing in it.
A submitted form does not widen any of that. It travels beside the values —
{"args": [null, null], "form": {"at": 1, "entries": [["note", "hi"]]}} — never
inside one. A tag in the value tree would be a payload saying which constructor
to call; outside the tree, at names a position and not a type, the slot it
names must hold null, and the decoder's only constructor is FormData, fixed
in the code and never named by the payload.
The call, and the endpoint
A POST to the page's own URL, with uf-action: <id>,
content-type: application/json, credentials: same-origin and cache: no-store.
The URL is the page rather than a path uf reserves so that the middleware
guarding that path runs above the call exactly as it does above the page — no
new route to collide with a project's own, and no second spelling of "which
guard applies here". Every host runs the action dispatcher in the same place:
below the middleware, above the route handlers. A guard that answers is the
whole request; a request naming no action is declined outright, so a
$route.js at the same path still gets it.
Every answer is one of these, and every refusal carries the same fixed body — the status says what a caller may usefully do differently, and nothing says what went wrong:
| Status | When |
|---|---|
200 | The action ran and its result crossed |
400 | The payload is not this grammar, including a body that is not UTF-8 |
403 | Origin is absent, unparseable, or not equal to Host |
404 | No row has that id — malformed, well-formed but unknown, or not callable |
405 | Not a POST. Carries Allow: POST |
413 | The body is larger than an action accepts |
409 | The call names a different build from the one answering, as a tab left open across a deploy does. It is refused in front of the endpoint, with a plain-text body of its own. Nothing runs, and the reference loads the page again rather than throwing (Deploy) |
415 | The content type is not exactly application/json |
500 | The action threw, or its result cannot cross |
Cross-site calls cannot happen by accident, three times over. uf-action is
not a header a simple request may set, so a cross-origin caller needs a
preflight and nothing answers one. application/json is not a content type a
<form> can produce. And Origin must be present and must equal Host. Any
one of the three would do; all three are there because the cost is three ifs
and the failure is somebody's account.
Origin is compared against Host and against nothing else, because Host is
what the browser was talking to and X-Forwarded-Host is a string the caller
wrote. A proxy in front of a uf application has to preserve Host, and one
that rewrites it turns every action call into a 403 rather than into a hole.
The port is part of the comparison, so :5173 and :5174 on one machine are
two origins — which is what a cookie already thinks.
An action authorizes itself
The middleware running above the call is a convenience and it is not a
boundary, because the URL is the caller's to choose: a client that wants to
skip the guard on /dashboard posts the same id to /.
A server action is the unit of authorization, the way a route handler is. A
"use server" function that relies on a path guard having run is a function
with a hole in it. Read the session inside the action, decide inside the action,
and treat every argument as something a stranger sent — because one may have.
The id, and what is dialable at all
An action id is HMAC-SHA256(build id, module ‖ export ‖ kind), rendered as 64
lowercase hexadecimal characters. Four properties follow, and each answers a
failure that has produced a real CVE in another React framework.
- Ids are not guessable. The whole repository plus a published sourcemap is not enough to derive the id of a function the interface does not offer, because the build id is the key and is never published — the manifest carries only a fingerprint of it.
- Ids do not survive a rebuild. A new build id changes every action id, so an id captured from an older, more permissive deployment cannot be replayed.
- Only actions a client boundary can reach have a row. An action nothing can hand across a boundary is recorded in the registry and never written to the manifest, so it has no endpoint. There is no path from a request to a module specifier, a file name or an export name: the id selects a row, and the row was decided at build time.
- Every failed lookup answers identically. A malformed id, a well-formed id
nobody has, and an id naming something not callable are all
404with the same body — and the table is scanned in full with a constant-time comparison, so the time taken does not answer either. A malformed payload is decided before the id is even looked up, so a bad body cannot be used to test a guess.
Set UF_BUILD_ID when a build has to be reproducible. It is an HMAC key, so it
has to be at least 8 bytes and at most 256; a value outside that is ignored and
one is generated from operating-system entropy instead, which is also what
happens when the variable is unset.
Redirects and the other routing calls
redirect(), notFound(), unauthorized() and forbidden() work in an action
just as they do in a loader. They are how page code says where the visitor goes
next, so they are not failures. A mutation that ends in redirect("/notes")
behaves the same whether or not the page has hydrated:
| Called | Before hydration (the form post) | After hydration (the JSON call) |
|---|---|---|
redirect(to) | 303 See Other to to | the endpoint answers 204 with Location. The reference moves the router on screen there, as router.push would, or loads the document when to is another origin. The call then resolves with undefined |
notFound() | 404 | 404. The reference throws NotFoundError, and the route's error boundary shows the not-found page a loader's notFound() would, for the same URL |
unauthorized() | 401 | 401. The reference throws UnauthorizedError, which the route's error boundary shows as "unauthorized" |
forbidden() | 403 | 403. The reference throws ForbiddenError, which the boundary shows as "forbidden" |
React passes an error thrown by an action called from a form or a transition to
the nearest error boundary. unauthorized() and forbidden() reach your
$error.js, which can check what it was given. notFound() does not: the
boundary asks the router for the nearest $not-found.js of the URL on screen
instead, and the URL stays as it is. A page rendered from its modules resolves
that page in the browser. A page React Server Components rendered fetches the
URL's not-found payload from the server. If neither can be had, the boundary
shows the error view.
The JSON call's answer carries a uf-action-outcome header naming the kind.
That is how notFound()'s 404 is kept apart from the 404 for an id nobody
has. Only the kind crosses, plus the address for a redirect. None of these is
reported as a failure.
Nothing about a failure comes back
An action that throws anything but the four routing calls above is a 500
with a fixed body. The exception goes to the
host's error reporting with the module and export named; a message, a name or a
stack in the response is your application's internals published to whoever
asked for them. This is true in development too, because uf dev and uf build
have to agree about what the endpoint answers, and the terminal is where you
read the exception anyway.
On the browser side a failed call is a ServerActionError carrying the status
and the action's build-time module#export name, and nothing else — because
nothing else came back.
Draft mode
A server action is one of the two places draftMode().enable() is allowed, the
other being a route handler. Both own a response, and a cookie is part of one.
That is what makes a CMS whose "preview" is a button rather than a link work.
The refusals stay outside that scope: a 403 for a cross-origin call carries no
cookie, and nothing in that path ran that could have asked for one. See
Answering requests
for the rest of draft mode.
The types the wire needs
Flow reading a declaration tells you createUser(36) is wrong. It cannot tell
you whether string is a thing that can cross a network — and
createUser(when: Date) type-checks perfectly at every call site and then
arrives on the server as {}.
uf prepareuf prepare writes server-actions.js: every callable action of the project
keyed by module#export, with its real type read off the declaring module
through import typeof, plus two lines that hold every action's arguments and
result against the wire grammar over the whole project at once. An action taking
a callback, or returning a Map, is a uf check error naming the offending
type.
The file is types only — every import in it is erased — so importing it adds nothing to a bundle. It is for code holding an action's name rather than the action: a dispatch table, a test harness, an RPC client. A client component that imports the action already has its type.
What is not here
- Uploads. A
Fileentry is refused by name at the call site. Multipart parsing is its own attack surface and is deliberately absent; use a route handler. - An inline
"use server"closure in a"use client"module. One written in a server module is hoisted to$$ufActionN, its captured variables are sealed with the build id, and a Client Component can call it. One written in a client module cannot be sealed without sending the key, so it is not an endpoint. Write it in the server module, or export an async function from a"use server"file. - A form before hydration, with an action that arrived as a prop. The form fields a form writes for a pre-hydration post come from the imported reference. An action handed down as a prop is React's own stub on the server that renders HTML, and a form bound to it cannot be posted until the page has hydrated. Import the action in the client component when the form has to work without JavaScript.
- A reference format. Nothing in an action's arguments can name a function, a module, a class, a prototype, a React element or a reference, and this grammar is not where one grows quietly. The Flight payload a route renders with is a different wire, and naming client modules is its job, not this one's.
- A type error for two forms in one signature. The wire refuses a second form at run time; the types accept the signature, because saying it needs a walk over the parameter tuple that #300 makes answer wrongly rather than fail. A constraint built on it would report every signature as fine, which is worse than none.
- An action cache.
rendering.cache.actionsis refused by name when the config loads rather than accepted and ignored. See Caching. - A static deployment.
build.staticBuildanduf build --adapter staticrefuse a project whose build the browser can call an action in: the client bundle carries the reference either way, so the button would be wired with nothing to answer it.--adapter nodeand the other four server targets run the same application file.
The whole of the split this sits inside — what a "use client" boundary is,
and what is still route-shaped — is in
Server Components.
Where to go next
Data in the browser is next: a cache for what client components fetch once the page has arrived.
Edit this pagedocs/app/guide/server-actions/$page.mdx