Server Actions — Mutations Without APIs
The Idea
A server action is a function marked "use server" that runs on the server but can be called from your components — even from a plain <form>. No endpoint, no fetch, no JSON plumbing:
// app/actions.js
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createCourse(formData) {
const title = formData.get("title");
const price = Number(formData.get("price"));
if (!title || title.length < 3) {
return { error: "Title must be at least 3 characters" };
}
await db.course.create({ data: { title, price } });
revalidatePath("/courses"); // refresh the cached listing
return { success: true };
}Calling From a Form — Works Without JS
// app/courses/new/page.js — server component!
import { createCourse } from "@/app/actions";
export default function NewCoursePage() {
return (
<form action={createCourse}>
<input name="title" required />
<input name="price" type="number" />
<button>Create</button>
</form>
);
}The form posts to the action; Next serialises it, runs the function server-side, revalidates, and re-renders. It even works before hydration or with JavaScript disabled — progressive enhancement for free.
Feedback UI with useActionState
For errors, success messages, and pending states, the client wrapper:
"use client";
import { useActionState } from "react";
import { createCourse } from "@/app/actions";
export default function CourseForm() {
const [state, formAction, isPending] = useActionState(
async (prev, formData) => createCourse(formData),
null
);
return (
<form action={formAction}>
<input name="title" required />
<input name="price" type="number" />
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="ok">Course created!</p>}
<button disabled={isPending}>
{isPending ? "Creating…" : "Create"}
</button>
</form>
);
}Actions From Buttons
"use client";
import { deleteCourse } from "@/app/actions";
export function DeleteButton({ id }) {
return (
<button onClick={async () => {
if (confirm("Delete this course?")) {
await deleteCourse(id); // just call it — it runs on the server
}
}}>
Delete
</button>
);
}Security — Actions Are Public Endpoints
Every server action is callable over HTTP by anyone who finds it. Treat each one like an API route:
"use server";
import { auth } from "@/lib/auth";
export async function deleteCourse(id) {
const session = await auth();
if (!session?.user?.isAdmin) {
throw new Error("unauthorised"); // check EVERY action
}
await db.course.delete({ where: { id } });
revalidatePath("/courses");
}Validate inputs (Zod again), check auth inside the action, and never trust arguments from the client. With that discipline, actions + revalidation replace a whole CRUD API layer for same-app mutations — mutate, revalidate, fresh UI, in one function.