Introduction
Forms are a common source of application complexity. They need to validate input, report errors, handle network failures, and update the interface after successful submission.
In the Next.js App Router, Server Actions allow a form to invoke server-side code without requiring a separate client-side request handler for every operation. They simplify some workflows, but they do not remove the need for validation, authorization, and careful error handling.
1. Create a server-side action
A Server Action can be defined in a module marked with “use server”:
// app/actions.ts
"use server";
export async function createTask(formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || !title.trim()) {
return {
success: false,
message: "A task title is required.",
};
}
// Persist the task using the application's data layer.
return {
success: true,
message: "Task created.",
};
}
The persistence step is intentionally omitted here. In a real application, the action should validate the request, authorize the user, and save the task through the application’s trusted data-access layer.
A Server Action is reachable through a request boundary. It must not assume that the caller has used the intended UI.
2. Connect the action to a form
A form can reference the server action directly:
import { createTask } from "./actions";
export default function NewTaskPage() {
return (
<form action={createTask}>
<label htmlFor="title">Task title</label>
<input id="title" name="title" required />
<button type="submit">Create task</button>
</form>
);
}
The browser submits the form data to the action. This can reduce the amount of client-side request plumbing required for a straightforward form.
Native HTML validation provides a useful first layer of feedback, but it is not a security boundary. Users can bypass browser validation and send crafted requests directly.
3. Validate on the server
Client-side validation improves usability, while server-side validation protects the application.
A schema library can help validate incoming data:
"use server";
import { z } from "zod";
const TaskSchema = z.object({
title: z.string().trim().min(1).max(120),
});
export async function createTask(formData: FormData) {
const parsed = TaskSchema.safeParse({
title: formData.get("title"),
});
if (!parsed.success) {
return {
success: false,
message: "Please enter a valid task title.",
};
}
// Authenticate, authorize, and save parsed.data.title.
return {
success: true,
message: "Task created.",
};
}
The schema defines a basic field contract. Real applications may need additional rules, such as uniqueness, project membership, or restrictions based on the current user’s permissions.
Do not expose raw database errors or internal exception messages to the user. Return clear validation feedback and log technical details securely on the server.
4. Display pending and error states
For more interactive forms, React’s useActionState can manage action results, while useFormStatus can help a nested submit button reflect the submission state.
The precise action signature should match the React and Next.js versions in use. A simplified pattern is:
"use client";
import { useActionState } from "react";
import { createTask } from "./actions";
const initialState = {
success: false,
message: "",
};
export function TaskForm() {
const [state, formAction, pending] =
useActionState(createTask, initialState);
return (
<form action={formAction}>
<label htmlFor="title">Task title</label>
<input id="title" name="title" required />
<button disabled={pending} type="submit">
{pending ? "Saving..." : "Create task"}
</button>
{state.message && <p role="status">{state.message}</p>}
</form>
);
}
Because useActionState passes previous state as an argument to the action, adapt the server action’s signature accordingly when using this pattern. The action should accept the previous state and then the submitted form data.
Also distinguish validation errors from unexpected server failures. A useful form should explain what the user can correct and avoid silently losing their input.
5. Treat authentication and authorization as mandatory
Before performing a mutation, the server action should establish the identity of the caller and verify permission to perform the requested operation.
For example, creating a task inside a project may require verifying that the current user belongs to that project. Do not rely only on hiding a submit button or checking a route on the client.
Use the authenticated identity from the trusted server-side session rather than accepting a user ID from the form as proof of ownership.
6. Refresh the right data after a mutation
After successfully creating or updating data, the application may need to refresh cached pages or redirect the user. Next.js provides APIs such as revalidatePath, revalidateTag, and redirect for appropriate server-side workflows.
Choose invalidation based on the data that actually changed. If a mutation affects a project list and a task detail page, ensure both views remain consistent with the application’s caching strategy.
Conclusion
Server Actions can simplify form submissions and keep mutations close to server-side data access. Reliable implementations still validate untrusted input, verify authorization, provide clear pending and error states, and invalidate affected data deliberately.