Server Functions, Forms, and Mutations
Server Functions, Forms, and Mutations let a Next.js App Router page change server-side state without building a separate JSON endpoint for every button and form. The outcome is practical: a user submits HTML form data, the server validates and writes it, and React receives either new UI, a returned state object, or a redirect. This lesson focuses on the moving parts that make that possible so you can choose Server Functions deliberately instead of treating them as magic form handlers.
In this course section, Server Functions sit between data fetching and cache behavior. They are the write side of the same system that Server Components use to read. A good mutation updates the durable record, invalidates or refreshes the right cached view, and returns feedback that matches the user’s next screen.
How The Mechanism Works
A Server Function is an async function that executes only on the server. In Next.js you mark it with the "use server" directive, either at the top of a file that exports server-only functions or inside an inline function. When that function is connected to a form’s action prop or a button’s formAction prop, React and Next.js arrange a POST submission to call the server function. The browser does not receive the function body; it receives markup and a reference that can invoke the server entry point.
For forms, the first ordinary payload is a FormData object containing successful form controls by their name attributes. File inputs, unchecked checkboxes, missing names, duplicate names, and hidden fields all follow normal HTML form rules before the Server Function ever runs. With useActionState, React changes the signature: the server function receives the previous state first and FormData second, then its serializable return value becomes the next state for the client component.
After a write, the server response can carry updated UI and data in the same roundtrip. If your route reads cached data, the mutation must call revalidatePath, revalidateTag, or another cache-aware mechanism before the user expects fresh output. If the next correct screen is elsewhere, redirect ends the action by throwing a framework-handled control-flow signal, so place it after durable work and cache invalidation.
API Anatomy
"use server": declares that a function or module exports server-callable code. Keep database clients, secrets, and privileged authorization checks here.<form action={fn}>: invokes the function withFormData. It works with normal browser submission semantics and can progressively enhance before client JavaScript finishes loading.useActionState(action, initialState): binds a form action to UI state for validation messages, success messages, and pending status.bindor hidden inputs: pass extra identifiers such as a post id. Treat both as untrusted; authorize against the session on the server.revalidatePathandredirect: coordinate cache freshness and navigation after the mutation.
Example 1: A Minimal Form Mutation
This first example creates a task from a plain form. The server function reads title, validates it, stores it through a small in-memory helper, and invalidates the task list route. In a real application the helper would be a database call, but the control flow is the same.
// app/tasks/actions.ts
"use server";
import { revalidatePath } from "next/cache";
const tasks: Array<{ id: string; title: string }> = [];
export async function createTask(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (title.length < 3) {
throw new Error("Task title must be at least 3 characters.");
}
tasks.push({ id: crypto.randomUUID(), title });
revalidatePath("/tasks");
}
// app/tasks/page.tsx
import { createTask } from "./actions";
export default function TasksPage() {
return (
<form action={createTask}>
<label htmlFor="title">Task title</label>
<input id="title" name="title" required minLength={3} />
<button type="submit">Create</button>
</form>
);
}
Submitting title=Ship draft appends one task and refreshes /tasks. Submitting an empty value fails server validation even if a user bypasses the browser’s required attribute. The deterministic behavior is that titles shorter than three trimmed characters never reach the storage step.
Example 2: Returning Validation State
Throwing is appropriate for unexpected failures, but common form errors should return state the UI can render. Here the Client Component uses useActionState. Because of that hook, signup receives prevState first and formData second.
// app/signup/actions.ts
"use server";
type SignupState = {
ok: boolean;
message: string;
};
export async function signup(
prevState: SignupState,
formData: FormData
): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email.includes("@")) {
return { ok: false, message: "Enter a valid email address." };
}
return { ok: true, message: `Confirmation sent to ${email}.` };
}
// app/signup/signup-form.tsx
"use client";
import { useActionState } from "react";
import { signup } from "./actions";
const initialState = { ok: false, message: "" };
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, initialState);
return (
<form action={formAction}>
<input name="email" type="email" aria-label="Email" />
<button disabled={pending}>Join</button>
<p aria-live="polite">{state.message}</p>
</form>
);
}
For email=ada@example.com, the state becomes { ok: true, message: "Confirmation sent to ada@example.com." }. For email=ada, the state becomes { ok: false, message: "Enter a valid email address." }. The returned object must stay serializable; do not return database clients, class instances, or functions.
Example 3: Mutating A Specific Record
Mutations often need a stable record id. Passing it with bind avoids putting the id in visible markup, but it is still request input and must be authorized. The server function checks the current user against the post before updating.
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
async function requireUserId() {
return "user_123";
}
async function userCanEditPost(userId: string, postId: string) {
return userId === "user_123" && postId.startsWith("post_");
}
export async function updatePostTitle(postId: string, formData: FormData) {
const userId = await requireUserId();
if (!(await userCanEditPost(userId, postId))) {
throw new Error("Unauthorized");
}
const title = String(formData.get("title") ?? "").trim();
if (title.length < 5) {
throw new Error("Post title must be at least 5 characters.");
}
// await db.post.update({ where: { id: postId }, data: { title } });
revalidatePath(`/posts/${postId}`);
redirect(`/posts/${postId}`);
}
// app/posts/[id]/edit-form.tsx
import { updatePostTitle } from "../actions";
export function EditPostForm({ postId }: { postId: string }) {
const action = updatePostTitle.bind(null, postId);
return (
<form action={action}>
<input name="title" minLength={5} />
<button type="submit">Save</button>
</form>
);
}
If postId is post_abc and the user is allowed, the path for that post is revalidated and the client is sent to the post page. If authorization fails, no write, revalidation, or redirect should occur. The important invariant is not where the id came from; it is that the server verifies ownership at the write.
Design Choices And Trade-Offs
Use Server Functions for UI-adjacent mutations that belong to your Next.js app: creating records from forms, toggling preferences, updating profile data, and invalidating pages that the App Router renders. Use Route Handlers when you need a public HTTP API, webhook target, non-form clients, custom status codes, or a protocol that is clearer as explicit REST or RPC.
Server Functions reduce boilerplate because you do not manually serialize a request body, call fetch, parse JSON, and then refresh UI state. The trade-off is tighter coupling to React and Next.js conventions. That coupling is useful for product forms, but less useful for integrations that need stable external contracts.
Returning state keeps expected validation failures close to the form. Throwing errors moves control to an error boundary and is better reserved for authorization failures, unavailable dependencies, programming defects, or cases where the current UI cannot continue. Redirecting is clean after successful create or update flows, but it prevents code after redirect from running.
Failure Modes And Troubleshooting
- Symptom: the action receives
nullfor a field. Cause: the input has noname, is disabled, or the form control is outside the submitted form. Diagnose: log bounded field names withArray.from(formData.keys()). Correct: add the expectedname, usereadOnlyinstead ofdisabledwhen a value must submit, or associate the control with the form. - Symptom: a validation message never appears. Cause:
useActionStateis not used in a Client Component, or the action signature forgot the leading previous-state parameter. Diagnose: inspect the function parameters and confirm the component has"use client". Correct: return a serializable state object and render it in anaria-liveregion. - Symptom: the database changed but the page still shows old data. Cause: cached route output or cached data was not invalidated. Diagnose: reload the route, compare direct database state, and check whether the page uses cached fetches or tags. Correct: call
revalidatePathor tag-based invalidation before returning or redirecting. - Symptom: a user edits another user’s record by changing a hidden field or replaying a request. Cause: trusting form payload identity. Diagnose: replay the POST with a different id in a local test. Correct: derive the user from the session and authorize the requested record inside the Server Function.
Security, Performance, And Reliability
Server Functions are reachable through network POST requests, so treat every argument as hostile input even when the only visible trigger is your form. Validate shape, length, enum values, ownership, rate limits, and idempotency. Keep secrets and database clients in server-only modules, but remember that server-only placement is not authorization by itself.
Performance depends on how much work the mutation performs before responding. Keep the critical path short: validate, authorize, write, invalidate, and return. Move email, indexing, or analytics fan-out to a queue when the user does not need it to finish synchronously. For reliability, make duplicate submissions harmless with unique constraints, idempotency keys, or transaction checks.
Hands-On Lab
Prerequisites: a Next.js App Router project, Node installed, and a route where you can add app/lab/actions.ts, app/lab/page.tsx, and an optional Client Component.
- Create
app/lab/actions.tswith a"use server"action that readstitle, trims it, rejects values shorter than three characters, and returns{ ok, message }. - Create
app/lab/lab-form.tsxas a Client Component usinguseActionState. Render one input, one submit button disabled by the pending flag, and one polite status paragraph. - Render the form from
app/lab/page.tsx. Start the dev server and open/lab. - Submit
Hi. Verification: the status paragraph reports the validation error and no durable write function is called. - Submit
Release checklist. Verification: the status paragraph reports success and the normalized title is the value used by the server. - Temporarily remove the input
name. Verification: the server sees an empty title. Restore thenameduring cleanup.
Cleanup: delete the lab route files or keep them behind a development-only route. If you connected a database, remove test rows and roll back any schema changes created only for the lab.
Assessment
- A form uses
useActionState, but the server function still expects onlyformData. What bug appears, and how should the signature change? - You create a post and immediately redirect to its detail page, but the old title appears. Where should cache invalidation happen relative to
redirect, and why? - Compare using a Server Function versus a Route Handler for a mobile app that must create the same resource. Which interface is more stable for that client?
- Design a duplicate-submit defense for a payment-like mutation. Which invariant belongs in the database or transaction layer?
- Explain why hiding
postIdinbinddoes not remove the need for authorization.
Summary
Server Functions make form-driven mutations feel native to the App Router: the form submits data, the server performs trusted work, React receives updated state or UI, and Next.js can revalidate affected routes. The mechanism is powerful because it joins mutation, rendering, and cache refresh, but that same closeness requires discipline. Validate the actual FormData, authorize every record at the write, return serializable state for expected errors, invalidate cached views before navigation, and choose Route Handlers when the consumer is an external HTTP client.
