Server Actions: Handle Forms Without a Separate API
1. Overview
Previously, to handle forms in Next.js, you had to write an API route (app/api/.../route.ts), then call fetch from the client to submit data. Server Actions eliminate that middleman step: you write a function that runs on the server, attach it directly to the action attribute of <form>, and you’re done.
Read this article and you’ll learn how to declare Server Actions, use them for forms with validation, handle pending/error states with useFormState and useFormStatus, revalidate data after submission, and avoid common mistakes when switching from API routes.
Note: Server Actions are an App Router feature (Next.js 13.4+), stable since Next.js 14. If you’re still using Pages Router, this article doesn’t apply.
2. Your First Server Action
A Server Action is an async function with the "use server" directive, which can be placed in a separate file or directly in a component (if that component is a Server Component).
// app/actions/customer-actions.ts
"use server";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
console.log("Creating customer:", name, email);
// Simulate DB save
}
// app/customers/new/page.tsx
import { createCustomer } from "@/app/actions/customer-actions";
export default function NewCustomerPage() {
return (
<form action={createCustomer}>
<input name="name" placeholder="Customer name" />
<input name="email" placeholder="Email" />
<button type="submit">Create</button>
</form>
);
}
"use server"at the top of the file tells Next.js that every export in this file is a Server Action, runs only on the server, and isn’t bundled into client-side JavaScript.action={createCustomer}attaches the function directly to the form. Next.js automatically serializes the form intoFormDataand calls the function on the server when submitted.- No need for
onSubmit,preventDefault, orfetch. The form works even before JavaScript finishes loading (progressive enhancement).
When you submit: the log appears in the server terminal (not the browser console), because the function runs on the server.
3. Returning Results to the Client with useFormState
In practice you need to display validation errors or a success message. useFormState (from react-dom) helps Server Actions return state to the client.
// app/actions/customer-actions.ts
"use server";
type ActionResult = { success: boolean; message: string };
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { success: false, message: "Name or email is missing." };
}
if (!email.includes("@")) {
return { success: false, message: "Email is invalid." };
}
// Simulate DB save
return { success: true, message: `Created customer ${name}.` };
}
// app/customers/new/page.tsx
"use client";
import { useFormState } from "react-dom";
import { createCustomer } from "@/app/actions/customer-actions";
const initialState = { success: false, message: "" };
export default function NewCustomerForm() {
const [state, formAction] = useFormState(createCustomer, initialState);
return (
<form action={formAction}>
<input name="name" placeholder="Customer name" />
<input name="email" placeholder="Email" />
<button type="submit">Create</button>
{state.message && (
<p style={{ color: state.success ? "green" : "red" }}>
{state.message}
</p>
)}
</form>
);
}
useFormState(action, initialState)wraps a Server Action, returns[state, formAction].stateis the most recent return value from the action,formActionis the actual function attached to<form action={...}>.- Server Action receives an additional first parameter,
prevState— required when used withuseFormState, even if you don’t use it. - Since it uses
useFormState(a React hook), this file must be a Client Component, so it needs"use client".
# Submit with missing email
> Name or email is missing.
# Submit valid
> Created customer Nguyễn Văn A.
4. Pending State with useFormStatus
When submitting, you want to disable the button and show a loading state. useFormStatus reads the pending state of the nearest parent form — but it must be in a separate child component.
// app/customers/new/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create"}
</button>
);
}
useFormStatusonly works when the component is rendered inside a<form>. If you place<SubmitButton />directly in the same component that contains<form>, it still reads correctly because React looks for the nearest parent form in the DOM tree, not within the same component.pendingistruewhile the Server Action is running on the server, helping you disable the button to prevent double-submission.
Note: Don’t call
useFormStatusdirectly in the component containing<form>and expect it to automatically know the form’s state — it actually does work because it searches the DOM tree, but separating it into a child component is best practice to avoid confusion when there are multiple forms on the same page.
5. Validating Data with Zod
Hand-validating with if/else quickly becomes messy. Use Zod to declare schemas clearly.
<!-- Not applicable — this is an npm dependency, see below -->
npm install zod
// app/actions/customer-actions.ts
"use server";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters."),
email: z.string().email("Email is invalid."),
});
type ActionResult = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const parsed = customerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
success: false,
message: "Data is invalid.",
errors: parsed.error.flatten().fieldErrors,
};
}
return { success: true, message: `Created customer ${parsed.data.name}.` };
}
safeParsereturns an object withsuccessanddata/error, doesn’t throw an exception — perfect for returning errors to the client instead of crashing the action.flatten().fieldErrorsreturns an object like{ name: ["Name must..."], email: [...] }, convenient for displaying errors directly under each input.- Define the schema once, reuse it for both client-side and server-side validation (if needed) — avoid duplicating logic.
6. Revalidating Data After Submission
After creating a customer, the customer list page (cached by Next.js) needs to update. Use revalidatePath or revalidateTag.
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
// ... save to DB
revalidatePath("/customers");
redirect("/customers");
}
revalidatePath("/customers")purges the cache for that page, the next access will re-render with fresh data.redirectthrows a special exception that Next.js catches to perform the redirect — call it afterrevalidatePath, and don’t wrap it intry/catch(which would swallow the error and block the redirect).- Both functions can only be called within Server Actions or Server Components, not from the client.
7. Calling Server Actions from Client Components (Outside Forms)
Server Actions don’t have to be attached to a <form>. You can call them directly like an async function, for example in onClick.
// app/customers/[id]/delete-button.tsx
"use client";
import { useState } from "react";
import { deleteCustomer } from "@/app/actions/customer-actions";
export function DeleteButton({ customerId }: { customerId: string }) {
const [isDeleting, setIsDeleting] = useState(false);
async function handleClick() {
setIsDeleting(true);
await deleteCustomer(customerId);
setIsDeleting(false);
}
return (
<button onClick={handleClick} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
);
}
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function deleteCustomer(id: string) {
console.log("Deleting customer id:", id);
revalidatePath("/customers");
}
- Server Actions are actually built by Next.js into hidden POST endpoints; the client calls them via auto-generated network requests — you don’t see fetch code but it’s there, visible in the Network tab.
- This is convenient for delete buttons, likes, toggle states — places where you don’t need a full
<form>.
8. Testing Server Actions
Since a Server Action is just an async function accepting FormData, you test it like a regular unit function, no HTTP mocking needed.
npm install -D vitest
// app/actions/customer-actions.test.ts
import { describe, it, expect } from "vitest";
import { createCustomer } from "./customer-actions";
function buildFormData(data: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(data)) {
fd.append(key, value);
}
return fd;
}
describe("createCustomer", () => {
it("returns error when email is invalid", async () => {
const formData = buildFormData({ name: "An", email: "not-valid" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(false);
expect(result.errors?.email).toContain("Email is invalid.");
});
it("succeeds with valid data", async () => {
const formData = buildFormData({ name: "An", email: "an@example.com" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(true);
expect(result.message).toContain("An");
});
});
buildFormDatasimulatesFormDataas the browser would send it, no need to render components or mock the server.- Since Server Actions are pure functions (input → output), tests run fast, no need to spin up a Next.js dev server.
Note: If your Server Action calls
redirectorrevalidatePath, tests will throw errors because those functions only work in Next.js’s real request context. Separate business logic (validation, calculations) from calls toredirect/revalidatePathto make testing easier.
9. Common Mistakes
Forgetting "use server" or putting it in the wrong place. The directive must be at the top of the file (or at the top of the function if declared inline in a Server Component). Without it, Next.js treats the function as regular code, bundles it to the client, and errors on execution.
Using useFormState/useFormStatus in a Server Component. These hooks are from react-dom, only run in Client Components. Forgetting "use client" will cause a build error.
Returning complex objects (class instances, unserializable Dates) from Server Actions. Return data must be serializable over the network, like JSON. Returning a raw Date sometimes causes subtle bugs — convert to string first.
Calling Server Actions for everything, including logic that doesn’t need the server. If it’s just simple validation without database access, handle it on the client—it’ll be faster and avoid unnecessary round-trips.
No fallback when JavaScript fails. Server Actions support progressive enhancement, but only if you use <form action={...}> correctly. If you preventDefault() and call the action manually, you lose that benefit.
10. When NOT to Use Server Actions
Server Actions are suitable for internal CRUD forms in your Next.js app. But if you need:
- A public API for mobile apps or third parties to call — write a real Route Handler (
route.ts) with clear versioning and auth. - Streaming responses or handling very large file uploads with fine-grained control — Route Handlers are more flexible.
- Logic that needs to run independently or be reused outside Next.js context (e.g., workers, cron jobs) — separate into a dedicated service and have Server Actions call it.
11. Conclusion
Server Actions let you write forms that process data on the server without building a separate API route, cutting down on fetch/JSON boilerplate. Combined with useFormState for displaying errors, useFormStatus for showing pending states, and revalidatePath for cache updates, you get a clean CRUD flow that works even before JavaScript loads. Use Zod for clear validation, and remember to separate actions into real Route Handlers if you need a public API for external consumers.