Topic 07 / 13

Server Actions — Mutations Without APIs

~10 min read  //  Next.js Series  //  Coding India

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.