Server Actions
Server actions are functions marked with "use server" that run on the server but can be called from client components.
Experimental feature. Requires a Node.js server — static hosting alone cannot handle server action requests.
Creating Server Actions
// src/actions/todos.server.ts
"use server";
export async function addTodo(title: string) {
// Database operations, file I/O, etc.
return { success: true };
}
export async function getTodos() {
return [{ id: 1, title: "Example", completed: false }];
}
Passing Actions to Components
Use the props file to wire server actions into your page:
// src/routes/todos/props.ts (fileRouter("src/routes") — see Routing)
import { addTodo, getTodos } from "../../actions/todos.server.js";
export const props = async () => {
const todos = await getTodos();
return { todos, addTodo, getTodos };
};
export type Props = Awaited<ReturnType<typeof props>>;
// src/routes/todos/page.tsx
import { TodoList } from "../../components/TodoList.client.js";
import type { Props } from "./props.js";
export const Page = (props: Props) => <TodoList {...props} />;
// src/components/TodoList.client.tsx
"use client";
import { useState } from "react";
import type { Props } from "../routes/todos/props.js";
export function TodoList({ todos: initial, addTodo, getTodos }: Props) {
const [todos, setTodos] = useState(initial);
const [input, setInput] = useState("");
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
await addTodo(input);
setTodos(await getTodos());
setInput("");
};
return (
<form onSubmit={handleAdd}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit">Add</button>
<ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
</form>
);
}
Directive Placement
File-level — marks all exports as server actions:
"use server";
export async function myAction() { /* ... */ }
Function-level — marks individual functions:
export async function myAction() {
"use server";
// ...
}
"use client" must always be file-level (first line).
Build Transformation
Server actions are transformed differently per build target:
Server build — registerServerReference is added:
import { registerServerReference } from "react-server-dom-esm/server";
registerServerReference(addTodo, "/src/actions/todos.server.ts", "addTodo");
Client/static builds — server actions are excluded.
Hosting
Actions can be dispatched in three ways; the first is what the official demo runs and the right default:
1. The baked gate, worker-free, no --conditions (primary). build.edge
bakes a sealed action gate into dist/server-edge/render.js as
handleRouteAction. A plain Node process dispatches through it:
npm run build
NODE_ENV=production node server.js # plain node — no --conditions
// server.js
import { createRequestHandler, toNodeListener } from "vite-plugin-react-server/request-handler";
import * as bundle from "./dist/server-edge/render.js";
const app = createRequestHandler({
staticDir: "dist/static",
action: bundle.handleRouteAction, // the baked sealed gate
});
⚠️ In this no-
--conditionsprocess, never statically import (or re-export) your built*.server.*modules — they import the react-server transport at load, which asserts the condition and crashes the server at startup. The baked gate owns them; dispatch throughhandleRouteAction. See Edge / Single-Isolate for the full note.
2. A --conditions react-server main thread. Host dist/server directly
under the condition and use the sealed handleServerAction helper below.
3. The dev/worker path. In dev and in worker-based prod serving, the
client environment's rsc-worker dispatches actions; nothing to wire.
See Build Output and Edge / Single-Isolate for server setup details.
Security
A server action is a callable endpoint. The client POSTs a reference id of the
form <base><path>#<export> and the server runs the matching function. That id
is attacker-controllable, so resolving it is a trust boundary. vprs renders
through the ESM transport, which on its own resolves a reference by the path the
id encodes with only a prefix check, no allowlist, so the boundary has to be
enforced one level up.
The baked handleRouteAction gate (hosting mode 1) is sealed the same way —
its allowlist is enumerated at build time and compiled in, so nothing below
needs configuring there. The rest of this section describes the on-disk
helper for hosting mode 2.
handleServerAction seals automatically — you do not pass anything extra.
It exists under --conditions react-server only: executing an action needs
the react-server React build, so under the default condition the same import
throws with setup guidance instead of executing anything. A process without
the condition forwards the request to its RSC worker with
delegateServerActionToWorker (same subpath), or uses the baked gate. A
Web-standard (Request) => Response sibling, handleServerActionRequest, is
what the baked gate itself wraps. In
production it reads the build's server manifest from
<serverRoot>/.vite/manifest.json and resolves the id through a sealed allowlist:
an id the build never emitted is rejected before any import, and the importer is
bound to the manifest's real file, never to a path derived from the id.
import { handleServerAction } from "vite-plugin-react-server/helpers";
// Default build layout (dist/server): nothing else needed — this is sealed.
await handleServerAction(req, res, { projectRoot });
// Custom build output? Point it at the server dir; still no manifest to load.
await handleServerAction(req, res, { projectRoot, serverRoot, base });
serverRoot defaults to <projectRoot>/dist/server; pass it only if you changed
build.outDir. You can also pass serverManifest directly to override (e.g. a
manifest you already hold). The manifest lookup is cached, so it reads disk once.
It fails closed: if no manifest can be found and you are not in the dev path,
the handler refuses the request rather than falling back to unsealed resolution.
A missing manifest is a misconfiguration (wrong serverRoot, or an unbuilt tree),
and silently reopening the boundary would defeat the point. If you hit this in
production, point serverRoot at your build's server dir (or pass serverManifest).
The underlying primitive is exported as vite-plugin-react-server/references
(createSealedServerReferenceGate) if you wire your own handler.
The only unsealed path is development: the Vite dev wrapper sets devOpen, which
resolves actions on demand against live source (project-root resolution with a
traversal guard, no build manifest exists yet). That is not a trust boundary —
it is reachable only via the dev wrapper, never from a missing manifest in prod.
Two more notes:
- A static (no-server) build has no runtime to POST to, so it has no server action surface at all.
- Keep the dev server off untrusted networks (its on-demand resolver is not a trust boundary).
Whatever resolves the id, these stay yours per action:
- Validate and authorize every argument. Treat all of them as untrusted input.
- Do not close over secrets in an action you hand to a client component. The ESM transport serializes a reference for that function and does not encrypt the values it captures, so treat anything an exposed action closes over as visible to the client. Pass an id and look the value up on the server instead.
Wire protocol
Arguments and results ride the transport's own flight codec, end to end:
- The browser's
encodeReply(args)output — plain text, or multipartFormDatawhen an argument carries binary (aFileupload) — reaches the server'sdecodeReplyintact. Typed values survive: aDateargument arrives as aDate, aFileas aFile. - The response is the flight render of
{ returnValue }in the deploy's transport flavor, so a return value may be anything the transport can serialize — not only JSON. - Where the action executes follows the topology: the react-server process decodes and renders in place; the client-condition dev server forwards the raw body to the rsc-worker, which decodes, executes, and renders; the baked edge gate carries its own codec inside the bundle.
A hand-rolled caller (no encodeReply) may POST the JSON envelope
{ "id": "path#export", "args": [...] } instead; its args are taken as-is.
Terminal outcomes
An action ends in exactly one of four ways, and each is a declared wire shape — the flight decoder never receives anything it cannot read:
| Outcome | The action | The response | The router client |
|---|---|---|---|
| return | returns a value | 200, flight { returnValue }; x-vprs-outcome: refresh when it called ctx.refresh() | resolves with the value; with refresh, also refreshes the current route (drops every cached flight, refetches, swaps) |
| redirect | throws redirect(to) | 303 to the target's flight, x-vprs-outcome: redirect | fetch follows the 303; the decoded response IS the target page — delivered to the view, address bar updated, no document load |
| not found | throws notFound() | 404, x-vprs-outcome: not-found, no body | fetches the /404/ route's flight through its own GET path and swaps it in; the address stays |
| error | throws anything else | tagged status (403/413) or 500, x-vprs-outcome: error, flight { error: { message } } | rejects the action call with the server's message — the message only, never the stack |
A return leaves the view alone unless the action asks for a refresh. The
action's trailing context argument (typed ServerActionContext from
vite-plugin-react-server/router) carries refresh(): call it from an
action whose change the current page renders, and the router refetches the
route once the call settles. A read, or a mutation whose caller renders the
returned value itself, does not call it and costs no refetch. Refreshing
after every action made a read called from an effect loop: each refresh
handed the component a new action reference, the effect ran again, and
that call refreshed again.
"use server";
import type { ServerActionContext } from "vite-plugin-react-server/router";
export async function addTodo(text: string, ctx: ServerActionContext) {
await db.insert(text);
ctx.refresh();
}
redirect and notFound are the same signals loaders throw
(vite-plugin-react-server/router), and the mapping is identical in every
execution topology: the react-server process, the rsc-worker delegate, and
the baked edge gate.
Outside the router (a bare createReactFetcher with no callServerHooks),
the defaults still hold the contract: a redirect becomes a document
navigation, not-found rejects with a notFound()-marked error (test with
isNotFound), and a non-flight response is reported as an error instead of
being fed to the decoder.
An error outcome rejects the call; what happens next is the caller's.
React 19 unmounts the whole tree when a transition's rejection goes uncaught
with no error boundary above it: one failed click, blank page, navigation
dead. startClient mounts the root with no default boundary. Catch the
rejection where the action is called (try/catch inside the
transition), or put a boundary above the caller (error.tsx in the router,
createErrorBoundary from vite-plugin-react-server/router/client
elsewhere, see routing.md).
The message is the server's error.message verbatim and nothing else: no
stack, no cause, no paths of vprs's own. Library errors interpolate their
internals into the message itself (a database driver's host and port, a
wrapped config value), so an action that must stay opaque throws its own
error with a message written for the browser.
Endpoint hardening
The sealed gate decides which function a request may resolve — it confirms the target is a real action, not that the caller is allowed to call it or that the request is well-sized. The endpoint itself ships guarded by default; two options adjust the guards:
await handleServerAction(req, res, {
projectRoot,
allowedOrigins: ["https://app.example.com"], // trusted origins beyond same-origin
maxBodyBytes: 8 * 1024 * 1024, // raise the default 1 MiB body cap
});
allowedOrigins(CSRF / cross-origin). A server action is a cookie-bearing, state-changing POST, so a page on another origin can drive a logged-in user's browser to invoke one. Same-origin is enforced by default: a request whoseOriginheader is present and whose host differs from the request's own host is rejected with403before the action runs. The list names additional trusted origins (same-origin stays allowed alongside it);"any"turns the guard off for a deliberately public endpoint. A missingOriginis allowed: a page cannot suppress it on a cross-origin browser POST, so its absence means same-origin or a non-browser client, which is not a CSRF vector.maxBodyBytes(DoS). The handler buffers the POST body in memory to decode the arguments. A body exceeding the cap — 1 MiB by default — is rejected with413before it is fully buffered, so an unauthenticated client cannot exhaust memory. PassInfinityfor an endpoint that genuinely takes unbounded uploads.
Error responses never include a stack trace — only { success: false, error }.
The full error (with stack) goes to the server log via your Vite logger, not to
the client. If you mount your own handler instead of handleServerAction, keep
the same three properties: an origin check, a body cap, and no stack in the
response.
Limitations
- Server actions don't support CSS collection or custom prop functions
- Return values ride the flight renderer, so anything the transport can serialize survives — but a small explicit result object still beats returning your whole model
- Always use
"use server"directive (file-level or function-level) - Never expose sensitive operations without proper authorization