Server actions & forms
Writing without an API in between
A server action is a function that runs on the server and that you call straight from a form. No fetch, no route, no JSON.
Reading was easy: await in the component. Writing always needed an API route, a fetch, JSON back and forth. A Server actionA function that runs on the server and that you can call straight from a form or a click – with no API in between.→ turns that into a function call.
export default function Contact() {
async function submit(formData: FormData) {
"use server"; // runs on the server
const mail = formData.get("mail") as string;
await db.enquiry.create({ data: { mail } });
}
return (
<form action={submit}>
<input name="mail" type="email" required />
<button>Send</button>
</form>
);
} Because it is a real <form> with an action, the browser can submit it the classic way if it has to. Someone on a slow connection, or before the script has loaded, can still use the form.
Writing used to be like posting a letter to your own company: address it, send it, open it again in the mailroom. A server action is the call to the next office – the function is invoked directly and Next.js builds the postal route invisibly around it.
Where actions live
"use server"; // at the top: EVERY export is a server action
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
if (!title?.trim()) return;
const post = await db.post.create({ data: { title } });
revalidatePath("/blog");
redirect("/blog/" + post.slug);
} Next.js creates an endpoint for every action. Anyone can call it – with any values. So check inside the action whether the person is signed in and allowed to do this. Hiding the button in the interface protects nothing.
Feedback for the user
For validation errors and loading states there is useActionState – a React hook giving you the action's last return value, the action itself and a pending flag.
"use client";
import { useActionState } from "react";
import { sendEnquiry } from "../actions";
export function Form() {
const [state, action, pending] = useActionState(sendEnquiry, {
error: null,
});
return (
<form action={action}>
<input name="mail" type="email" />
<button disabled={pending}>{pending ? "Sending …" : "Send"}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
} "use server";
type State = { error: string | null; success?: boolean };
export async function sendEnquiry(
previous: State,
formData: FormData,
): Promise<State> {
const mail = String(formData.get("mail") ?? "");
if (!mail.includes("@")) return { error: "Please enter a valid address" };
await db.enquiry.create({ data: { mail } });
return { error: null, success: true };
} useFormStatus() reads the surrounding form's state. That lets you build a <SubmitButton> that works everywhere without threading pending through. Important: the hook has to live inside the <form>, not in the same component.
Not only forms
"use client";
import { useTransition } from "react";
import { favourite } from "../actions";
export function Heart({ id }: { id: string }) {
const [pending, start] = useTransition();
return (
<button onClick={() => start(() => favourite(id))} disabled={pending}>
♥
</button>
);
} - +
Forms of every kind – create, edit, delete
- +
Single actions like favouriting or subscribing
- +
Anything you want to revalidate afterwards anyway
- −
Pure reading – fetch that straight in the server component
- −
As a general API for third-party systems – that is what route handlers are for
- −
Without checking sign-in and permissions
▸ In more depth: instant feedback with useOptimistic optional
A server action needs a network round trip. For small actions – a heart, a checkbox – that feels sluggish. useOptimistic shows the result immediately and takes it back if it fails.
"use client";
import { useOptimistic, useTransition } from "react";
export function List({ entries }: { entries: Entry[] }) {
const [visible, add] = useOptimistic(
entries,
(current, text: string) => [...current, { text, sending: true }],
);
const [, start] = useTransition();
function submit(formData: FormData) {
const text = String(formData.get("text"));
start(async () => {
add(text); // visible immediately
await createEntry(text); // server action
});
}
return <form action={submit}>…</form>;
} If the action fails, the optimistic entry disappears again. For a heart that is fine; for a payment it is not.
Does it stick?
5 questions on this lesson. Wrong answers show up in your stats.
Now write it yourself
Finish the sign-up form using useActionState. The action already exists and simulates the server – you wire up the form, the error message and the button label. (In Next.js the action would live in a file with "use server".)
- □Without an
@the error textPlease enter a valid addressappears - □With a valid address
Thanks!appears - □While submitting the button reads
Sending …
import { useActionState } from "react"; type State = { error: string | null; success?: boolean }; // In Next.js this function would live in a file with "use server". async function signUp(previous: State, formData: FormData): Promise<State> { const mail = String(formData.get("mail") ?? ""); await new Promise((r) => setTimeout(r, 300)); if (!mail.includes("@")) return { error: "Please enter a valid address" }; return { error: null, success: true }; } export default function App() { // TODO: wire up useActionState return ( <form style={{ fontFamily: "system-ui", padding: 16 }}> <input name="mail" aria-label="mail" />{" "} <button>Send</button> </form> ); }