Server Functions
Call server code from React with 'use server': what the framework sends over the network, forms that work before JavaScript, and how to keep them secure.
- 01Write and call Server FunctionsMark functions with 'use server' in a file or inside a Server Component, and call them from a form's action or from an event handler in a Transition.
- 02Explain what crosses the networkSay what the framework builds from 'use server': a reference in the browser, a POST with the arguments written as text, and the answer sent back, and which values can travel.
- 03Keep them safeTreat every Server Function as a public endpoint: check who is signed in, check the input, check permission, and return only what the page needs.
A button that runs code on the server
Adding tea to a cart has to change something on the server: the cart lives in a database there, not in the browser. Until now, that meant writing an API: a web address on the server, code that reads the request, and a fetch call in the browser that sends it. A Server Function lets you skip that. You write an async function that runs on the server, mark it with "use server", and call it from a component like any other function.
Server Functions need a server and a framework that supports them, like Next.js. This page has neither, so the live examples bring a small pretend framework in their tabs. It writes the request exactly the way React does, with React's own code, and lists every request under the result. Only the network and the server are pretend: the "server" is the same page, 0.6 seconds away. Press Add 2 cups:
import { useState, useTransition } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; // In a real app: import { addToCart } from "./actions.js";const { addToCart } = server; export default function App() { const [cups, setCups] = useState(0); const [isPending, startTransition] = useTransition(); function handleClick() { startTransition(async () => { const result = await addToCart("mint", 2); startTransition(() => setCups(result.cups)); }); } return ( <> <p>Mint tea in your cart: {cups} cups</p> <button onClick={handleClick} disabled={isPending}> {isPending ? "Adding…" : "Add 2 cups"} </button> <NetworkLog /> </> );}Open the actions.js tab first. Line 1, "use server", marks every function the file exports as a Server Function. addToCart (lines 6 to 9) adds the cups to the cart and returns how many there are now. In a real app this file runs only on the server, so it could talk to a database directly; here a Map on line 4 stands in for one.
In App.jsx, line 14 calls addToCart("mint", 2) and waits for its answer with await. Line 15 stores the new count. Lines 13 and 15 wrap the work in startTransition, so isPending is true while the request travels and the button says Adding… (the Transitions lesson explains why line 15 needs its own startTransition). In a real app, line 5 would be the whole story: you import addToCart from ./actions.js. Here, line 6 takes it from the pretend framework, which stands in for the build step that a framework runs.
After the click, the network list shows POST actions.js#addToCart with the body ["mint",2], and 0.6 seconds later the answer {"teaId":"mint","cups":2}. The page says Mint tea in your cart: 2 cups. Press it again: a second request, and 4 cups. The browser never ran addToCart's code; it sent the function's name and the arguments, and the server sent back the result.
actions.js#addToCart. Calling it sends a POST with the arguments, ["mint",2]. The server runs the real addToCart and sends back what it returned.At a bakery's order window, you never walk into the kitchen. You read an item number off the menu board, write a slip, and hand it through the window. The kitchen bakes, and passes the bag back. The baker doesn't know who wrote the slip, so the kitchen checks it before it starts.
- In real life: The kitchen, which you never enter
- In React: The server, which runs addToCart's code
- In real life: The item number on the menu board
- In React: The function's id, actions.js#addToCart
- In real life: The order slip: "item 12, two of them"
- In React: The request body: ["mint",2]
- In real life: The bag passed back through the window
- In React: The value the function returns
- In real life: Anyone can hand a slip through the window, so the kitchen checks it
- In React: Anyone can send the request, so the function checks it
A Server Function is an async function marked with "use server" that runs on the server but can be called from the browser. The framework gives the browser a reference instead of the code. Calling it sends a POST request with the arguments written as text; the server runs the function and sends the result back. You call it from a form's action or from an event handler, and, like any address on your server, anyone can call it.
Older articles say Server Actions. Until September 2024, react.dev used that name for all of them. Its Server Functions page now uses Server Function for any function marked with "use server", and keeps Server Action for one way of using it: passed to a form's action, or called inside an action. Next.js's docs use both names the same way. The code is the same either way.
Where the use server directive goes
"use server" is a directive: a plain string on a line of its own that tells the build tool something about the code. It does nothing when the code runs; the framework's bundler reads it while it builds your app. React gives it two places.
At the top of a file
"use server";
import { db } from "./db.js";
export async function addToCart(teaId, cups) {
await db.cart.add(teaId, cups);
return { teaId, cups: await db.cart.count(teaId) };
}
export async function removeFromCart(teaId) {
await db.cart.remove(teaId);
}Line 1 marks the whole file. Every function it exports, addToCart on line 5 and removeFromCart on line 10, becomes a Server Function. The directive has to come first, before the imports; only comments may sit above it. Each export must be an async function, because calling it from the browser always waits for the network. Line 3 imports the database code, which never reaches the browser. A Client Component can then import these functions:
"use client";
import { startTransition } from "react";
import { addToCart } from "./actions.js";
export default function AddButton({ teaId }) {
return (
<button onClick={() => startTransition(() => addToCart(teaId, 1))}>
Add 1 cup
</button>
);
}Line 1 marks this file as a Client Component, code that runs in the browser (the Server Components lesson covers "use client"). Line 4 imports addToCart as if it were local. The bundler doesn't send actions.js to the browser: it puts a reference in its place. Line 8 calls it in a Transition. The overview's example did the same, with the pretend framework doing the bundler's job.
Inside a function, in a Server Component
A Server Component runs only on the server, so it can read the database while it renders. It can also define a Server Function right where it's used:
import { db } from "./db.js";
import AddButton from "./AddButton.jsx";
export default async function ProductPage({ teaId }) {
const tea = await db.teas.find(teaId);
async function addThisTea(cups) {
"use server";
await db.cart.add(tea.id, cups);
}
return (
<article>
<h1>{tea.name}</h1>
<AddButton onAdd={addThisTea} />
</article>
);
}Line 4 is an async Server Component, and line 5 reads the tea from the database. Lines 7 to 10 define addThisTea; line 8, "use server", is the first line of its body, which marks this one function. Line 15 passes it to AddButton as a prop. The button, a Client Component, calls it like any function prop:
"use client";
import { startTransition } from "react";
export default function AddButton({ onAdd }) {
return <button onClick={() => startTransition(() => onAdd(1))}>Add 1 cup</button>;
}Line 6 calls onAdd(1) in a Transition, and the server runs line 9 of ProductPage.jsx. Notice that addThisTea uses tea.id, a variable from the component around it: a closure. But the component finished rendering long before the click, on a server that may have handled a thousand other requests since. So the framework sends tea.id to the browser along with the reference, and the browser sends it back with every call. That detail matters for security, later in this lesson.
How to read the table: each row asks one question, answered for each place the directive can go.
| Question | At the top of a file | Inside a function |
|---|---|---|
| Where do you write it? | As the first line of a file, before the imports. | As the first line inside an async function's body. |
| What does it mark? | Every exported function in the file. | That one function. |
| Where can the file or function live? | In any file the server runs; Client Components can import from it. | Inside a Server Component, or a file the server runs. |
| How does a Client Component get it? | It imports the function, like any other. | A Server Component passes it down as a prop. |
| Can it use the component's variables? | No. It only sees its arguments and the file's imports. | Yes. The values it captures go to the browser and come back with each call. |
"use client" marks where the browser's code starts: components below it run in the browser too. "use server" marks functions the browser may call. Server Components need no directive at all; in a framework like Next.js's App Router, every component is a Server Component unless it's marked "use client".
What happens when the browser calls one
When the framework builds your app, it splits each "use server" file in two. The server keeps the code. The browser gets, for each function, a reference: a small stand-in function that knows the real one's id and nothing else.
"use server" file, the browser gets a reference with an id, and the server keeps the code. The id is how a call from the browser finds the code.Frameworks write this step for you, with React's packages for each bundler (react-server-dom-webpack, react-server-dom-turbopack, react-server-dom-parcel). With Parcel's, the two halves look roughly like this:
// What the browser gets for actions.js (simplified)
import { createServerReference } from "react-server-dom-parcel/client";
export const addToCart = createServerReference("actions.js", "addToCart");
export const removeFromCart = createServerReference("actions.js", "removeFromCart");// What the server keeps for actions.js (simplified)
import { registerServerReference } from "react-server-dom-parcel/server";
export async function addToCart(teaId, cups) {
await db.cart.add(teaId, cups);
return { teaId, cups: await db.cart.count(teaId) };
}
registerServerReference(addToCart, "actions.js", "addToCart");In the browser's half, line 4 makes addToCart a reference whose id is actions.js#addToCart; no database code, no cart. In the server's half, lines 4 to 7 are your function, and line 8 registers it under the same id, so a request naming that id finds it. Real frameworks use a hash instead of the file's name, like 7f3a9c….
Then, on each call:
- The reference passes the id and your arguments to the framework. React's
encodeReplywrites the arguments as text: the request's body. - The framework sends a POST request to the server with the id and the body.
- On the server, React rebuilds the arguments from the body (
decodeReply), the framework finds the function by its id, and calls it. - Whatever the function returns is written back in the same format that Server Components use to send what they render, and React turns it into a value again in the browser. Your
awaitgets that value.
What can travel
Since the arguments have to become text, only some values can be arguments: the ones React knows how to write down and rebuild. The pretend framework in this example uses React's own encodeReply, so each button shows you the real body, or the real error. The actions.js tab's inspect answers with the kind of value that arrived. Press each button, from left to right:
import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { inspect } = server; const tries = { "A string and a number": () => inspect("mint", 2), "An object": () => inspect({ teaId: "mint", cups: 2 }), "A date": () => inspect(new Date("2026-10-26T09:00:00Z")), "A Map": () => inspect(new Map([["mint", 2]])), "A function": () => inspect(() => "mint"), "The click event": (event) => inspect(event),}; export default function App() { const [error, setError] = useState(""); function send(name, event) { setError(""); startTransition(async () => { try { await tries[name](event); } catch (thrown) { startTransition(() => setError(thrown.message.split("\n")[0])); } }); } return ( <> <div className="row"> {Object.keys(tries).map((name) => ( <button key={name} onClick={(event) => send(name, event)}> {name} </button> ))} </div> {error && <p className="error">Not sent: {error}</p>} <NetworkLog /> </> );}Lines 7 to 14 list six calls. Lines 19 to 28 run the one you pick in a Transition, and line 25 keeps the first line of any error to show on line 39. What you'll see:
- A string and a number (line 8) send
["mint",2]: the arguments as a JSON list. They arrive asStringandNumber. - An object (line 9) sends
[{"teaId":"mint","cups":2}]and arrives as a plainObject. - A date (line 10) sends
["$D2026-10-26T09:00:00.000Z"]. JSON has no dates, so React writes the date as text and marks it with$D; it arrives as a realDate. - A Map (line 11) needs more than one line of JSON, so the body becomes a multipart form, the format browsers use to send forms. The log shows each part:
1=[["mint",2]]holds the Map's entries, and0=["$Q1"]is the argument list, saying "a Map, from part 1". - A function (line 12) never leaves the page:
Not sent: Client Functions cannot be passed directly to Server Functions. Only Functions passed from the Server can be passed back again.Code can't be written as text. - The click event (line 13) fails too:
Not sent: Only plain objects, and a few built-ins, can be passed to Server Functions. Classes or null prototypes are not supported.React's event object is made by a class. This one happens by accident a lot:onClick={addToCart}passes the event as the first argument.
Press Edit & run and try your own on line 8, like inspect(new Set(["mint"]), 5n): a Set and a BigInt travel too. How to read the table: each row is a kind of value, whether it can be an argument, and why.
| Value | Can it travel? | Why |
|---|---|---|
Strings, numbers, booleans, null, undefined, BigInt | Yes. | React writes each one as text, marking the ones JSON has no word for, like $undefined or $n5. |
| Plain objects and arrays of those | Yes. | They become JSON, the same text JSON.stringify makes. |
Date, Map, Set, typed arrays | Yes. | React marks them, like $D for a date, so they arrive as the same kind of value. |
FormData | Yes. | The request becomes a multipart form, the format browsers use to send forms. |
| Another Server Function | Yes. | Only its id travels, and the server finds it again. |
| Any other function, a class instance, or an event | No. | Code and objects made by classes can't be written as text. React stops before sending. |
| JSX | No. | React elements can't be sent to a Server Function. |
The same rules apply to what the function returns: values a Server Component could pass to a Client Component as props. The arguments that arrive are copies, rebuilt from text, so changing an object on the server never changes the browser's object.
These links point at React 19.3.1. The browser's half of the protocol is processReply in ReactFlightReplyClient.js. It marks dates with $D in serializeDateFromDateJSON, and throws the "Client Functions cannot be passed" error at line 905 for any function it doesn't know as a reference. Parcel's createServerReference is at line 72 of ReactFlightDOMClientBrowser.js.
Server Functions as form actions
In Form actions, a form's action was a function in the browser. It can be a Server Function just as well, and with useActionState it can return the answer to show. Type an email and press the button; then try asha@example.com, who is already a member:
import { useActionState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { joinClub } = server; export default function App() { const [state, formAction, isPending] = useActionState(joinClub, { message: "", email: "" }); return ( <> <form action={formAction}> <input name="email" aria-label="Email" defaultValue={state.email} /> <button disabled={isPending}>{isPending ? "Joining…" : "Join the tea club"}</button> </form> <p>{state.message}</p> <NetworkLog /> </> );}In App.jsx, line 8 hands the Server Function to useActionState, exactly as you would a local action, and line 12 gives the form the formAction it returns. In actions.js, line 5 has the two parameters useActionState passes: the previous state, then the form's FormData. Lines 7 and 8 return an error for a missing @ or a known member, with the email, so line 13's defaultValue puts it back after React resets the form. Join with mint@example.com: the page says Welcome, mint@example.com!.
Look at the request. The body is a multipart form again, because one argument is a FormData. One part holds the email field, its name with a prefix added. The part named 0 is the argument list: [{"message":"","email":""},"$K1"], the previous state, then $K1, "the FormData from part 1". The previous state travels with every call; the server doesn't remember it for you.
Forms that work before JavaScript
A server-rendered page shows its HTML before its JavaScript has loaded (Server rendering and hydration). What can a form do in that gap? It depends on its action. The playground shows the HTML React's server renderer really writes for four versions of the sign-up form:
import { joinClub } from "./actions.js";
function JoinForm() {
return (
<form action={joinClub}>
<input name="email" />
<button>Join</button>
</form>
);
}posts without JavaScript<form action="" encType="multipart/form-data" method="POST"> <input type="hidden" name="$ACTION_ID_actions.js#joinClub"/> <input name="email"/> <button>Join</button> </form>
The form posts to the page's own address (action=""), and a hidden field names the function: $ACTION_ID_ and its id. Before JavaScript loads, or with it turned off, the browser sends this form as a normal POST, and the framework runs joinClub.
With a browser function, the form has nowhere to go: its action is a placeholder that throws, and a small script keeps early submits until React has loaded, then React runs them. With a Server Function, the form is ordinary HTML: method="POST", the page's own address, and a hidden field naming the function. The browser can send it with JavaScript still loading, or turned off, and the framework runs the function and sends back the page. That's progressive enhancement: the page works as plain HTML first, and gets better when JavaScript arrives.
With useActionState, the hidden fields also hold the initial state, so the server can call joinClub with both arguments and render the new state into the page it sends back. With a permalink (useActionState's third argument, "/club"), the form posts to that page instead, which helps when the same form sits on many pages. Once React is running, none of this shows: React calls the function with fetch, as in the live example, and the page doesn't reload.
Calling one from an event handler
Not every change is a form. A Save button, a star rating, or a drag in a list calls the Server Function from an event handler. React doesn't start a Transition for you there, so you start one. Save a few teas, then press one again:
import { useState, useTransition } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { toggleSaved } = server;const teas = ["mint", "chai", "sencha"]; export default function App() { const [saved, setSaved] = useState([]); const [isPending, startTransition] = useTransition(); function toggle(teaId) { startTransition(async () => { const list = await toggleSaved(teaId); startTransition(() => setSaved(list)); }); } return ( <> <div className="row"> {teas.map((teaId) => ( <button key={teaId} onClick={() => toggle(teaId)} disabled={isPending}> {saved.includes(teaId) ? "Saved" : "Save"} {teaId} </button> ))} </div> <p>{isPending ? "Saving…" : `Saved for later: ${saved.join(", ") || "nothing yet"}`}</p> <NetworkLog /> </> );}Line 10 gets isPending and startTransition from useTransition. Line 14 calls toggleSaved and waits; line 15 stores the list the server returned, in a second startTransition because it comes after an await. While the request travels, line 23 turns the buttons off and line 28 says Saving…. Save mint, then chai: Saved for later: mint, chai. The server owns the list (lines 3 to 8 of actions.js), and the page shows what it returned.
How to read the table: each row asks one question about calling a Server Function, answered for each way.
| Question | In a form's action | In an event handler |
|---|---|---|
| What starts the call? | Submitting the form: a button or the Enter key. | Any event: a click, a change, a key. |
| Is it in a Transition? | Yes. React starts one for every form action. | Only if you call it inside startTransition. |
| How do you show the wait? | isPending from useActionState, or useFormStatus in a child. | isPending from useTransition. |
| What does the function get? | The form's FormData, after the previous state with useActionState. | Exactly the arguments you pass. |
| Does it work before JavaScript loads? | Yes, when it's a Server Function: the form posts normally. | No. The handler is JavaScript. |
Three more things to know when you call one yourself:
- Show the result right away with
useOptimistic. A Transition is what Optimistic updates need, so the Save button can saySavedbefore the server answers, and go back if it fails. - Calls may wait in line. React hands each call to the framework when you make it, and frameworks often send them one at a time. Next.js's docs (checked October 2026) say it sends a page's Server Actions one after another, so
Promise.alldoesn't make them parallel. - They're for changes, not for reading. react.dev says Server Functions are designed for mutations, and that frameworks usually have no way to cache their answers. To read data, use a Server Component, a route's loader, or a library from Data fetching libraries.
An error the function throws reaches the browser as an error from the await. Inside a Transition, React shows it in the nearest error boundary, the same as for any action. For errors you expect, like "already a member", return a value instead, as joinClub does.
Every Server Function is a public endpoint
An endpoint is an address on a server that answers requests. A Server Function is one, even though you never wrote an address: the framework made one for it. The function's id is in your page's JavaScript, where anyone can read it, and anyone can send a POST with that id and any body they like. Here is a delete function that trusts its caller:
"use server";
import { db } from "./db.js";
export async function deleteReview(reviewId) {
await db.reviews.delete({ id: reviewId });
}Line 6 deletes whichever review it's given. The page may only show a Delete button under your own reviews, but the button isn't what calls the function; a request is. Anyone signed in can open the browser's console on the shop and send this:
await fetch("/teas/mint", {
method: "POST",
headers: { "Next-Action": "a1b2c3" },
body: JSON.stringify(["r2"]),
});Line 1 posts to a page of the shop, line 3 names the function the way Next.js does, with its id in a header, and line 4 sends Ben's review id. To the server, this request looks the same as a click on a Delete button.
deleteReview checks who is asking and what they sent, every time.react.dev's security notes for "use server" say it plainly: arguments are fully client-controlled, so treat them as untrusted input, and in every Server Function check that the signed-in user is allowed to do it. Here is the same function with those checks:
"use server";
import { db } from "./db.js";
import { getUser } from "./session.js";
export async function deleteReview(reviewId) {
const user = await getUser();
if (!user) return { error: "Sign in first." };
if (typeof reviewId !== "string") return { error: "Not a review." };
const review = await db.reviews.find({ id: reviewId });
if (!review || review.author !== user.name) return { error: "Not allowed." };
await db.reviews.delete({ id: reviewId });
return { deleted: reviewId };
}Line 7 asks your session code who is signed in; it reads a cookie the server signed, so it can't be faked like an argument (the Cookies lesson covers cookies). Line 8 stops anyone who isn't. Line 9 checks that the argument is the kind of value you expect. Line 10 loads the review, and line 11 refuses unless this user wrote it. Only then does line 12 delete. Try four requests against both versions:
answers nothing- r2: Ben's review
answers {"deleted":"r1"}- r2: Ben's review
This request comes from Asha's Delete button on the page. The server sees the same kind of request either way: a POST naming deleteReview, with "r1" as its argument. Without checks, Asha's review is deleted. With checks, Asha's review is deleted: the session says Asha wrote it.
Asha deletes her review: both versions delete r1. Asha deletes Ben's review: without checks, Ben's review is gone; with them, the answer is {"error":"Not allowed."}. Nobody is signed in: the unchecked version deletes Asha's review for a stranger. An object instead of an id: the argument {"$ne":""} means "any id that isn't empty" to a database like MongoDB, so the unchecked version deletes every review. Line 9's type check stops it.
Captured values travel too
An inline Server Function that uses a variable from its component, like reviewId here, gets that value from the browser on every call:
export default async function ReviewPage({ reviewId }) {
async function deleteThisReview() {
"use server";
await db.reviews.delete({ id: reviewId });
}
return (
<form action={deleteThisReview}>
<button>Delete</button>
</form>
);
}Line 4 uses reviewId from line 1. React treats a captured value like an argument fixed in advance, as .bind does. This is the HTML React's server renderer writes for that form, when reviewId is "review-7":
<form action="" encType="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_REF_1"/>
<input type="hidden" name="$ACTION_1:0" value="{"id":"ReviewPage.jsx#deleteThisReview","bound":"$@1"}"/>
<input type="hidden" name="$ACTION_1:1" value="["review-7"]"/>
<button>Delete</button>
</form>Line 4 holds ["review-7"] in plain text, in a field anyone can edit before submitting. Next.js encrypts captured values before they reach the page, which keeps them from being read; its docs still say not to rely on that alone, and react.dev says to treat every argument as untrusted. So check captured values like arguments, and never capture a secret.
The same habits keep the rest of a Server Function safe:
- Return only what the page needs. The return value is sent to the browser as it is. Return
{ deleted: reviewId }, not the whole database row with the author's email in it. - Keep secrets out of the code. Read keys at run time, from
process.env, instead of writing them in the function. A flaw found in December 2025 let a crafted request make some Server Functions send back Server Functions' source code, with any secret written in it. - Keep React and your framework up to date. That same month, react.dev announced a critical flaw (CVE-2025-55182) that let a crafted request to a Server Function endpoint run code on the server. It was fixed in React 19.0.1, 19.1.2 and 19.2.1, with more fixes in January 2026; React 19.3.1, which this course uses, came out after them.
Fresh data after a change
After a Server Function changes data, other parts of the page may be out of date: the review count, the list of reviews, a cached copy of the page. React doesn't know about your data, so the framework handles it. In Next.js (version 16.4, checked October 2026), the function says what changed:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { getUser } from "@/session";
export async function postReview(teaId, formData) {
const user = await getUser();
if (!user) redirect("/sign-in");
await db.reviews.add({ teaId, author: user.name, text: String(formData.get("text")) });
revalidatePath(`/teas/${teaId}`);
}Lines 9 and 10 check the user and send anyone signed out to the sign-in page; redirect throws, so nothing after it runs. Line 11 saves the review. Line 12, revalidatePath, tells Next.js that the saved copy of the tea's page is out of date (the Rendering strategies lesson met it with revalidation). When that's the page you're on, Next.js renders it again on the server and sends the new Server Component output in the same answer as the return value, so the reviews update without a second request. A form passes the tea's id with postReview.bind(null, teaId), and the FormData arrives second.
Next.js has a few of these, each for a different scope: revalidatePath for one page, updateTag and revalidateTag for data you tagged when you read it, and refresh() to render the current page again. Other frameworks do the same job their own way. React Router (version 8, in framework mode) doesn't need "use server" for this at all:
// app/routes/tea.jsx, a React Router route module
import { Form } from "react-router";
import { db } from "../db.js";
export async function loader({ params }) {
return { reviews: await db.reviews.forTea(params.teaId) };
}
export async function action({ request, params }) {
const formData = await request.formData();
await db.reviews.add({ teaId: params.teaId, text: String(formData.get("text")) });
}
export default function Tea({ loaderData }) {
return (
<Form method="post">
<textarea name="text" />
<button>Post</button>
<p>{loaderData.reviews.length} reviews</p>
</Form>
);
}Lines 5 to 7 are the route's loader, which reads the reviews on the server. Lines 9 to 12 are its action, which runs on the server when the <Form> on line 16 is submitted. After every action, React Router calls the page's loaders again, so line 19's count is fresh without any revalidation call (the Routing lesson met loaders). React Router also has a mode with Server Components and "use server", which its docs still called experimental in October 2026.
A review form, end to end
Here is everything together: a form whose action is a Server Function, which reads the signed-in user, checks every field on the server, returns errors with what was typed, and saves the review. Press Post review with nothing filled in first:
import { useActionState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { postReview } = server;const blank = { errors: {}, values: {} }; export default function App() { const [state, formAction, isPending] = useActionState(postReview, blank); return ( <> <form action={formAction}> <label> Stars <input name="stars" size="2" defaultValue={state.values.stars} /> </label> <input name="text" aria-label="Review" placeholder="Your review" defaultValue={state.values.text} /> <button disabled={isPending}>{isPending ? "Posting…" : "Post review"}</button> </form> {state.errors.form && <p className="error">{state.errors.form}</p>} {state.errors.stars && <p className="error">{state.errors.stars}</p>} {state.errors.text && <p className="error">{state.errors.text}</p>} {state.message && <p>{state.message}</p>} <NetworkLog /> </> );}In actions.js, line 4 stands in for the session a framework reads from a cookie, and line 8 refuses anyone signed out. Line 9 reads both fields as strings, whatever the browser sent. Lines 12 and 13 check them, and line 14 returns the errors with the values. Only valid reviews reach line 15. In App.jsx, lines 15 and 17 put the values back as defaults, and lines 20 to 23 show the errors and the thanks.
Empty, the page says Pick 1 to 5 stars. and Write at least 10 characters. Type 9 stars and Lovely and fresh.: only the stars error is left, and your review is still in its box. Change it to 4 stars: Thanks, Asha! Review 1 is up. Then press Edit & run, open actions.js, and change line 4 to { user: null }: every post now says Sign in first.
Browser checks, like required or min, still help: they answer at once, with no request. But they run in the browser, so the server checks again. Sort what a Server Function gets by where it comes from:
formData.get("stars")- The
reviewIdargument previousStatefromuseActionState- A value an inline Server Function captured
- A hidden
<input name="userId"> - The user your session code reads from a signed cookie
- A review your code just read from the database
process.env.DATABASE_URL
Sort each value a Server Function might use: does it come from the browser, so you check it, or from your own server?
Common mix-ups
- "The browser downloads the Server Function's code." No. It gets a reference with an id. The code, and anything it imports, stays on the server.
- "Hiding the button keeps the function safe." No. Anyone can send the request without the button. Check the user and the input inside the function.
- "Any value can be an argument." No. Only values React can write as text and rebuild: not functions from the browser, class instances, or events.
- "'use server' marks a Server Component." No. It marks functions the browser may call. Server Components need no directive;
"use client"marks the other kind. - "Server Functions are a good way to read data." Not usually. They're designed for changes; frameworks run them one at a time and don't cache their answers.
- "Server Actions and Server Functions are different features." No. Server Function is the general name since September 2024; a Server Action is one used as an action.
Practice exercises
Each exercise runs with the same pretend framework, in the framework.js and network.jsx tabs, and its Server Functions are in the actions.js tab. Try a guess first, then use the hints.
You press Add chai. What body does the request carry? Answer first, then press Add chai in the result and read the network list.
import { startTransition } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { addToCart } = server; export default function App() { return ( <> <button onClick={() => startTransition(() => addToCart("chai", 3))}>Add chai</button> <NetworkLog /> </> );}import { startTransition } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { addToCart } = server; export default function App() { return ( <> <button onClick={() => startTransition(() => addToCart("chai", 3))}>Add chai</button> <NetworkLog /> </> );}React writes the arguments as a JSON list: ["chai",3]. The parameter names, teaId and cups, aren't in it; only the server's code knows them.
Press Add mint: no request is sent, and the page says it failed. Fix it. What does the page say after one click?
import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { addToCart } = server; export default function App() { const [message, setMessage] = useState("Nothing added yet."); function add(teaId) { startTransition(async () => { try { const result = await addToCart(teaId, 1); startTransition(() => setMessage(`${result.teaId}: ${result.cups} cup`)); } catch (error) { startTransition(() => setMessage(`Failed: ${error.message.split(".")[0]}.`)); } }); } return ( <> <button onClick={add}>Add mint</button> <p>{message}</p> <NetworkLog /> </> );}import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { addToCart } = server; export default function App() { const [message, setMessage] = useState("Nothing added yet."); function add(teaId) { startTransition(async () => { try { const result = await addToCart(teaId, 1); startTransition(() => setMessage(`${result.teaId}: ${result.cups} cup`)); } catch (error) { startTransition(() => setMessage(`Failed: ${error.message.split(".")[0]}.`)); } }); } return ( <> <button onClick={() => add("mint")}>Add mint</button> <p>{message}</p> <NetworkLog /> </> );}onClick={add} calls add(event), so addToCart got the click event, which React can't send. With an arrow function, it gets "mint", and the server answers {"teaId":"mint","cups":1}.
Nothing shows that the save is on its way. Make the button say Saving… and turn off while it waits. What does it say then?
import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { toggleSaved } = server; export default function App() { const [saved, setSaved] = useState([]); function save() { startTransition(async () => { const list = await toggleSaved("chai"); startTransition(() => setSaved(list)); }); } return ( <> <button onClick={save}>Save chai</button> <p>Saved for later: {saved.join(", ") || "nothing yet"}</p> <NetworkLog /> </> );}import { useState, useTransition } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { toggleSaved } = server; export default function App() { const [saved, setSaved] = useState([]); const [isPending, startTransition] = useTransition(); function save() { startTransition(async () => { const list = await toggleSaved("chai"); startTransition(() => setSaved(list)); }); } return ( <> <button onClick={save} disabled={isPending}> {isPending ? "Saving…" : "Save chai"} </button> <p>Saved for later: {saved.join(", ") || "nothing yet"}</p> <NetworkLog /> </> );}useTransition gives isPending, true from the click until the second startTransition has stored the list. The button turns off and says Saving… for the 0.6 seconds the request travels.
Join with mint@example.com: the server answers with an error and the example stops. Fix actions.js. What does the page say then?
import { useActionState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { joinClub } = server; export default function App() { const [state, formAction] = useActionState(joinClub, { message: "", email: "" }); return ( <> <form action={formAction}> <input name="email" aria-label="Email" defaultValue={state.email} /> <button>Join the tea club</button> </form> <p>{state.message}</p> <NetworkLog /> </> );}import { useActionState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { joinClub } = server; export default function App() { const [state, formAction] = useActionState(joinClub, { message: "", email: "" }); return ( <> <form action={formAction}> <input name="email" aria-label="Email" defaultValue={state.email} /> <button>Join the tea club</button> </form> <p>{state.message}</p> <NetworkLog /> </> );}The fix is in the actions.js tab: joinClub(previousState, formData). useActionState passes the previous state first, so in the starter formData was the state object, which has no get. Now the form arrives second, and the page says Welcome, mint@example.com!.
Asha is signed in. The second button sends what any script could send: Ben's review id. Make deleteReview in actions.js refuse it. What does the page say when you send r2?
import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { deleteReview } = server; export default function App() { const [answer, setAnswer] = useState(null); function send(reviewId) { startTransition(async () => { const result = await deleteReview(reviewId); startTransition(() => setAnswer(result)); }); } return ( <> <div className="row"> <button onClick={() => send("r1")}>Delete my review (r1)</button> <button onClick={() => send("r2")}>Send what a script could: r2</button> </div> {answer && <p>{answer.error ?? "Deleted."} Reviews left: {answer.left.join(", ") || "none"}</p>} <NetworkLog /> </> );}import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { deleteReview } = server; export default function App() { const [answer, setAnswer] = useState(null); function send(reviewId) { startTransition(async () => { const result = await deleteReview(reviewId); startTransition(() => setAnswer(result)); }); } return ( <> <div className="row"> <button onClick={() => send("r1")}>Delete my review (r1)</button> <button onClick={() => send("r2")}>Send what a script could: r2</button> </div> {answer && <p>{answer.error ?? "Deleted."} Reviews left: {answer.left.join(", ") || "none"}</p>} <NetworkLog /> </> );}The fix is in the actions.js tab. The function now checks the review's author against the signed-in user before deleting. Sending r2, Ben's review, answers Not allowed., and both reviews are left: the page says Not allowed. Reviews left: r1, r2.
Press the second button: mint now averages 50 stars. Make rateTea refuse it. What does the page say for the 50-star request then?
import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { rateTea } = server; export default function App() { const [message, setMessage] = useState(""); function rate(stars) { startTransition(async () => { const result = await rateTea("mint", stars); startTransition(() => setMessage(result.message)); }); } return ( <> <div className="row"> <button onClick={() => rate(4)}>Rate 4 stars</button> <button onClick={() => rate(50)}>Send what a script could: 50</button> </div> <p>{message}</p> <NetworkLog /> </> );}import { startTransition, useState } from "react";import { server } from "./framework.js";import { NetworkLog } from "./network.jsx"; const { rateTea } = server; export default function App() { const [message, setMessage] = useState(""); function rate(stars) { startTransition(async () => { const result = await rateTea("mint", stars); startTransition(() => setMessage(result.message)); }); } return ( <> <div className="row"> <button onClick={() => rate(4)}>Rate 4 stars</button> <button onClick={() => rate(50)}>Send what a script could: 50</button> </div> <p>{message}</p> <NetworkLog /> </> );}The fix is in the actions.js tab. Without the check, 50 stars lands in the ratings and the average jumps. With it, the server refuses anything that isn't a whole number from 1 to 5, before it stores anything, whatever the page offers.
Check your understanding
For each question, ask what the browser has (a reference and the arguments) and what the server has (the code, the session, the database).
Question 1 of 8Until September 2024, react.dev called every Server Function a "Server Action". What does "Server Action" mean now?
Choose an answer to see the explanation.
Question 2 of 8What body does this button's request carry, when the framework writes the arguments the way React does?
Read the code, then predictJSX"use client"; import { startTransition } from "react"; import { addToCart } from "./actions.js"; export default function Button() { return <button onClick={() => startTransition(() => addToCart("sencha", 1))}>Add sencha</button>; }Choose an answer to see the explanation.
Question 3 of 8Which call stops in the browser, before any request is sent?
Choose an answer to see the explanation.
Question 4 of 8Pressing this form's button makes the server throw
Cannot read properties of null (reading 'get'). Why?Read the code, then predictJavaScript"use server"; export async function joinClub(formData) { const email = formData.get("email"); // … } // In a Client Component: const [state, formAction] = useActionState(joinClub, null);Choose an answer to see the explanation.
Question 5 of 8Your page only shows a Delete button to a review's author. Is
deleteReview(reviewId)safe without checks inside it?Choose an answer to see the explanation.
Question 6 of 8A form's action is a Server Function. Someone submits it before the page's JavaScript has loaded. What happens?
Choose an answer to see the explanation.
Question 7 of 8This runs inside
startTransition:const list = await toggleSaved(id); setSaved(list);. What's wrong with thesetSavedline?Choose an answer to see the explanation.
Question 8 of 8Should a Client Component load the product list by calling a Server Function from an Effect?
Choose an answer to see the explanation.
Key takeaways
- A Server Function is an async function marked with
"use server", at the top of a file or of a function in a Server Component. It runs on the server and can be called from the browser. - The browser gets a reference with an id. A call sends a POST with the arguments, written as text by React; the server runs the function and sends back the result. Only values React can write down can travel.
- As a form's action, with
useActionState, it gets(previousState, formData), and the form works before JavaScript loads. From an event handler, call it instartTransition. - Every Server Function is a public endpoint. Check who is signed in, check every argument and captured value, check permission, and return only what the page needs.
- Frameworks refresh data after a change: Next.js with
revalidatePathand friends, React Router by reloading its loaders.
Remember the one-liner.
Call it like a function, guard it like an endpoint.
Coming next: React frameworks. Server Functions, Server Components and streaming all need a framework; that lesson compares Next.js, React Router, TanStack Start and Expo, and when Vite alone is enough.