Server Actions & Forms
Until now you have only read data. Real apps also change it: a student enrols, a shopkeeper adds stock, a teacher posts marks. In the App Router you do not need to build a separate API for this. You write an async function that runs on the server, mark it with "use server", and pass it to a form. That function is a Server Action.
In this lesson you will create Server Actions, read form data, validate it and show errors with useActionState, show pending states, update the screen optimistically, and keep actions secure.
Your first Server Action
A Server Function is an async function that runs on the server but can be called from the browser. When it is used to handle a form or a change, it is called a Server Action. You create one with the "use server" directive, either at the top of the function body or at the top of a file (then every export in the file is an action):
// app/enquiry/page.jsx (Server Component)
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export default function EnquiryPage() {
async function saveEnquiry(formData) {
"use server";
const name = String(formData.get("name") ?? "").trim();
const course = String(formData.get("course") ?? "");
await db.query("INSERT INTO enquiries (name, course) VALUES (?, ?)", [name, course]);
redirect("/enquiry/thanks");
}
return (
<form action={saveEnquiry}>
<label>Name <input name="name" required /></label>
<label>
Course
<select name="course">
<option>HTML</option>
<option>React</option>
<option>Next.js</option>
</select>
</label>
<button type="submit">Send enquiry</button>
</form>
);
}When the form is submitted, React sends the fields to the server as a FormData object (read each field with formData.get("name")), runs saveEnquiry, and handles the redirect. Behind the scenes this is a POST request to the current page; you never write a URL or call fetch.
Two benefits come free. First, progressive enhancement: a form in a Server Component works even before JavaScript has loaded, as an ordinary HTML form post. Second, the action can update the data and the UI in a single round trip.
Actions in Client Components
You cannot define a Server Action inside a Client Component, but you can import one from a file that starts with "use server":
// app/enquiry/actions.js
"use server";
export async function saveEnquiry(prevState, formData) {
const name = String(formData.get("name") ?? "").trim();
const phone = String(formData.get("phone") ?? "").trim();
const errors = {};
if (name.length < 2) errors.name = "Please enter your full name.";
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = "Enter a 10-digit Indian mobile number.";
if (Object.keys(errors).length) return { ok: false, errors };
await db.query("INSERT INTO enquiries (name, phone) VALUES (?, ?)", [name, phone]);
return { ok: true, message: `Thanks ${name}, we will call you soon.` };
}Notice the two parameters. When an action is used with React's useActionState hook, it receives the previous state first and the form data second, and whatever it returns becomes the new state. Returning errors as data (instead of throwing) is the recommended way to handle expected problems such as invalid input.
Validation errors with useActionState
useActionState(action, initialState) returns [state, formAction, pending]. Pass formAction to the form, read errors from state, and use pending to disable the button:
// app/enquiry/EnquiryForm.jsx
"use client";
import { useActionState } from "react";
import { saveEnquiry } from "./actions";
export default function EnquiryForm() {
const [state, formAction, pending] = useActionState(saveEnquiry, { ok: false, errors: {} });
return (
<form action={formAction}>
<input name="name" placeholder="Name" />
{state.errors?.name && <p role="alert">{state.errors.name}</p>}
<input name="phone" placeholder="Mobile" />
{state.errors?.phone && <p role="alert">{state.errors.phone}</p>}
<button disabled={pending}>{pending ? "Sending..." : "Send enquiry"}</button>
{state.ok && <p>{state.message}</p>}
</form>
);
}React 19 supports form actions in the browser too, so you can try the same pattern here. The saveEnquiry function below only pretends to be on the server (it waits 800 ms), but the hook, the state and the pending flag behave exactly as in Next.js. Submit it empty first, then with a valid name and number:
import { useActionState } from "react";
import { createRoot } from "react-dom/client";
async function saveEnquiry(prevState, formData) {
await new Promise((r) => setTimeout(r, 800)); // pretend network + database
const name = String(formData.get("name") ?? "").trim();
const phone = String(formData.get("phone") ?? "").trim();
const errors = {};
if (name.length < 2) errors.name = "Please enter your full name.";
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = "Enter a 10-digit Indian mobile number.";
if (Object.keys(errors).length) return { ok: false, errors };
console.log("Saved enquiry for", name);
return { ok: true, errors: {}, message: `Thanks ${name}, we will call you soon.` };
}
function EnquiryForm() {
const [state, formAction, pending] = useActionState(saveEnquiry, { ok: false, errors: {} });
return (
<form action={formAction} style={{ display: "grid", gap: 6, maxWidth: 280 }}>
<input id="name" name="name" placeholder="Name" />
{state.errors.name && <small style={{ color: "crimson" }}>{state.errors.name}</small>}
<input id="phone" name="phone" placeholder="Mobile" />
{state.errors.phone && <small style={{ color: "crimson" }}>{state.errors.phone}</small>}
<button disabled={pending}>{pending ? "Sending..." : "Send enquiry"}</button>
{state.ok && <p>{state.message}</p>}
</form>
);
}
createRoot(document.getElementById("root")).render(<EnquiryForm />);An empty submit shows "Sending..." for a moment, then both red messages. With "Rohan Mehta" and "9876543210", the console prints Saved enquiry for Rohan Mehta and the form shows the thank-you message.
For a submit button that lives in a separate component, the useFormStatus() hook from react-dom gives pending for its parent form.
After the change: revalidate and redirect
An action that changes data must also update anything cached that shows that data. Call updateTag (or revalidatePath) before redirect, as you learned in Revalidating Cached Data. You can also set or delete cookies in an action with (await cookies()).set(...); Next.js then re-renders the current page so it reflects the new cookie.
To pass extra values that are not form fields, such as a record ID, use bind:
import { deleteBatch } from "./actions"; // async function deleteBatch(batchId, formData)
export default function BatchRow({ batch }) {
const deleteThis = deleteBatch.bind(null, batch.id);
return (
<form action={deleteThis}>
{batch.name} <button>Delete</button>
</form>
);
}Optimistic updates
Some actions should feel instant, such as liking a post. React's useOptimistic hook shows the expected result immediately and falls back automatically if the action fails. Try adding comments below; each one appears greyed out at once and turns black when the "server" confirms:
import { useOptimistic, useState, startTransition } from "react";
import { createRoot } from "react-dom/client";
async function postComment(text) {
await new Promise((r) => setTimeout(r, 1000));
return { id: Date.now(), text };
}
function Comments() {
const [comments, setComments] = useState([{ id: 1, text: "Very clear lesson!" }]);
const [optimistic, addOptimistic] = useOptimistic(comments, (list, text) => [
...list,
{ id: "temp", text, sending: true },
]);
function submit(formData) {
const text = String(formData.get("text"));
startTransition(async () => {
addOptimistic(text);
const saved = await postComment(text);
startTransition(() => setComments((list) => [...list, saved]));
});
}
return (
<>
<ul>
{optimistic.map((c) => (
<li key={c.id} style={{ color: c.sending ? "#9ca3af" : "black" }}>{c.text}</li>
))}
</ul>
<form action={submit}>
<input id="text" name="text" placeholder="Add a comment" required />
<button>Post</button>
</form>
</>
);
}
createRoot(document.getElementById("root")).render(<Comments />);Security: every action is a public endpoint
A Server Action can be called by anyone who sends the right POST request, not only through your form. So treat each action like a public API:
- Check authentication and authorisation inside the action, every time. Hiding a button is not security.
- Validate every input on the server, even if the form has
requiredorpattern. Libraries such as Zod help with complex forms. - Return only what the client needs. Never return a whole database row with password hashes.
"use server";
import { verifySession } from "@/lib/dal";
export async function changeFee(formData) {
const session = await verifySession();
if (session.role !== "admin") throw new Error("Not allowed");
// ...validate and update...
}Common mistakes
- Trusting the browser. Always validate and check permissions inside the action.
- Throwing for expected errors. Return an error object for bad input; throw only for real failures, which go to
error.jsx. - Forgetting to revalidate. The data changes but cached pages still show old values.
- Using actions to read data. Actions are for changes and run one at a time per client. Fetch data in Server Components instead.
Key points
"use server"marks Server Functions; used for forms and mutations they are Server Actions, sent asPOSTrequests.- A form
actionreceivesFormData; forms in Server Components work even before JavaScript loads. useActionStategives[state, formAction, pending]; the action receives(prevState, formData).- After a change, call
updateTagorrevalidatePath, thenredirectif needed; usebindfor extra arguments. - Every action is a public endpoint: authenticate, authorise and validate inside it.
Exercise
Write a Server Action file app/attendance/actions.js with an action markPresent(prevState, formData) that reads a roll number, rejects anything that is not 1 to 60 with an error message, saves it, and calls updateTag("attendance"). Then write a Client Component form that uses useActionState and shows the error or "Marked roll N present".
Show solution
// app/attendance/actions.js
"use server";
import { updateTag } from "next/cache";
import { verifySession } from "@/lib/dal";
export async function markPresent(prevState, formData) {
await verifySession(); // teachers only
const roll = Number(formData.get("roll"));
if (!Number.isInteger(roll) || roll < 1 || roll > 60) {
return { error: "Roll number must be between 1 and 60." };
}
await db.query("INSERT INTO attendance (roll, day) VALUES (?, CURDATE())", [roll]);
updateTag("attendance");
return { message: `Marked roll ${roll} present` };
}
// app/attendance/AttendanceForm.jsx
"use client";
import { useActionState } from "react";
import { markPresent } from "./actions";
export default function AttendanceForm() {
const [state, formAction, pending] = useActionState(markPresent, {});
return (
<form action={formAction}>
<input name="roll" type="number" min="1" max="60" required />
<button disabled={pending}>Mark present</button>
{state.error && <p role="alert">{state.error}</p>}
{state.message && <p>{state.message}</p>}
</form>
);
}The browser's min/max help users, but the action checks the range again because anyone could send a request directly.
Quick Quiz
1. How does a form pass its values to a Server Action used as <form action={save}>?
The action receives FormData; read fields with formData.get("name").
2. With useActionState, what are the action's parameters?
When used with useActionState, the action receives the previous state first.
3. Which HTTP method does Next.js use to call Server Actions?
Server Actions are invoked with POST requests to the route where they are used.
4. A button to delete a course is hidden for non-admins. Is the delete action secure?
Actions are reachable by direct POST requests, so they must verify permissions themselves.
Learn Next.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
