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---conditions process, 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 through handleRouteAction. 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 multipart FormData when an argument carries binary (a File upload) — reaches the server's decodeReply intact. Typed values survive: a Date argument arrives as a Date, a File as a File.
  • 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 whose Origin header is present and whose host differs from the request's own host is rejected with 403 before 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 missing Origin is 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 with 413 before it is fully buffered, so an unauthenticated client cannot exhaust memory. Pass Infinity for 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